From 41f9a58c27d36fed9d58dbaccb6322fe6448583b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 1 Jan 2026 12:04:57 +0000 Subject: [PATCH 001/138] ignore: update download stats 2026-01-01 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index db2e14f7a740..2d2b7c476cd8 100644 --- a/STATS.md +++ b/STATS.md @@ -187,3 +187,4 @@ | 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | | 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | | 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) | +| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) | From dc8586371cdd75f2cf8d07e317a3688ec6e02794 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 2 Jan 2026 00:35:04 +0700 Subject: [PATCH 002/138] fix(server): add Content-Type headers for proxied static assets (#6587) --- packages/opencode/src/server/server.ts | 34 +++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f31b8ec44f50..acd4ad5c44b7 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -2657,12 +2657,44 @@ export namespace Server { }, ) .all("/*", async (c) => { - return proxy(`https://app.opencode.ai${c.req.path}`, { + const path = c.req.path + const response = await proxy(`https://app.opencode.ai${path}`, { ...c.req, headers: { host: "app.opencode.ai", }, }) + // Cloudflare doesn't return Content-Type for static assets, so we need to add it + const mimeTypes: Record = { + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".wasm": "application/wasm", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + } + for (const [ext, mime] of Object.entries(mimeTypes)) { + if (path.endsWith(ext)) { + const headers = new Headers(response.headers) + headers.set("Content-Type", mime) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + } + } + return response }), ) From 35fff0ca702faf9ede6bb1cfa4a431fdf7f710ff Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 1 Jan 2026 17:35:37 +0000 Subject: [PATCH 003/138] chore: generate --- 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 998477e73827..bea2c35271b8 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 6f7ddaccab1f..8bb055d704a2 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 3b033245783899adefe2a42157e4e34cf8fb1234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lek=C3=AB=20Dobruna?= Date: Thu, 1 Jan 2026 18:39:21 +0100 Subject: [PATCH 004/138] fix: display error if invalid agent is used in a command (#6578) --- packages/opencode/src/session/prompt.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6bf71ef36534..80779f468d15 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1331,7 +1331,7 @@ export namespace SessionPrompt { } if (command.agent) { const cmdAgent = await Agent.get(command.agent) - if (cmdAgent.model) { + if (cmdAgent?.model) { return cmdAgent.model } } @@ -1353,6 +1353,16 @@ export namespace SessionPrompt { throw e } const agent = await Agent.get(agentName) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } const parts = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true From 7a3ff5b98f5b8fbe69c217daebaef6bccb7d9a2c Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Thu, 1 Jan 2026 23:33:18 +0530 Subject: [PATCH 005/138] fix(session): check for context overflow mid-turn in finish-step (#6480) --- packages/opencode/src/session/processor.ts | 8 + packages/opencode/src/session/prompt.ts | 8 + .../opencode/test/session/compaction.test.ts | 251 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 packages/opencode/test/session/compaction.test.ts diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 78871630c65b..567b9647934d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -13,6 +13,7 @@ import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" +import { SessionCompaction } from "./compaction" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -31,6 +32,7 @@ export namespace SessionProcessor { let snapshot: string | undefined let blocked = false let attempt = 0 + let needsCompaction = false const result = { get message() { @@ -41,6 +43,7 @@ export namespace SessionProcessor { }, async process(streamInput: LLM.StreamInput) { log.info("process") + needsCompaction = false const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { @@ -279,6 +282,9 @@ export namespace SessionProcessor { sessionID: input.sessionID, messageID: input.assistantMessage.parentID, }) + if (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) { + needsCompaction = true + } break case "text-start": @@ -339,6 +345,7 @@ export namespace SessionProcessor { }) continue } + if (needsCompaction) break } } catch (e: any) { log.error("process", { @@ -398,6 +405,7 @@ export namespace SessionProcessor { } input.assistantMessage.time.completed = Date.now() await Session.updateMessage(input.assistantMessage) + if (needsCompaction) return "compact" if (blocked) return "stop" if (input.assistantMessage.error) return "stop" return "continue" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 80779f468d15..3171192a3523 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -549,6 +549,14 @@ export namespace SessionPrompt { model, }) if (result === "stop") break + if (result === "compact") { + await SessionCompaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + }) + } continue } SessionCompaction.prune({ sessionID }) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts new file mode 100644 index 000000000000..9070428ea54f --- /dev/null +++ b/packages/opencode/test/session/compaction.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { SessionCompaction } from "../../src/session/compaction" +import { Token } from "../../src/util/token" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" +import { Session } from "../../src/session" +import type { Provider } from "../../src/provider/provider" + +Log.init({ print: false }) + +function createModel(opts: { context: number; output: number; cost?: Provider.Model["cost"] }): Provider.Model { + return { + id: "test-model", + providerID: "test", + name: "Test", + limit: { + context: opts.context, + output: opts.output, + }, + cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false }, + output: { text: true, image: false, audio: false, video: false }, + }, + api: { npm: "@ai-sdk/anthropic" }, + options: {}, + } as Provider.Model +} + +describe("session.compaction.isOverflow", () => { + test("returns true when token count exceeds usable context", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true) + }, + }) + }) + + test("returns false when token count within usable context", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 200_000, output: 32_000 }) + const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false) + }, + }) + }) + + test("includes cache.read in token count", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const tokens = { input: 50_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true) + }, + }) + }) + + test("returns false when model context limit is 0", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 0, output: 32_000 }) + const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false) + }, + }) + }) + + test("returns false when compaction.auto is disabled", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + compaction: { auto: false }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false) + }, + }) + }) +}) + +describe("util.token.estimate", () => { + test("estimates tokens from text (4 chars per token)", () => { + const text = "x".repeat(4000) + expect(Token.estimate(text)).toBe(1000) + }) + + test("estimates tokens from larger text", () => { + const text = "y".repeat(20_000) + expect(Token.estimate(text)).toBe(5000) + }) + + test("returns 0 for empty string", () => { + expect(Token.estimate("")).toBe(0) + }) +}) + +describe("session.getUsage", () => { + test("normalizes standard usage to token format", () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + }, + }) + + expect(result.tokens.input).toBe(1000) + expect(result.tokens.output).toBe(500) + expect(result.tokens.reasoning).toBe(0) + expect(result.tokens.cache.read).toBe(0) + expect(result.tokens.cache.write).toBe(0) + }) + + test("extracts cached tokens to cache.read", () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + cachedInputTokens: 200, + }, + }) + + expect(result.tokens.input).toBe(800) + expect(result.tokens.cache.read).toBe(200) + }) + + test("handles anthropic cache write metadata", () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + }, + metadata: { + anthropic: { + cacheCreationInputTokens: 300, + }, + }, + }) + + expect(result.tokens.cache.write).toBe(300) + }) + + test("does not subtract cached tokens for anthropic provider", () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + cachedInputTokens: 200, + }, + metadata: { + anthropic: {}, + }, + }) + + expect(result.tokens.input).toBe(1000) + expect(result.tokens.cache.read).toBe(200) + }) + + test("handles reasoning tokens", () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + reasoningTokens: 100, + }, + }) + + expect(result.tokens.reasoning).toBe(100) + }) + + test("handles undefined optional values gracefully", () => { + const model = createModel({ context: 100_000, output: 32_000 }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }, + }) + + expect(result.tokens.input).toBe(0) + expect(result.tokens.output).toBe(0) + expect(result.tokens.reasoning).toBe(0) + expect(result.tokens.cache.read).toBe(0) + expect(result.tokens.cache.write).toBe(0) + expect(Number.isNaN(result.cost)).toBe(false) + }) + + test("calculates cost correctly", () => { + const model = createModel({ + context: 100_000, + output: 32_000, + cost: { + input: 3, + output: 15, + cache: { read: 0.3, write: 3.75 }, + }, + }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 1_000_000, + outputTokens: 100_000, + totalTokens: 1_100_000, + }, + }) + + expect(result.cost).toBe(3 + 1.5) + }) +}) From 8ebc601ea28794da4367e3bf4373bf5c4c7f168e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 12:46:25 -0600 Subject: [PATCH 006/138] core: use --no-cache when behind proxy to avoid hangs --- packages/opencode/src/bun/index.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 55bbf7b4170b..fe2f0dec3710 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -73,8 +73,24 @@ export namespace BunProc { }) if (parsed.dependencies[pkg] === version) return mod + const proxied = !!( + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy + ) + // Build command arguments - const args = ["add", "--force", "--exact", "--cwd", Global.Path.cache, pkg + "@" + version] + const args = [ + "add", + "--force", + "--exact", + // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) + ...(proxied ? ["--no-cache"] : []), + "--cwd", + Global.Path.cache, + pkg + "@" + version, + ] // Let Bun handle registry resolution: // - If .npmrc files exist, Bun will use them automatically From e50365425246ea94cebfaefaf2d65c49ddb2b9ff Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 13:00:39 -0600 Subject: [PATCH 007/138] core: make installdeps non blocking --- packages/opencode/src/cli/cmd/acp.ts | 7 ------- packages/opencode/src/config/config.ts | 4 +--- packages/opencode/src/index.ts | 10 ++++------ 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 060d0d5a1562..30e919d999a3 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -9,13 +9,6 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) -process.on("unhandledRejection", (reason, promise) => { - log.error("Unhandled rejection", { - promise, - reason, - }) -}) - export const AcpCommand = cmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 34bb6654ecd7..f66b467905e3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -90,7 +90,6 @@ export namespace Config { log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) } - const promises: Promise[] = [] for (const dir of unique(directories)) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.jsonc", "opencode.json"]) { @@ -103,13 +102,12 @@ export namespace Config { } } - promises.push(installDependencies(dir)) + installDependencies(dir) result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadMode(dir)) result.plugin.push(...(await loadPlugin(dir))) } - await Promise.allSettled(promises) // Migrate deprecated mode field to agent field for (const [name, mode] of Object.entries(result.mode)) { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 03ccf76042f2..0ccc4e94a661 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,6 +28,8 @@ import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +const cancel = new AbortController() + process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, @@ -150,10 +152,6 @@ try { console.error(e) } process.exitCode = 1 -} finally { - // Some subprocesses don't react properly to SIGTERM and similar signals. - // Most notably, some docker-container-based MCP servers don't handle such signals unless - // run using `docker run --init`. - // Explicitly exit to avoid any hanging subprocesses. - process.exit() } + +cancel.abort() From 5138f9250e685780e63cb3b178fc2654557655a5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 13:05:08 -0600 Subject: [PATCH 008/138] ignore: keep the process exit logic --- packages/opencode/src/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0ccc4e94a661..03ccf76042f2 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,8 +28,6 @@ import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" -const cancel = new AbortController() - process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, @@ -152,6 +150,10 @@ try { console.error(e) } process.exitCode = 1 +} finally { + // Some subprocesses don't react properly to SIGTERM and similar signals. + // Most notably, some docker-container-based MCP servers don't handle such signals unless + // run using `docker run --init`. + // Explicitly exit to avoid any hanging subprocesses. + process.exit() } - -cancel.abort() From 9be944a2d212b5104beeefeb8508566c4d47a1e1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 13:25:26 -0600 Subject: [PATCH 009/138] core: fix stats command day calculation and time filtering --- packages/opencode/src/cli/cmd/stats.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 81e250d208fd..d78c4f0abd1d 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -118,6 +118,12 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin return Date.now() - days * MS_IN_DAY })() + const windowDays = (() => { + if (days === undefined) return + if (days === 0) return 1 + return days + })() + let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions if (projectFilter !== undefined) { @@ -159,6 +165,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin } if (filteredSessions.length === 0) { + stats.days = windowDays ?? 0 return stats } @@ -231,7 +238,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning, sessionToolUsage, sessionModelUsage, - earliestTime: session.time.created, + earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, latestTime: session.time.updated, } }) @@ -271,13 +278,14 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin } } - const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY)) + const rangeDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY)) + const effectiveDays = windowDays ?? rangeDays stats.dateRange = { earliest: earliestTime, latest: latestTime, } - stats.days = actualDays - stats.costPerDay = stats.totalCost / actualDays + stats.days = effectiveDays + stats.costPerDay = stats.totalCost / effectiveDays const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0 sessionTotalTokens.sort((a, b) => a - b) From 8b35d56a48d67ff40339f064c41244a4cf80dec5 Mon Sep 17 00:00:00 2001 From: alcpereira <48070464+alcpereira@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:04:31 +0000 Subject: [PATCH 010/138] fix: remove outdated Haiku filter for GitHub Copilot (#6593) --- packages/opencode/src/provider/provider.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 983a08272237..93d2104e25c0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1026,10 +1026,6 @@ export namespace Provider { "gemini-2.5-flash", "gpt-5-nano", ] - // claude-haiku-4.5 is considered a premium model in github copilot, we shouldn't use premium requests for title gen - if (providerID === "github-copilot") { - priority = priority.filter((m) => m !== "claude-haiku-4.5") - } if (providerID.startsWith("opencode")) { priority = ["gpt-5-nano"] } From 5f2be55e54ba6d9f3677805629351c9d931a2d68 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 1 Jan 2026 16:10:09 -0500 Subject: [PATCH 011/138] docs: update zen processing fee --- packages/web/src/content/docs/zen.mdx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 891027aee0b2..1b2c9b091afe 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -142,7 +142,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions. :::note -Credit card fees are passed along at cost; we don't charge anything beyond that. +Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don't charge anything beyond that. ::: The free models: @@ -158,8 +158,7 @@ The free models: ### Auto-reload -If your balance goes below $5, Zen will automatically reload $20 (plus $1.23 -processing fee). +If your balance goes below $5, Zen will automatically reload $20. You can change the auto-reload amount. You can also disable auto-reload entirely. From dccb8875ad0e114242fce3dabc7f7e31c1bac29b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 15:18:32 -0600 Subject: [PATCH 012/138] core: fix import command regex --- packages/opencode/src/cli/cmd/import.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 65c1eec61c6c..9d7e8c56171e 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -31,9 +31,9 @@ export const ImportCommand = cmd({ const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") if (isUrl) { - const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) + const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/share\/([a-zA-Z0-9_-]+)/) if (!urlMatch) { - process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/s/`) + process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/share/`) process.stdout.write(EOL) return } From 351ddeed914d237138fc6f3f8b3d65d2e559357a Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 1 Jan 2026 17:54:11 -0500 Subject: [PATCH 013/138] Permission rework (#6319) Co-authored-by: Github Action Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> --- .github/workflows/test.yml | 6 +- .opencode/agent/git-committer.md | 10 - .opencode/opencode.jsonc | 8 +- bunfig.toml | 4 + flake.lock | 6 +- package.json | 3 +- packages/app/src/context/global-sync.tsx | 10 +- packages/app/src/context/permission.tsx | 14 +- packages/app/src/pages/layout.tsx | 2 +- packages/opencode/src/acp/agent.ts | 31 +- packages/opencode/src/agent/agent.ts | 322 ++---- packages/opencode/src/cli/cmd/agent.ts | 3 +- packages/opencode/src/cli/cmd/debug/agent.ts | 25 +- packages/opencode/src/cli/cmd/run.ts | 6 +- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - .../cli/cmd/tui/component/prompt/index.tsx | 6 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 42 +- .../src/cli/cmd/tui/routes/session/footer.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 941 ++++++++---------- .../cli/cmd/tui/routes/session/permission.tsx | 313 ++++++ .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 1 + packages/opencode/src/config/config.ts | 131 ++- packages/opencode/src/installation/index.ts | 1 + packages/opencode/src/permission/arity.ts | 163 +++ packages/opencode/src/permission/index.ts | 6 +- packages/opencode/src/permission/next.ts | 253 +++++ packages/opencode/src/plugin/index.ts | 1 + packages/opencode/src/server/server.ts | 52 +- packages/opencode/src/session/index.ts | 13 +- packages/opencode/src/session/llm.ts | 14 +- packages/opencode/src/session/processor.ts | 43 +- packages/opencode/src/session/prompt.ts | 213 ++-- packages/opencode/src/session/system.ts | 2 +- packages/opencode/src/tool/bash.ts | 100 +- packages/opencode/src/tool/codesearch.ts | 24 +- packages/opencode/src/tool/edit.ts | 114 +-- packages/opencode/src/tool/glob.ts | 12 +- packages/opencode/src/tool/grep.ts | 13 +- packages/opencode/src/tool/ls.ts | 11 +- packages/opencode/src/tool/lsp.ts | 9 +- packages/opencode/src/tool/patch.ts | 57 +- packages/opencode/src/tool/read.ts | 44 +- packages/opencode/src/tool/registry.ts | 24 - packages/opencode/src/tool/skill.ts | 140 ++- packages/opencode/src/tool/task.ts | 35 +- packages/opencode/src/tool/todo.ts | 22 +- packages/opencode/src/tool/tool.ts | 2 + packages/opencode/src/tool/webfetch.ts | 26 +- packages/opencode/src/tool/websearch.ts | 30 +- packages/opencode/src/tool/write.ts | 58 +- packages/opencode/test/agent/agent.test.ts | 468 +++++++-- packages/opencode/test/config/config.test.ts | 220 +++- packages/opencode/test/fixture/fixture.ts | 11 + .../opencode/test/permission/arity.test.ts | 33 + .../opencode/test/permission/next.test.ts | 652 ++++++++++++ packages/opencode/test/tool/bash.test.ts | 420 ++------ packages/opencode/test/tool/grep.test.ts | 1 + packages/opencode/test/tool/patch.test.ts | 8 +- packages/opencode/test/tool/read.test.ts | 105 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 44 + packages/sdk/js/src/v2/gen/types.gen.ts | 278 +++--- packages/sdk/openapi.json | 4 + packages/ui/src/components/message-part.tsx | 19 +- packages/ui/src/components/session-turn.tsx | 24 +- packages/ui/src/context/data.tsx | 4 +- 66 files changed, 3587 insertions(+), 2075 deletions(-) delete mode 100644 .opencode/agent/git-committer.md create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx create mode 100644 packages/opencode/src/permission/arity.ts create mode 100644 packages/opencode/src/permission/next.ts create mode 100644 packages/opencode/test/permission/arity.test.ts create mode 100644 packages/opencode/test/permission/next.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac1a24fd5147..c39710bee8f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,11 +2,9 @@ name: test on: push: - branches-ignore: - - production + branches: + - dev pull_request: - branches-ignore: - - production workflow_dispatch: jobs: test: diff --git a/.opencode/agent/git-committer.md b/.opencode/agent/git-committer.md deleted file mode 100644 index 49c3e3de19f7..000000000000 --- a/.opencode/agent/git-committer.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: Use this agent when you are asked to commit and push code changes to a git repository. -mode: subagent ---- - -You commit and push to git - -Commit messages should be brief since they are used to generate release notes. - -Messages should say WHY the change was made and not WHAT was changed. diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index cbcbb0c65181..f547e874dd78 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,7 +10,13 @@ "options": {}, }, }, - "mcp": {}, + "permission": "ask", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + }, + }, "tools": { "github-triage": false, }, diff --git a/bunfig.toml b/bunfig.toml index b6874be144e8..36a21d9332a3 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,6 @@ [install] exact = true + +[test] +root = "./do-not-run-tests-from-root" + diff --git a/flake.lock b/flake.lock index 2a06923c2de9..ad18c3c633b0 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1767151656, - "narHash": "sha256-ujL2AoYBnJBN262HD95yer7QYUmYp5kFZGYbyCCKxq8=", + "lastModified": 1767242400, + "narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f665af0cdb70ed27e1bd8f9fdfecaf451260fc55", + "rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a", "type": "github" }, "original": { diff --git a/package.json b/package.json index aa7031bec725..577ca4650973 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "typecheck": "bun turbo typecheck", "prepare": "husky", "random": "echo 'Random script'", - "hello": "echo 'Hello World!'" + "hello": "echo 'Hello World!'", + "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { "packages": [ diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index dd040d8d5da1..92de0a636892 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -15,7 +15,7 @@ import { type McpStatus, type LspStatus, type VcsInfo, - type Permission, + type PermissionRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -46,7 +46,7 @@ type State = { [sessionID: string]: Todo[] } permission: { - [sessionID: string]: Permission[] + [sessionID: string]: PermissionRequest[] } mcp: { [name: string]: McpStatus @@ -168,7 +168,7 @@ function createGlobalSync() { vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), permission: () => sdk.permission.list().then((x) => { - const grouped: Record = {} + const grouped: Record = {} for (const perm of x.data ?? []) { if (!perm?.id || !perm.sessionID) continue const existing = grouped[perm.sessionID] @@ -349,7 +349,7 @@ function createGlobalSync() { setStore("vcs", { branch: event.properties.branch }) break } - case "permission.updated": { + case "permission.asked": { const sessionID = event.properties.sessionID const permissions = store.permission[sessionID] if (!permissions) { @@ -375,7 +375,7 @@ function createGlobalSync() { case "permission.replied": { const permissions = store.permission[event.properties.sessionID] if (!permissions) break - const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + const result = Binary.search(permissions, event.properties.requestID, (p) => p.id) if (!result.found) break setStore( "permission", diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index a0ad1ee05b64..061470361167 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,7 +1,7 @@ import { createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import type { Permission } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import { persisted } from "@/utils/persist" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" @@ -14,10 +14,8 @@ type PermissionRespondFn = (input: { directory?: string }) => void -const AUTO_ACCEPT_TYPES = new Set(["edit", "write"]) - -function shouldAutoAccept(perm: Permission) { - return AUTO_ACCEPT_TYPES.has(perm.type) +function shouldAutoAccept(perm: PermissionRequest) { + return perm.permission === "edit" } export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ @@ -48,7 +46,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) } - function respondOnce(permission: Permission, directory?: string) { + function respondOnce(permission: PermissionRequest, directory?: string) { if (responded.has(permission.id)) return responded.add(permission.id) respond({ @@ -65,7 +63,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const unsubscribe = globalSDK.event.listen((e) => { const event = e.details - if (event?.type !== "permission.updated") return + if (event?.type !== "permission.asked") return const perm = event.properties if (!isAutoAccepting(perm.sessionID)) return @@ -98,7 +96,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return { ready, respond, - autoResponds(permission: Permission) { + autoResponds(permission: PermissionRequest) { return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission) }, isAutoAccepting, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0bcf0f7a2d2a..7aa1e244856a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -175,7 +175,7 @@ export default function Layout(props: ParentProps) { const permissionAlertCooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { - if (e.details?.type !== "permission.updated") return + if (e.details?.type !== "permission.asked") return const directory = e.name const perm = e.details.properties if (permission.autoResponds(perm)) return diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index e6419dd7665c..bab4d2b8216b 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -71,19 +71,19 @@ export namespace ACP { this.config.sdk.event.subscribe({ directory }).then(async (events) => { for await (const event of events.stream) { switch (event.type) { - case "permission.updated": + case "permission.asked": try { const permission = event.properties const res = await this.connection .requestPermission({ sessionId, toolCall: { - toolCallId: permission.callID ?? permission.id, + toolCallId: permission.tool?.callID ?? permission.id, status: "pending", - title: permission.title, + title: permission.permission, rawInput: permission.metadata, - kind: toToolKind(permission.type), - locations: toLocations(permission.type, permission.metadata), + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), }, options, }) @@ -93,28 +93,25 @@ export namespace ACP { permissionID: permission.id, sessionID: permission.sessionID, }) - await this.config.sdk.permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", + await this.config.sdk.permission.reply({ + requestID: permission.id, + reply: "reject", directory, }) return }) if (!res) return if (res.outcome.outcome !== "selected") { - await this.config.sdk.permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", + await this.config.sdk.permission.reply({ + requestID: permission.id, + reply: "reject", directory, }) return } - await this.config.sdk.permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: res.outcome.optionId as "once" | "always" | "reject", + await this.config.sdk.permission.reply({ + requestID: permission.id, + reply: res.outcome.optionId as "once" | "always" | "reject", directory, }) } catch (err) { diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ad665e5d6eed..db49b0f4fc5b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,16 +4,14 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" -import { mergeDeep } from "remeda" -import { Log } from "../util/log" - -const log = Log.create({ service: "agent" }) import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" +import { PermissionNext } from "@/permission/next" +import { mergeDeep, pipe, sortBy, values } from "remeda" export namespace Agent { export const Info = z @@ -23,18 +21,10 @@ export namespace Agent { mode: z.enum(["subagent", "primary", "all"]), native: z.boolean().optional(), hidden: z.boolean().optional(), - default: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), - permission: z.object({ - edit: Config.Permission, - bash: z.record(z.string(), Config.Permission), - skill: z.record(z.string(), Config.Permission), - webfetch: Config.Permission.optional(), - doom_loop: Config.Permission.optional(), - external_directory: Config.Permission.optional(), - }), + permission: PermissionNext.Ruleset, model: z .object({ modelID: z.string(), @@ -42,9 +32,8 @@ export namespace Agent { }) .optional(), prompt: z.string().optional(), - tools: z.record(z.string(), z.boolean()), options: z.record(z.string(), z.any()), - maxSteps: z.number().int().positive().optional(), + steps: z.number().int().positive().optional(), }) .meta({ ref: "Agent", @@ -53,113 +42,74 @@ export namespace Agent { const state = Instance.state(async () => { const cfg = await Config.get() - const defaultTools = cfg.tools ?? {} - const defaultPermission: Info["permission"] = { - edit: "allow", - bash: { - "*": "allow", - }, - skill: { - "*": "allow", - }, - webfetch: "allow", + + const defaults = PermissionNext.fromConfig({ + "*": "allow", doom_loop: "ask", external_directory: "ask", - } - const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) - - const planPermission = mergeAgentPermissions( - { - edit: "deny", - bash: { - "cut*": "allow", - "diff*": "allow", - "du*": "allow", - "file *": "allow", - "find * -delete*": "ask", - "find * -exec*": "ask", - "find * -fprint*": "ask", - "find * -fls*": "ask", - "find * -fprintf*": "ask", - "find * -ok*": "ask", - "find *": "allow", - "git diff*": "allow", - "git log*": "allow", - "git show*": "allow", - "git status*": "allow", - "git branch": "allow", - "git branch -v": "allow", - "grep*": "allow", - "head*": "allow", - "less*": "allow", - "ls*": "allow", - "more*": "allow", - "pwd*": "allow", - "rg*": "allow", - "sort --output=*": "ask", - "sort -o *": "ask", - "sort*": "allow", - "stat*": "allow", - "tail*": "allow", - "tree -o *": "ask", - "tree*": "allow", - "uniq*": "allow", - "wc*": "allow", - "whereis*": "allow", - "which*": "allow", - "*": "ask", - }, - webfetch: "allow", - }, - cfg.permission ?? {}, - ) + }) + const user = PermissionNext.fromConfig(cfg.permission ?? {}) const result: Record = { build: { name: "build", - tools: { ...defaultTools }, options: {}, - permission: agentPermission, + permission: PermissionNext.merge(defaults, user), mode: "primary", native: true, }, plan: { name: "plan", options: {}, - permission: planPermission, - tools: { - ...defaultTools, - }, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + edit: { + "*": "deny", + ".opencode/plan/*.md": "allow", + }, + }), + user, + ), mode: "primary", native: true, }, general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - tools: { - todoread: false, - todowrite: false, - ...defaultTools, - }, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + todoread: "deny", + todowrite: "deny", + }), + user, + ), options: {}, - permission: agentPermission, mode: "subagent", native: true, hidden: true, }, explore: { name: "explore", - tools: { - todoread: false, - todowrite: false, - edit: false, - write: false, - ...defaultTools, - }, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + }), + user, + ), description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, prompt: PROMPT_EXPLORE, options: {}, - permission: agentPermission, mode: "subagent", native: true, }, @@ -169,11 +119,14 @@ export namespace Agent { native: true, hidden: true, prompt: PROMPT_COMPACTION, - tools: { - "*": false, - }, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + }), + user, + ), options: {}, - permission: agentPermission, }, title: { name: "title", @@ -181,9 +134,14 @@ export namespace Agent { options: {}, native: true, hidden: true, - permission: agentPermission, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + }), + user, + ), prompt: PROMPT_TITLE, - tools: {}, }, summary: { name: "summary", @@ -191,11 +149,17 @@ export namespace Agent { options: {}, native: true, hidden: true, - permission: agentPermission, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + }), + user, + ), prompt: PROMPT_SUMMARY, - tools: {}, }, } + for (const [key, value] of Object.entries(cfg.agent ?? {})) { if (value.disable) { delete result[key] @@ -206,74 +170,22 @@ export namespace Agent { item = result[key] = { name: key, mode: "all", - permission: agentPermission, + permission: PermissionNext.merge(defaults, user), options: {}, - tools: {}, native: false, } - const { - name, - model, - prompt, - tools, - description, - temperature, - top_p, - mode, - permission, - color, - maxSteps, - ...extra - } = value - item.options = { - ...item.options, - ...extra, - } - if (model) item.model = Provider.parseModel(model) - if (prompt) item.prompt = prompt - if (tools) - item.tools = { - ...item.tools, - ...tools, - } - item.tools = { - ...defaultTools, - ...item.tools, - } - if (description) item.description = description - if (temperature != undefined) item.temperature = temperature - if (top_p != undefined) item.topP = top_p - if (mode) item.mode = mode - if (color) item.color = color - // just here for consistency & to prevent it from being added as an option - if (name) item.name = name - if (maxSteps != undefined) item.maxSteps = maxSteps - - if (permission ?? cfg.permission) { - item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) - } + if (value.model) item.model = Provider.parseModel(value.model) + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.name = value.options?.name ?? item.name + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } - - // Mark the default agent - const defaultName = cfg.default_agent ?? "build" - const defaultCandidate = result[defaultName] - if (defaultCandidate && defaultCandidate.mode !== "subagent") { - defaultCandidate.default = true - } else { - // Fall back to "build" if configured default is invalid - if (result["build"]) { - result["build"].default = true - } - } - - const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0 - if (!hasPrimaryAgents) { - throw new Config.InvalidError({ - path: "config", - message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.", - }) - } - return result }) @@ -282,13 +194,16 @@ export namespace Agent { } export async function list() { - return state().then((x) => Object.values(x)) + const cfg = await Config.get() + return pipe( + await state(), + values(), + sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]), + ) } - export async function defaultAgent(): Promise { - const agents = await state() - const defaultCandidate = Object.values(agents).find((a) => a.default) - return defaultCandidate?.name ?? "build" + export async function defaultAgent() { + return state().then((x) => Object.keys(x)[0]) } export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { @@ -329,70 +244,3 @@ export namespace Agent { return result.object } } - -function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] { - if (typeof basePermission.bash === "string") { - basePermission.bash = { - "*": basePermission.bash, - } - } - if (typeof overridePermission.bash === "string") { - overridePermission.bash = { - "*": overridePermission.bash, - } - } - - if (typeof basePermission.skill === "string") { - basePermission.skill = { - "*": basePermission.skill, - } - } - if (typeof overridePermission.skill === "string") { - overridePermission.skill = { - "*": overridePermission.skill, - } - } - const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any - let mergedBash - if (merged.bash) { - if (typeof merged.bash === "string") { - mergedBash = { - "*": merged.bash, - } - } else if (typeof merged.bash === "object") { - mergedBash = mergeDeep( - { - "*": "allow", - }, - merged.bash, - ) - } - } - - let mergedSkill - if (merged.skill) { - if (typeof merged.skill === "string") { - mergedSkill = { - "*": merged.skill, - } - } else if (typeof merged.skill === "object") { - mergedSkill = mergeDeep( - { - "*": "allow", - }, - merged.skill, - ) - } - } - - const result: Agent.Info["permission"] = { - edit: merged.edit ?? "allow", - webfetch: merged.webfetch ?? "allow", - bash: mergedBash ?? { "*": "allow" }, - skill: mergedSkill ?? { "*": "allow" }, - doom_loop: merged.doom_loop, - external_directory: merged.external_directory, - } - - return result -} diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 60dd9cc75a21..b57de0ae464e 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -241,7 +241,8 @@ const AgentListCommand = cmd({ }) for (const agent of sortedAgents) { - process.stdout.write(`${agent.name} (${agent.mode})${EOL}`) + process.stdout.write(`${agent.name} (${agent.mode})` + EOL) + process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) } }, }) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 5a51a044df37..6bd04a0eecb7 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,9 +1,6 @@ import { EOL } from "os" import { basename } from "path" import { Agent } from "../../../agent/agent" -import { Provider } from "../../../provider/provider" -import { ToolRegistry } from "../../../tool/registry" -import { Wildcard } from "../../../util/wildcard" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -25,27 +22,7 @@ export const AgentCommand = cmd({ ) process.exit(1) } - const resolvedTools = await resolveTools(agent) - const output = { - ...agent, - tools: resolvedTools, - toolOverrides: agent.tools, - } - process.stdout.write(JSON.stringify(output, null, 2) + EOL) + process.stdout.write(JSON.stringify(agent, null, 2) + EOL) }) }, }) - -async function resolveTools(agent: Agent.Info) { - const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID - const toolOverrides = { - ...agent.tools, - ...(await ToolRegistry.enabled(agent)), - } - const availableTools = await ToolRegistry.tools(providerID, agent) - const resolved: Record = {} - for (const tool of availableTools) { - resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false - } - return resolved -} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0c371b864ce8..876b64bd82a9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -202,14 +202,14 @@ export const RunCommand = cmd({ break } - if (event.type === "permission.updated") { + if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue const result = await select({ - message: `Permission required to run: ${permission.title}`, + message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, options: [ { value: "once", label: "Allow once" }, - { value: "always", label: "Always allow" }, + { value: "always", label: "Always allow: " + permission.always.join(", ") }, { value: "reject", label: "Reject" }, ], initialValue: "once", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8b7b68273ac3..35b33b4a09f7 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" -import { Global } from "@/global" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index ab9487e1dd43..ed0f50b2c532 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -33,6 +33,7 @@ import { useKV } from "../../context/kv" export type PromptProps = { sessionID?: string + visible?: boolean disabled?: boolean onSubmit?: () => void ref?: (ref: PromptRef) => void @@ -373,7 +374,8 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { - input.focus() + if (props.visible !== false) input?.focus() + if (props.visible === false) input?.blur() }) onMount(() => { @@ -798,7 +800,7 @@ export function Prompt(props: PromptProps) { agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} /> - (anchor = r)}> + (anchor = r)} visible={props.visible !== false}> ({ - current: agents().find((x) => x.default)?.name ?? agents()[0].name, + current: agents()[0].name, }) const { theme } = useTheme() const colors = createMemo(() => [ diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 2528a4998965..cc4e9c69aec9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -7,7 +7,7 @@ import type { Config, Todo, Command, - Permission, + PermissionRequest, LspStatus, McpStatus, FormatterStatus, @@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: Agent[] command: Command[] permission: { - [sessionID: string]: Permission[] + [sessionID: string]: PermissionRequest[] } config: Config session: Session[] @@ -97,36 +97,38 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.event.listen((e) => { const event = e.details switch (event.type) { - case "permission.updated": { - const permissions = store.permission[event.properties.sessionID] - if (!permissions) { - setStore("permission", event.properties.sessionID, [event.properties]) - break - } - const match = Binary.search(permissions, event.properties.id, (p) => p.id) + case "permission.replied": { + const requests = store.permission[event.properties.sessionID] + if (!requests) break + const match = Binary.search(requests, event.properties.requestID, (r) => r.id) + if (!match.found) break setStore( "permission", event.properties.sessionID, produce((draft) => { - if (match.found) { - draft[match.index] = event.properties - return - } - draft.push(event.properties) + draft.splice(match.index, 1) }), ) break } - case "permission.replied": { - const permissions = store.permission[event.properties.sessionID] - const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id) - if (!match.found) break + case "permission.asked": { + const request = event.properties + const requests = store.permission[request.sessionID] + if (!requests) { + setStore("permission", request.sessionID, [request]) + break + } + const match = Binary.search(requests, request.id, (r) => r.id) + if (match.found) { + setStore("permission", request.sessionID, match.index, reconcile(request)) + break + } setStore( "permission", - event.properties.sessionID, + request.sessionID, produce((draft) => { - draft.splice(match.index, 1) + draft.splice(match.index, 0, request) }), ) break diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 69082c870ba4..3d1315ccde76 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -59,7 +59,7 @@ export function Footer() { 0}> - {permissions().length} Permission + {permissions().length} Permission {permissions().length > 1 ? "s" : ""} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 374645abb356..8a6c5cdd254f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -9,7 +9,6 @@ import { Show, Switch, useContext, - type Component, } from "solid-js" import { Dynamic } from "solid-js/web" import path from "path" @@ -23,6 +22,7 @@ import { addDefaultParsers, MacOSScrollAccel, type ScrollAcceleration, + TextAttributes, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" @@ -40,7 +40,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" -import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid" +import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "@tui/context/keybind" @@ -66,6 +66,7 @@ import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" +import { PermissionPrompt } from "./permission" import { DialogExportOptions } from "../../ui/dialog-export-options" addDefaultParsers(parsers.parsers) @@ -82,12 +83,12 @@ class CustomSpeedScroll implements ScrollAcceleration { const context = createContext<{ width: number + sessionID: string conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean usernameVisible: () => boolean showDetails: () => boolean - userMessageMarkdown: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType }>() @@ -106,8 +107,17 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)!) + const children = createMemo(() => { + const parentID = session()?.parentID ?? session()?.id + return sync.data.session + .filter((x) => x.parentID === parentID || x.id === parentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) - const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) + const permissions = createMemo(() => { + if (session().parentID) return sync.data.permission[route.sessionID] ?? [] + return children().flatMap((x) => sync.data.permission[x.id] ?? []) + }) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -125,7 +135,6 @@ export function Session() { const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) - const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) @@ -176,28 +185,6 @@ export function Session() { } }) - // Auto-navigate to whichever session currently needs permission input - createEffect(() => { - const currentSession = session() - if (!currentSession) return - const currentPermissions = permissions() - let targetID = currentPermissions.length > 0 ? currentSession.id : undefined - - if (!targetID) { - const child = sync.data.session.find( - (x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0, - ) - if (child) targetID = child.id - } - - if (targetID && targetID !== currentSession.id) { - navigate({ - type: "session", - sessionID: targetID, - }) - } - }) - let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() @@ -248,29 +235,6 @@ export function Session() { dialog.clear() } - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - const first = permissions()[0] - if (first) { - const response = iife(() => { - if (evt.ctrl || evt.meta) return - if (evt.name === "return") return "once" - if (evt.name === "a") return "always" - if (evt.name === "d") return "reject" - if (evt.name === "escape") return "reject" - return - }) - if (response) { - sdk.client.permission.respond({ - permissionID: first.id, - sessionID: route.sessionID, - response: response, - }) - } - } - }) - function toBottom() { setTimeout(() => { if (scroll) scroll.scrollTo(scroll.scrollHeight) @@ -280,18 +244,14 @@ export function Session() { const local = useLocal() function moveChild(direction: number) { - const parentID = session()?.parentID ?? session()?.id - let children = sync.data.session - .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - if (children.length === 1) return - let next = children.findIndex((x) => x.id === session()?.id) + direction - if (next >= children.length) next = 0 - if (next < 0) next = children.length - 1 - if (children[next]) { + if (children().length === 1) return + let next = children().findIndex((x) => x.id === session()?.id) + direction + if (next >= children().length) next = 0 + if (next < 0) next = children().length - 1 + if (children()[next]) { navigate({ type: "session", - sessionID: children[next].id, + sessionID: children()[next].id, }) } } @@ -571,19 +531,6 @@ export function Session() { dialog.clear() }, }, - { - title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown", - value: "session.toggle.user_message_markdown", - category: "Session", - onSelect: (dialog) => { - setUserMessageMarkdown((prev) => { - const next = !prev - kv.set("user_message_markdown", next) - return next - }) - dialog.clear() - }, - }, { title: animationsEnabled() ? "Disable animations" : "Enable animations", value: "session.toggle.animations", @@ -990,12 +937,12 @@ export function Session() { get width() { return contentWidth() }, + sessionID: route.sessionID, conceal, showThinking, showTimestamps, usernameVisible, showDetails, - userMessageMarkdown, diffWrapMode, sync, }} @@ -1121,7 +1068,11 @@ export function Session() { + 0}> + + { prompt = r promptRef.set(r) @@ -1169,7 +1120,7 @@ function UserMessage(props: { const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const sync = useSync() - const { theme, syntax } = useTheme() + const { theme } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) @@ -1200,22 +1151,7 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - - - - - - {text()?.text} - - + {text()?.text} @@ -1321,7 +1257,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las - {" "} + {" "} {Locale.titlecase(props.message.mode)} · {props.message.modelID} @@ -1397,112 +1333,77 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess // Pending messages moved to individual tool pending functions function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) { - const { theme } = useTheme() - const { showDetails } = use() const sync = useSync() - const [margin, setMargin] = createSignal(0) - const component = createMemo(() => { - // Hide tool if showDetails is false and tool completed successfully - // But always show if there's an error or permission is required - const shouldHide = - !showDetails() && - props.part.state.status === "completed" && - !sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID) - - if (shouldHide) { - return undefined - } - const render = ToolRegistry.render(props.part.tool) ?? GenericTool - - const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) - const input = props.part.state.input ?? {} - const container = ToolRegistry.container(props.part.tool) - const permissions = sync.data.permission[props.message.sessionID] ?? [] - const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID) - const permission = permissions[permissionIndex] - - const style: BoxProps = - container === "block" || permission - ? { - border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const), - paddingTop: 1, - paddingBottom: 1, - paddingLeft: 2, - marginTop: 1, - gap: 1, - backgroundColor: theme.backgroundPanel, - customBorderChars: SplitBorder.customBorderChars, - borderColor: permissionIndex === 0 ? theme.warning : theme.background, - } - : { - paddingLeft: 3, - } - - return ( - 1) { - setMargin(1) - return - } - const children = parent.getChildren() - const index = children.indexOf(el) - const previous = children[index - 1] - if (!previous) { - setMargin(0) - return - } - if (previous.height > 1 || previous.id.startsWith("text-")) { - setMargin(1) - return - } - }} - > - - {props.part.state.status === "error" && ( - - {props.part.state.error.replace("Error: ", "")} - - )} - {permission && ( - - Permission required to run this tool: - - - enter - accept - - - a - accept always - - - d - deny - - - - )} - - ) - }) + const toolprops = { + get metadata() { + return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) + }, + get input() { + return props.part.state.input ?? {} + }, + get output() { + return props.part.state.status === "completed" ? props.part.state.output : undefined + }, + get permission() { + const permissions = sync.data.permission[props.message.sessionID] ?? [] + const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID) + return permissions[permissionIndex] + }, + get tool() { + return props.part.tool + }, + get part() { + return props.part + }, + } - return {component()} + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } type ToolProps = { @@ -1511,37 +1412,16 @@ type ToolProps = { permission: Record tool: string output?: string + part: ToolPart } function GenericTool(props: ToolProps) { return ( - + {props.tool} {input(props.input)} - + ) } -type ToolRegistration = { - name: string - container: "inline" | "block" - render?: Component> -} -const ToolRegistry = (() => { - const state: Record = {} - function register(input: ToolRegistration) { - state[input.name] = input - return input - } - return { - register, - container(name: string) { - return state[name]?.container - }, - render(name: string) { - return state[name]?.render - }, - } -})() - function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { const { theme } = useTheme() return ( @@ -1553,67 +1433,135 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children: ) } -ToolRegistry.register({ - name: "bash", - container: "block", - render(props) { - const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) - const { theme } = useTheme() - return ( - <> - - {props.input.description || "Shell"} - - - $ {props.input.command} +function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) { + const [margin, setMargin] = createSignal(0) + const { theme } = useTheme() + const ctx = use() + const sync = useSync() + + const permission = createMemo(() => { + const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID + if (!callID) return false + return callID === props.part.callID + }) + + const fg = createMemo(() => { + if (permission()) return theme.warning + if (props.complete) return theme.textMuted + return theme.text + }) + + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) + + const denied = createMemo(() => error()?.includes("rejected permission")) + + return ( + 1) { + setMargin(1) + return + } + const children = parent.getChildren() + const index = children.indexOf(el) + const previous = children[index - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.height > 1 || previous.id.startsWith("text-")) { + setMargin(1) + return + } + }} + > + + ~ {props.pending}} when={props.complete}> + {props.icon} {props.children} - - + + + {error()} + + + ) +} + +function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void }) { + const { theme } = useTheme() + const renderer = useRenderer() + const [hover, setHover] = createSignal(false) + return ( + props.onClick && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick?.() + }} + > + + {props.title} + + {props.children} + + ) +} + +function Bash(props: ToolProps) { + const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) + const { theme } = useTheme() + return ( + + + + + $ {props.input.command} {output()} - - - ) - }, -}) - -ToolRegistry.register({ - name: "read", - container: "inline", - render(props) { - return ( - <> - - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} - - - ) - }, -}) - -ToolRegistry.register({ - name: "write", - container: "block", - render(props) { - const { theme, syntax } = useTheme() - const code = createMemo(() => { - if (!props.input.content) return "" - return props.input.content - }) - - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - return props.metadata.diagnostics?.[filePath] ?? [] - }) - - const done = !!props.input.filePath - - return ( - <> - - Wrote {props.input.filePath} - - + + + + + {props.input.command} + + + + ) +} + +function Write(props: ToolProps) { + const { theme, syntax } = useTheme() + const code = createMemo(() => { + if (!props.input.content) return "" + return props.input.content + }) + + const diagnostics = createMemo(() => { + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + return props.metadata.diagnostics?.[filePath] ?? [] + }) + + return ( + + + ({ content={code()} /> - - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} - - )} - - - - ) - }, -}) - -ToolRegistry.register({ - name: "glob", - container: "inline", - render(props) { - return ( - <> - - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.count} matches) - - - ) - }, -}) - -ToolRegistry.register({ - name: "grep", - container: "inline", - render(props) { - return ( - - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.matches} matches) - - ) - }, -}) - -ToolRegistry.register({ - name: "list", - container: "inline", - render(props) { - const dir = createMemo(() => { - if (props.input.path) { - return normalizePath(props.input.path) - } - return "" - }) - return ( - <> - - List {dir()} - - - ) - }, -}) - -ToolRegistry.register({ - name: "task", - container: "block", - render(props) { - const { theme } = useTheme() - const keybind = useKeybind() - const dialog = useDialog() - const renderer = useRenderer() - - return ( - <> - - {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}" - - - - - {(task, index) => { - const summary = props.metadata.summary ?? [] - return ( - - {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "} - {task.state.status === "completed" ? task.state.title : ""} - - ) - }} + + + {(diagnostic) => ( + + Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} + + )} + + + + + + Write {normalizePath(props.input.filePath!)} + + + + ) +} + +function Glob(props: ToolProps) { + return ( + + Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + ({props.metadata.count} matches) + + ) +} + +function Read(props: ToolProps) { + return ( + + Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + + ) +} + +function Grep(props: ToolProps) { + return ( + + Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + ({props.metadata.matches} matches) + + ) +} + +function List(props: ToolProps) { + const dir = createMemo(() => { + if (props.input.path) { + return normalizePath(props.input.path) + } + return "" + }) + return ( + + List {dir()} + + ) +} + +function WebFetch(props: ToolProps) { + return ( + + WebFetch {(props.input as any).url} + + ) +} + +function CodeSearch(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Code Search "{input.query}" ({metadata.results} results) + + ) +} + +function WebSearch(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Web Search "{input.query}" ({metadata.numResults} results) + + ) +} + +function Task(props: ToolProps) { + const { theme } = useTheme() + const keybind = useKeybind() + const { navigate } = useRoute() + + const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) + + return ( + + + navigate({ type: "session", sessionID: props.metadata.sessionId! }) + : undefined + } + > + + + {props.input.description} ({props.metadata.summary?.length} toolcalls) + + + + └ {Locale.titlecase(current()!.tool)}{" "} + {current()!.state.status === "completed" ? current()!.state.title : ""} + + - - - {keybind.print("session_child_cycle")} - view subagents - - - ) - }, -}) - -ToolRegistry.register({ - name: "webfetch", - container: "inline", - render(props) { - return ( - - WebFetch {(props.input as any).url} - - ) - }, -}) - -ToolRegistry.register({ - name: "codesearch", - container: "inline", - render(props: ToolProps) { - const input = props.input as any - const metadata = props.metadata as any - return ( - - Exa Code Search "{input.query}" ({metadata.results} results) - - ) - }, -}) - -ToolRegistry.register({ - name: "websearch", - container: "inline", - render(props: ToolProps) { - const input = props.input as any - const metadata = props.metadata as any - return ( - - Exa Web Search "{input.query}" ({metadata.numResults} results) - - ) - }, -}) - -ToolRegistry.register({ - name: "edit", - container: "block", - render(props) { - const ctx = use() - const { theme, syntax } = useTheme() - - const view = createMemo(() => { - const diffStyle = ctx.sync.data.config.tui?.diff_style - if (diffStyle === "stacked") return "unified" - // Default to "auto" behavior - return ctx.width > 120 ? "split" : "unified" - }) - - const ft = createMemo(() => filetype(props.input.filePath)) - - const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"]) - - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - const arr = props.metadata.diagnostics?.[filePath] ?? [] - return arr.filter((x) => x.severity === 1).slice(0, 3) - }) - - return ( - <> - - Edit {normalizePath(props.input.filePath!)}{" "} - {input({ - replaceAll: props.input.replaceAll, - })} - - + + {keybind.print("session_child_cycle")} + view subagents + + + + + + {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}" + + + + ) +} + +function Edit(props: ToolProps) { + const ctx = use() + const { theme, syntax } = useTheme() + + const view = createMemo(() => { + const diffStyle = ctx.sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + // Default to "auto" behavior + return ctx.width > 120 ? "split" : "unified" + }) + + const ft = createMemo(() => filetype(props.input.filePath)) + + const diffContent = createMemo(() => props.metadata.diff) + + const diagnostics = createMemo(() => { + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + const arr = props.metadata.diagnostics?.[filePath] ?? [] + return arr.filter((x) => x.severity === 1).slice(0, 3) + }) + + return ( + + + ({ removedLineNumberBg={theme.diffRemovedLineNumberBg} /> - - - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message} - - )} - - - - - ) - }, -}) - -ToolRegistry.register({ - name: "patch", - container: "block", - render(props) { - const { theme } = useTheme() - return ( - <> - - Patch - - + + + + {(diagnostic) => ( + + Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "} + {diagnostic.message} + + )} + + + + + + + + Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + + + + ) +} + +function Patch(props: ToolProps) { + const { theme } = useTheme() + return ( + + + {props.output?.trim()} - - - ) - }, -}) - -ToolRegistry.register({ - name: "todowrite", - container: "block", - render(props) { - const { theme } = useTheme() - return ( - <> - - - Updating todos... - - - + + + + + Patch + + + + ) +} + +function TodoWrite(props: ToolProps) { + return ( + + + {(todo) => } - - - ) - }, -}) + + + + + Updating todos... + + + + ) +} function normalizePath(input?: string) { if (!input) return "" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx new file mode 100644 index 000000000000..e3d519115bf7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -0,0 +1,313 @@ +import { createStore } from "solid-js/store" +import { createMemo, For, Match, Show, Switch } from "solid-js" +import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useTheme } from "../../context/theme" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useSync } from "../../context/sync" +import path from "path" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { Locale } from "@/util/locale" + +function normalizePath(input?: string) { + if (!input) return "" + if (path.isAbsolute(input)) { + return path.relative(process.cwd(), input) || "." + } + return input +} + +function filetype(input?: string) { + if (!input) return "none" + const ext = path.extname(input) + const language = LANGUAGE_EXTENSIONS[ext] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function EditBody(props: { request: PermissionRequest }) { + const { theme, syntax } = useTheme() + const sync = useSync() + const dimensions = useTerminalDimensions() + + const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "") + const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "") + + const view = createMemo(() => { + const diffStyle = sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + return dimensions().width > 120 ? "split" : "unified" + }) + + const ft = createMemo(() => filetype(filepath())) + + return ( + + + {"→"} + Edit {normalizePath(filepath())} + + + + + + + + ) +} + +function TextBody(props: { title: string; description?: string; icon?: string }) { + const { theme } = useTheme() + return ( + <> + + + + {props.icon} + + + {props.title} + + + + {props.description} + + + + ) +} + +export function PermissionPrompt(props: { request: PermissionRequest }) { + const sdk = useSDK() + const sync = useSync() + const [store, setStore] = createStore({ + always: false, + }) + + const input = createMemo(() => { + const tool = props.request.tool + if (!tool) return {} + const parts = sync.data.part[tool.messageID] ?? [] + for (const part of parts) { + if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") { + return part.state.input ?? {} + } + } + return {} + }) + + const { theme } = useTheme() + + return ( + + + + + + + + + This will allow the following patterns until OpenCode is restarted + + + {(pattern) => ( + + {"- "} + {pattern} + + )} + + + + + + } + options={{ confirm: "Confirm", cancel: "Cancel" }} + onSelect={(option) => { + setStore("always", false) + if (option === "cancel") return + sdk.client.permission.reply({ + reply: "always", + requestID: props.request.id, + }) + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + onSelect={(option) => { + if (option === "always") { + setStore("always", true) + return + } + sdk.client.permission.reply({ + reply: option as "once" | "reject", + requestID: props.request.id, + }) + }} + /> + + + ) +} + +function Prompt>(props: { + title: string + body: JSX.Element + options: T + onSelect: (option: keyof T) => void +}) { + const { theme } = useTheme() + const keys = Object.keys(props.options) as (keyof T)[] + const [store, setStore] = createStore({ + selected: keys[0], + }) + + useKeyboard((evt) => { + if (evt.name === "left" || evt.name == "h") { + evt.preventDefault() + const idx = keys.indexOf(store.selected) + const next = keys[(idx - 1 + keys.length) % keys.length] + setStore("selected", next) + } + + if (evt.name === "right" || evt.name == "l") { + evt.preventDefault() + const idx = keys.indexOf(store.selected) + const next = keys[(idx + 1) % keys.length] + setStore("selected", next) + } + + if (evt.name === "return") { + evt.preventDefault() + props.onSelect(store.selected) + } + }) + + return ( + + + + {"△"} + {props.title} + + {props.body} + + + + + {(option) => ( + + + {props.options[option]} + + + )} + + + + + {"⇆"} select + + + enter confirm + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 9b773111c355..79bca42406a9 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -99,6 +99,7 @@ function init() { replace(input: any, onClose?: () => void) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable + focus?.blur() } for (const item of store.stack) { if (item.onClose) item.onClose() diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f66b467905e3..5d95814d7b08 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -123,13 +123,22 @@ export namespace Config { result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) } - if (!result.username) result.username = os.userInfo().username - - // Handle migration from autoshare to share field - if (result.autoshare === true && !result.share) { - result.share = "auto" + // Backwards compatibility: legacy top-level `tools` config + if (result.tools) { + const perms: Record = {} + for (const [tool, enabled] of Object.entries(result.tools)) { + const action: Config.PermissionAction = enabled ? "allow" : "deny" + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + perms.edit = action + continue + } + perms[tool] = action + } + result.permission = mergeDeep(perms, result.permission ?? {}) } + if (!result.username) result.username = os.userInfo().username + // Handle migration from autoshare to share field if (result.autoshare === true && !result.share) { result.share = "auto" @@ -368,7 +377,45 @@ export namespace Config { export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export type Mcp = z.infer - export const Permission = z.enum(["ask", "allow", "deny"]) + export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({ + ref: "PermissionActionConfig", + }) + export type PermissionAction = z.infer + + export const PermissionObject = z.record(z.string(), PermissionAction).meta({ + ref: "PermissionObjectConfig", + }) + export type PermissionObject = z.infer + + export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({ + ref: "PermissionRuleConfig", + }) + export type PermissionRule = z.infer + + export const Permission = z + .object({ + read: PermissionRule.optional(), + edit: PermissionRule.optional(), + glob: PermissionRule.optional(), + grep: PermissionRule.optional(), + list: PermissionRule.optional(), + bash: PermissionRule.optional(), + task: PermissionRule.optional(), + external_directory: PermissionRule.optional(), + todowrite: PermissionAction.optional(), + todoread: PermissionAction.optional(), + webfetch: PermissionAction.optional(), + websearch: PermissionAction.optional(), + codesearch: PermissionAction.optional(), + lsp: PermissionRule.optional(), + doom_loop: PermissionAction.optional(), + }) + .catchall(PermissionRule) + .or(PermissionAction) + .transform((x) => (typeof x === "string" ? { "*": x } : x)) + .meta({ + ref: "PermissionConfig", + }) export type Permission = z.infer export const Command = z.object({ @@ -386,33 +433,70 @@ export namespace Config { temperature: z.number().optional(), top_p: z.number().optional(), prompt: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional(), + tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"), disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), + options: z.record(z.string(), z.any()).optional(), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") .optional() .describe("Hex color code for the agent (e.g., #FF5733)"), - maxSteps: z + steps: z .number() .int() .positive() .optional() .describe("Maximum number of agentic iterations before forcing text-only response"), - permission: z - .object({ - edit: Permission.optional(), - bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), - skill: z.union([Permission, z.record(z.string(), Permission)]).optional(), - webfetch: Permission.optional(), - doom_loop: Permission.optional(), - external_directory: Permission.optional(), - }) - .optional(), + maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), + permission: Permission.optional(), }) .catchall(z.any()) + .transform((agent, ctx) => { + const knownKeys = new Set([ + "model", + "prompt", + "description", + "temperature", + "top_p", + "mode", + "color", + "steps", + "maxSteps", + "options", + "permission", + "disable", + "tools", + ]) + + // Extract unknown properties into options + const options: Record = { ...agent.options } + for (const [key, value] of Object.entries(agent)) { + if (!knownKeys.has(key)) options[key] = value + } + + // Convert legacy tools config to permissions + const permission: Permission = { ...agent.permission } + for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { + const action = enabled ? "allow" : "deny" + // write, edit, patch, multiedit all map to edit permission + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + permission.edit = action + } else { + permission[tool] = action + } + } + + // Convert legacy maxSteps to steps + const steps = agent.steps ?? agent.maxSteps + + return { ...agent, options, permission, steps } as typeof agent & { + options?: Record + permission?: Permission + steps?: number + } + }) .meta({ ref: "AgentConfig", }) @@ -785,16 +869,7 @@ export namespace Config { ), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), - permission: z - .object({ - edit: Permission.optional(), - bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), - skill: z.union([Permission, z.record(z.string(), Permission)]).optional(), - webfetch: Permission.optional(), - doom_loop: Permission.optional(), - external_directory: Permission.optional(), - }) - .optional(), + permission: Permission.optional(), tools: z.record(z.string(), z.boolean()).optional(), enterprise: z .object({ diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 975ca749bcec..25ef79fda017 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -158,6 +158,7 @@ export namespace Installation { throw new UpgradeFailedError({ stderr: result.stderr.toString("utf8"), }) + await $`${process.execPath} --version`.nothrow().quiet().text() } export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" diff --git a/packages/opencode/src/permission/arity.ts b/packages/opencode/src/permission/arity.ts new file mode 100644 index 000000000000..948841c8e799 --- /dev/null +++ b/packages/opencode/src/permission/arity.ts @@ -0,0 +1,163 @@ +export namespace BashArity { + export function prefix(tokens: string[]) { + for (let len = tokens.length; len > 0; len--) { + const prefix = tokens.slice(0, len).join(" ") + const arity = ARITY[prefix] + if (arity !== undefined) return tokens.slice(0, arity) + } + if (tokens.length === 0) return [] + return tokens.slice(0, 1) + } + + /* Generated with following prompt: +You are generating a dictionary of command-prefix arities for bash-style commands. +This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command. +2. **Flags NEVER count as tokens**. Only subcommands count. +3. **Longest matching prefix wins**. +4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity. +5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical +6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed) +* `git checkout main` → `git checkout` (because `git` has arity 2) +* `npm install` → `npm install` (because `npm` has arity 2) +* `npm run dev` → `npm run dev` (because `npm run` has arity 3) +* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.** +*/ + const ARITY: Record = { + cat: 1, // cat file.txt + cd: 1, // cd /path/to/dir + chmod: 1, // chmod 755 script.sh + chown: 1, // chown user:group file.txt + cp: 1, // cp source.txt dest.txt + echo: 1, // echo "hello world" + env: 1, // env + export: 1, // export PATH=/usr/bin + grep: 1, // grep pattern file.txt + kill: 1, // kill 1234 + killall: 1, // killall process + ln: 1, // ln -s source target + ls: 1, // ls -la + mkdir: 1, // mkdir new-dir + mv: 1, // mv old.txt new.txt + ps: 1, // ps aux + pwd: 1, // pwd + rm: 1, // rm file.txt + rmdir: 1, // rmdir empty-dir + sleep: 1, // sleep 5 + source: 1, // source ~/.bashrc + tail: 1, // tail -f log.txt + touch: 1, // touch file.txt + unset: 1, // unset VAR + which: 1, // which node + aws: 3, // aws s3 ls + az: 3, // az storage blob list + bazel: 2, // bazel build + brew: 2, // brew install node + bun: 2, // bun install + "bun run": 3, // bun run dev + "bun x": 3, // bun x vite + cargo: 2, // cargo build + "cargo add": 3, // cargo add tokio + "cargo run": 3, // cargo run main + cdk: 2, // cdk deploy + cf: 2, // cf push app + cmake: 2, // cmake build + composer: 2, // composer require laravel + consul: 2, // consul members + "consul kv": 3, // consul kv get config/app + crictl: 2, // crictl ps + deno: 2, // deno run server.ts + "deno task": 3, // deno task dev + doctl: 3, // doctl kubernetes cluster list + docker: 2, // docker run nginx + "docker builder": 3, // docker builder prune + "docker compose": 3, // docker compose up + "docker container": 3, // docker container ls + "docker image": 3, // docker image prune + "docker network": 3, // docker network inspect + "docker volume": 3, // docker volume ls + eksctl: 2, // eksctl get clusters + "eksctl create": 3, // eksctl create cluster + firebase: 2, // firebase deploy + flyctl: 2, // flyctl deploy + gcloud: 3, // gcloud compute instances list + gh: 3, // gh pr list + git: 2, // git checkout main + "git config": 3, // git config user.name + "git remote": 3, // git remote add origin + "git stash": 3, // git stash pop + go: 2, // go build + gradle: 2, // gradle build + helm: 2, // helm install mychart + heroku: 2, // heroku logs + hugo: 2, // hugo new site blog + ip: 2, // ip link show + "ip addr": 3, // ip addr show + "ip link": 3, // ip link set eth0 up + "ip netns": 3, // ip netns exec foo bash + "ip route": 3, // ip route add default via 1.1.1.1 + kind: 2, // kind delete cluster + "kind create": 3, // kind create cluster + kubectl: 2, // kubectl get pods + "kubectl kustomize": 3, // kubectl kustomize overlays/dev + "kubectl rollout": 3, // kubectl rollout restart deploy/api + kustomize: 2, // kustomize build . + make: 2, // make build + mc: 2, // mc ls myminio + "mc admin": 3, // mc admin info myminio + minikube: 2, // minikube start + mongosh: 2, // mongosh test + mysql: 2, // mysql -u root + mvn: 2, // mvn compile + ng: 2, // ng generate component home + npm: 2, // npm install + "npm exec": 3, // npm exec vite + "npm init": 3, // npm init vue + "npm run": 3, // npm run dev + "npm view": 3, // npm view react version + nvm: 2, // nvm use 18 + nx: 2, // nx build + openssl: 2, // openssl genrsa 2048 + "openssl req": 3, // openssl req -new -key key.pem + "openssl x509": 3, // openssl x509 -in cert.pem + pip: 2, // pip install numpy + pipenv: 2, // pipenv install flask + pnpm: 2, // pnpm install + "pnpm dlx": 3, // pnpm dlx create-next-app + "pnpm exec": 3, // pnpm exec vite + "pnpm run": 3, // pnpm run dev + poetry: 2, // poetry add requests + podman: 2, // podman run alpine + "podman container": 3, // podman container ls + "podman image": 3, // podman image prune + psql: 2, // psql -d mydb + pulumi: 2, // pulumi up + "pulumi stack": 3, // pulumi stack output + pyenv: 2, // pyenv install 3.11 + python: 2, // python -m venv env + rake: 2, // rake db:migrate + rbenv: 2, // rbenv install 3.2.0 + "redis-cli": 2, // redis-cli ping + rustup: 2, // rustup update + serverless: 2, // serverless invoke + sfdx: 3, // sfdx force:org:list + skaffold: 2, // skaffold dev + sls: 2, // sls deploy + sst: 2, // sst deploy + swift: 2, // swift build + systemctl: 2, // systemctl restart nginx + terraform: 2, // terraform apply + "terraform workspace": 3, // terraform workspace select prod + tmux: 2, // tmux new -s dev + turbo: 2, // turbo run build + ufw: 2, // ufw allow 22 + vault: 2, // vault login + "vault auth": 3, // vault auth list + "vault kv": 3, // vault kv get secret/api + vercel: 2, // vercel deploy + volta: 2, // volta install node + wp: 2, // wp plugin install + yarn: 2, // yarn add react + "yarn dlx": 3, // yarn dlx create-react-app + "yarn run": 3, // yarn run dev + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index cbfeb6a9b9a8..f1cd43fdbe58 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -27,7 +27,7 @@ export namespace Permission { sessionID: z.string(), messageID: z.string(), callID: z.string().optional(), - title: z.string(), + message: z.string(), metadata: z.record(z.string(), z.any()), time: z.object({ created: z.number(), @@ -99,7 +99,7 @@ export namespace Permission { export async function ask(input: { type: Info["type"] - title: Info["title"] + message: Info["message"] pattern?: Info["pattern"] callID?: Info["callID"] sessionID: Info["sessionID"] @@ -123,7 +123,7 @@ export namespace Permission { sessionID: input.sessionID, messageID: input.messageID, callID: input.callID, - title: input.title, + message: input.message, metadata: input.metadata, time: { created: Date.now(), diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts new file mode 100644 index 000000000000..4f7d831e75d4 --- /dev/null +++ b/packages/opencode/src/permission/next.ts @@ -0,0 +1,253 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config/config" +import { Identifier } from "@/id/id" +import { Instance } from "@/project/instance" +import { Storage } from "@/storage/storage" +import { fn } from "@/util/fn" +import { Log } from "@/util/log" +import { Wildcard } from "@/util/wildcard" +import z from "zod" + +export namespace PermissionNext { + const log = Log.create({ service: "permission" }) + + export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", + }) + export type Action = z.infer + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer + + export function fromConfig(permission: Config.Permission) { + const ruleset: Ruleset = [] + for (const [key, value] of Object.entries(permission)) { + if (typeof value === "string") { + ruleset.push({ + permission: key, + action: value, + pattern: "*", + }) + continue + } + ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action }))) + } + return ruleset + } + + export function merge(...rulesets: Ruleset[]): Ruleset { + return rulesets.flat() + } + + export const Request = z + .object({ + id: Identifier.schema("permission"), + sessionID: Identifier.schema("session"), + permission: z.string(), + patterns: z.string().array(), + metadata: z.record(z.string(), z.any()), + always: z.string().array(), + tool: z + .object({ + messageID: z.string(), + callID: z.string(), + }) + .optional(), + }) + .meta({ + ref: "PermissionRequest", + }) + + export type Request = z.infer + + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer + + export const Approval = z.object({ + projectID: z.string(), + patterns: z.string().array(), + }) + + export const Event = { + Asked: BusEvent.define("permission.asked", Request), + Replied: BusEvent.define( + "permission.replied", + z.object({ + sessionID: z.string(), + requestID: z.string(), + reply: Reply, + }), + ), + } + + const state = Instance.state(async () => { + const projectID = Instance.project.id + const stored = await Storage.read(["permission", projectID]).catch(() => [] as Ruleset) + + const pending: Record< + string, + { + info: Request + resolve: () => void + reject: (e: any) => void + } + > = {} + + return { + pending, + approved: stored, + } + }) + + export const ask = fn( + Request.partial({ id: true }).extend({ + ruleset: Ruleset, + }), + async (input) => { + const s = await state() + const { ruleset, ...request } = input + for (const pattern of request.patterns ?? []) { + const action = evaluate(request.permission, pattern, ruleset, s.approved) + log.info("evaluated", { permission: request.permission, pattern, action }) + if (action === "deny") throw new RejectedError() + if (action === "ask") { + const id = input.id ?? Identifier.ascending("permission") + return new Promise((resolve, reject) => { + const info: Request = { + id, + ...request, + } + s.pending[id] = { + info, + resolve, + reject, + } + Bus.publish(Event.Asked, info) + }) + } + if (action === "allow") continue + } + }, + ) + + export const reply = fn( + z.object({ + requestID: Identifier.schema("permission"), + reply: Reply, + }), + async (input) => { + const s = await state() + const existing = s.pending[input.requestID] + if (!existing) return + delete s.pending[input.requestID] + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + if (input.reply === "reject") { + existing.reject(new RejectedError()) + // Reject all other pending permissions for this session + const sessionID = existing.info.sessionID + for (const [id, pending] of Object.entries(s.pending)) { + if (pending.info.sessionID === sessionID) { + delete s.pending[id] + Bus.publish(Event.Replied, { + sessionID: pending.info.sessionID, + requestID: pending.info.id, + reply: "reject", + }) + pending.reject(new RejectedError()) + } + } + return + } + if (input.reply === "once") { + existing.resolve() + return + } + if (input.reply === "always") { + for (const pattern of existing.info.always) { + s.approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + + existing.resolve() + + const sessionID = existing.info.sessionID + for (const [id, pending] of Object.entries(s.pending)) { + if (pending.info.sessionID !== sessionID) continue + const ok = pending.info.patterns.every( + (pattern) => evaluate(pending.info.permission, pattern, s.approved) === "allow", + ) + if (!ok) continue + delete s.pending[id] + Bus.publish(Event.Replied, { + sessionID: pending.info.sessionID, + requestID: pending.info.id, + reply: "always", + }) + pending.resolve() + } + + // TODO: we don't save the permission ruleset to disk yet until there's + // UI to manage it + // await Storage.write(["permission", Instance.project.id], s.approved) + return + } + }, + ) + + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Action { + const merged = merge(...rulesets) + log.info("evaluate", { permission, pattern, ruleset: merged }) + const match = merged.findLast( + (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + ) + return match?.action ?? "ask" + } + + const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] + + export function disabled(tools: string[], ruleset: Ruleset): Set { + const result = new Set() + for (const tool of tools) { + const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + if (evaluate(permission, "*", ruleset) === "deny") { + result.add(tool) + } + } + return result + } + + export class RejectedError extends Error { + constructor(public readonly reason?: string) { + super( + reason !== undefined + ? reason + : `The user rejected permission to use this specific tool call. You may try again with different parameters.`, + ) + } + } + + export async function list() { + return state().then((x) => Object.values(x.pending).map((x) => x.info)) + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 79f6094944a5..18a621fbbdc0 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -78,6 +78,7 @@ export namespace Plugin { const hooks = await state().then((x) => x.hooks) const config = await Config.get() for (const hook of hooks) { + // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) } Bus.subscribeAll(async (input) => { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index acd4ad5c44b7..9d75308c1c15 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -47,6 +47,7 @@ import { SessionStatus } from "@/session/status" import { upgradeWebSocket, websocket } from "hono/bun" import { errors } from "./error" import { Pty } from "@/pty" +import { PermissionNext } from "@/permission/next" import { Installation } from "@/installation" import { MDNS } from "./mdns" @@ -1524,6 +1525,7 @@ export namespace Server { "/session/:sessionID/permissions/:permissionID", describeRoute({ summary: "Respond to permission", + deprecated: true, description: "Approve or deny a permission request from the AI assistant.", operationId: "permission.respond", responses: { @@ -1545,15 +1547,47 @@ export namespace Server { permissionID: z.string(), }), ), - validator("json", z.object({ response: Permission.Response })), + validator("json", z.object({ response: PermissionNext.Reply })), async (c) => { const params = c.req.valid("param") - const sessionID = params.sessionID - const permissionID = params.permissionID - Permission.respond({ - sessionID, - permissionID, - response: c.req.valid("json").response, + PermissionNext.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return c.json(true) + }, + ) + .post( + "/permission/:requestID/reply", + describeRoute({ + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.reply", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: z.string(), + }), + ), + validator("json", z.object({ reply: PermissionNext.Reply })), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await PermissionNext.reply({ + requestID: params.requestID, + reply: json.reply, }) return c.json(true) }, @@ -1569,14 +1603,14 @@ export namespace Server { description: "List of pending permissions", content: { "application/json": { - schema: resolver(Permission.Info.array()), + schema: resolver(PermissionNext.Request.array()), }, }, }, }, }), async (c) => { - const permissions = Permission.list() + const permissions = await PermissionNext.list() return c.json(permissions) }, ) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 4285223bc5c9..0776590d6a9a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -18,6 +18,7 @@ import { Command } from "../command" import { Snapshot } from "@/snapshot" import type { Provider } from "@/provider/provider" +import { PermissionNext } from "@/permission/next" export namespace Session { const log = Log.create({ service: "session" }) @@ -62,6 +63,7 @@ export namespace Session { compacting: z.number().optional(), archived: z.number().optional(), }), + permission: PermissionNext.Ruleset.optional(), revert: z .object({ messageID: z.string(), @@ -126,6 +128,7 @@ export namespace Session { .object({ parentID: Identifier.schema("session").optional(), title: z.string().optional(), + permission: Info.shape.permission, }) .optional(), async (input) => { @@ -133,6 +136,7 @@ export namespace Session { parentID: input?.parentID, directory: Instance.directory, title: input?.title, + permission: input?.permission, }) }, ) @@ -174,7 +178,13 @@ export namespace Session { }) }) - export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) { + export async function createNext(input: { + id?: string + title?: string + parentID?: string + directory: string + permission?: PermissionNext.Ruleset + }) { const result: Info = { id: Identifier.descending("session", input.id), version: Installation.VERSION, @@ -182,6 +192,7 @@ export namespace Session { directory: input.directory, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), + permission: input.permission, time: { created: Date.now(), updated: Date.now(), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ccd7af1f0f55..fc701588d575 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -17,8 +17,8 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" -import { ToolRegistry } from "@/tool/registry" import { Flag } from "@/flag/flag" +import { PermissionNext } from "@/permission/next" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -200,13 +200,11 @@ export namespace LLM { } async function resolveTools(input: Pick) { - const enabled = pipe( - input.agent.tools, - mergeDeep(await ToolRegistry.enabled(input.agent)), - mergeDeep(input.user.tools ?? {}), - ) - for (const [key, value] of Object.entries(enabled)) { - if (value === false) delete input.tools[key] + const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) + for (const tool of Object.keys(input.tools)) { + if (input.user.tools?.[tool] === false || disabled.has(tool)) { + delete input.tools[tool] + } } return input.tools } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 567b9647934d..227ca64bb9be 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -3,7 +3,6 @@ import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." import { Agent } from "@/agent/agent" -import { Permission } from "@/permission" import { Snapshot } from "@/snapshot" import { SessionSummary } from "./summary" import { Bus } from "@/bus" @@ -14,6 +13,7 @@ import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" +import { PermissionNext } from "@/permission/next" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -152,32 +152,18 @@ export namespace SessionProcessor { JSON.stringify(p.state.input) === JSON.stringify(value.input), ) ) { - const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission) - if (permission.doom_loop === "ask") { - await Permission.ask({ - type: "doom_loop", - pattern: value.toolName, - sessionID: input.assistantMessage.sessionID, - messageID: input.assistantMessage.id, - callID: value.toolCallId, - title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, - metadata: { - tool: value.toolName, - input: value.input, - }, - }) - } else if (permission.doom_loop === "deny") { - throw new Permission.RejectedError( - input.assistantMessage.sessionID, - "doom_loop", - value.toolCallId, - { - tool: value.toolName, - input: value.input, - }, - `You seem to be stuck in a doom loop, please stop repeating the same action`, - ) - } + const agent = await Agent.get(input.assistantMessage.agent) + await PermissionNext.ask({ + permission: "doom_loop", + patterns: [value.toolName], + sessionID: input.assistantMessage.sessionID, + metadata: { + tool: value.toolName, + input: value.input, + }, + always: [value.toolName], + ruleset: agent.permission, + }) } } break @@ -215,7 +201,6 @@ export namespace SessionProcessor { status: "error", input: value.input, error: (value.error as any).toString(), - metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, time: { start: match.state.time.start, end: Date.now(), @@ -223,7 +208,7 @@ export namespace SessionProcessor { }, }) - if (value.error instanceof Permission.RejectedError) { + if (value.error instanceof PermissionNext.RejectedError) { blocked = shouldBreak } delete toolcalls[value.toolCallId] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3171192a3523..d4fef6f7a1cd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -9,7 +9,7 @@ import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" -import { type Tool as AITool, tool, jsonSchema } from "ai" +import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai" import { SessionCompaction } from "./compaction" import { Instance } from "../project/instance" import { Bus } from "../bus" @@ -20,9 +20,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" -import { clone, mergeDeep, pipe } from "remeda" +import { clone } from "remeda" import { ToolRegistry } from "../tool/registry" -import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" @@ -39,6 +38,8 @@ import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" +import { Tool } from "@/tool/tool" +import { PermissionNext } from "@/permission/next" import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" @@ -88,7 +89,12 @@ export namespace SessionPrompt { .optional(), agent: z.string().optional(), noReply: z.boolean().optional(), - tools: z.record(z.string(), z.boolean()).optional(), + tools: z + .record(z.string(), z.boolean()) + .optional() + .describe( + "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", + ), system: z.string().optional(), variant: z.string().optional(), parts: z.array( @@ -145,6 +151,23 @@ export namespace SessionPrompt { const message = await createUserMessage(input) await Session.touch(input.sessionID) + // this is backwards compatibility for allowing `tools` to be specified when + // prompting + const permissions: PermissionNext.Ruleset = [] + for (const [tool, enabled] of Object.entries(input.tools ?? {})) { + permissions.push({ + permission: tool, + action: enabled ? "allow" : "deny", + pattern: "*", + }) + } + if (permissions.length > 0) { + session.permission = permissions + await Session.update(session.id, (draft) => { + draft.permission = permissions + }) + } + if (input.noReply === true) { return message } @@ -240,6 +263,7 @@ export namespace SessionPrompt { using _ = defer(() => cancel(sessionID)) let step = 0 + const session = await Session.get(sessionID) while (true) { SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) @@ -276,7 +300,7 @@ export namespace SessionPrompt { step++ if (step === 1) ensureTitle({ - session: await Session.get(sessionID), + session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, message: msgs.find((m) => m.info.role === "user")!, @@ -350,28 +374,35 @@ export namespace SessionPrompt { { args: taskArgs }, ) let executionError: Error | undefined - const result = await taskTool - .execute(taskArgs, { - agent: task.agent, - messageID: assistantMessage.id, - sessionID: sessionID, - abort, - async metadata(input) { - await Session.updatePart({ - ...part, - type: "tool", - state: { - ...part.state, - ...input, - }, - } satisfies MessageV2.ToolPart) - }, - }) - .catch((error) => { - executionError = error - log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) - return undefined - }) + const taskAgent = await Agent.get(task.agent) + const taskCtx: Tool.Context = { + agent: task.agent, + messageID: assistantMessage.id, + sessionID: sessionID, + abort, + async metadata(input) { + await Session.updatePart({ + ...part, + type: "tool", + state: { + ...part.state, + ...input, + }, + } satisfies MessageV2.ToolPart) + }, + async ask(req) { + await PermissionNext.ask({ + ...req, + sessionID: sessionID, + ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), + }) + }, + } + const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => { + executionError = error + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return undefined + }) await Plugin.trigger( "tool.execute.after", { @@ -473,7 +504,7 @@ export namespace SessionPrompt { // normal processing const agent = await Agent.get(lastUser.agent) - const maxSteps = agent.maxSteps ?? Infinity + const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = insertReminders({ messages: msgs, @@ -511,7 +542,7 @@ export namespace SessionPrompt { }) const tools = await resolveTools({ agent, - sessionID, + session, model, tools: lastUser.tools, processor, @@ -581,67 +612,73 @@ export namespace SessionPrompt { async function resolveTools(input: { agent: Agent.Info model: Provider.Model - sessionID: string + session: Session.Info tools?: Record processor: SessionProcessor.Info }) { using _ = log.time("resolveTools") const tools: Record = {} - const enabledTools = pipe( - input.agent.tools, - mergeDeep(await ToolRegistry.enabled(input.agent)), - mergeDeep(input.tools ?? {}), - ) - for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) { - if (Wildcard.all(item.id, enabledTools) === false) continue + + const context = (args: any, options: ToolCallOptions): Tool.Context => ({ + sessionID: input.session.id, + abort: options.abortSignal!, + messageID: input.processor.message.id, + callID: options.toolCallId, + extra: { model: input.model }, + agent: input.agent.name, + metadata: async (val: { title?: string; metadata?: any }) => { + const match = input.processor.partFromToolCall(options.toolCallId) + if (match && match.state.status === "running") { + await Session.updatePart({ + ...match, + state: { + title: val.title, + metadata: val.metadata, + status: "running", + input: args, + time: { + start: Date.now(), + }, + }, + }) + } + }, + async ask(req) { + await PermissionNext.ask({ + ...req, + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), + }) + }, + }) + + for (const item of await ToolRegistry.tools(input.model.providerID)) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, description: item.description, inputSchema: jsonSchema(schema as any), async execute(args, options) { + const ctx = context(args, options) await Plugin.trigger( "tool.execute.before", { tool: item.id, - sessionID: input.sessionID, - callID: options.toolCallId, + sessionID: ctx.sessionID, + callID: ctx.callID, }, { args, }, ) - const result = await item.execute(args, { - sessionID: input.sessionID, - abort: options.abortSignal!, - messageID: input.processor.message.id, - callID: options.toolCallId, - extra: { model: input.model }, - agent: input.agent.name, - metadata: async (val) => { - const match = input.processor.partFromToolCall(options.toolCallId) - if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - title: val.title, - metadata: val.metadata, - status: "running", - input: args, - time: { - start: Date.now(), - }, - }, - }) - } - }, - }) + const result = await item.execute(args, ctx) await Plugin.trigger( "tool.execute.after", { tool: item.id, - sessionID: input.sessionID, - callID: options.toolCallId, + sessionID: ctx.sessionID, + callID: ctx.callID, }, result, ) @@ -655,31 +692,41 @@ export namespace SessionPrompt { }, }) } + for (const [key, item] of Object.entries(await MCP.tools())) { - if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute if (!execute) continue // Wrap execute to add plugin hooks and format output item.execute = async (args, opts) => { + const ctx = context(args, opts) + await Plugin.trigger( "tool.execute.before", { tool: key, - sessionID: input.sessionID, + sessionID: ctx.sessionID, callID: opts.toolCallId, }, { args, }, ) + + await ctx.ask({ + permission: key, + metadata: {}, + patterns: ["*"], + always: ["*"], + }) + const result = await execute(args, opts) await Plugin.trigger( "tool.execute.after", { tool: key, - sessionID: input.sessionID, + sessionID: ctx.sessionID, callID: opts.toolCallId, }, result, @@ -694,7 +741,7 @@ export namespace SessionPrompt { } else if (contentItem.type === "image") { attachments.push({ id: Identifier.ascending("part"), - sessionID: input.sessionID, + sessionID: input.session.id, messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, @@ -834,14 +881,16 @@ export namespace SessionPrompt { await ReadTool.init() .then(async (t) => { const model = await Provider.getModel(info.model.providerID, info.model.modelID) - const result = await t.execute(args, { + const readCtx: Tool.Context = { sessionID: input.sessionID, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, extra: { bypassCwdCheck: true, model }, metadata: async () => {}, - }) + ask: async () => {}, + } + const result = await t.execute(args, readCtx) pieces.push({ id: Identifier.ascending("part"), messageID: info.id, @@ -893,16 +942,16 @@ export namespace SessionPrompt { if (part.mime === "application/x-directory") { const args = { path: filepath } - const result = await ListTool.init().then((t) => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - metadata: async () => {}, - }), - ) + const listCtx: Tool.Context = { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + metadata: async () => {}, + ask: async () => {}, + } + const result = await ListTool.init().then((t) => t.execute(args, listCtx)) return [ { id: Identifier.ascending("part"), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index e10ee3bad301..f9ac12a2bbd8 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -44,7 +44,7 @@ export namespace SystemPrompt { ``, ``, ` ${ - project.vcs === "git" + project.vcs === "git" && false ? await Ripgrep.tree({ cwd: Instance.directory, limit: 200, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 92d4ced0f7b7..6671c939c409 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -6,16 +6,15 @@ import { Log } from "../util/log" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" -import { Agent } from "@/agent/agent" + import { $ } from "bun" import { Filesystem } from "@/util/filesystem" -import { Wildcard } from "@/util/wildcard" -import { Permission } from "@/permission" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" -import path from "path" import { Shell } from "@/shell/shell" +import { BashArity } from "@/permission/arity" + const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -81,41 +80,11 @@ export const BashTool = Tool.define("bash", async () => { if (!tree) { throw new Error("Failed to parse command") } - const agent = await Agent.get(ctx.agent) - - const checkExternalDirectory = async (dir: string) => { - if (Filesystem.contains(Instance.directory, dir)) return - const title = `This command references paths outside of ${Instance.directory}` - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [dir, path.join(dir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title, - metadata: { - command: params.command, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - command: params.command, - }, - `${title} so this command is not allowed to be executed.`, - ) - } - } - - await checkExternalDirectory(cwd) + const directories = new Set() + if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd) + const patterns = new Set() + const always = new Set() - const permissions = agent.permission.bash - - const askPatterns = new Set() for (const node of tree.rootNode.descendantsOfType("command")) { if (!node) continue const command = [] @@ -150,48 +119,33 @@ export const BashTool = Tool.define("bash", async () => { process.platform === "win32" && resolved.match(/^\/[a-z]\//) ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") : resolved - - await checkExternalDirectory(normalized) + directories.add(normalized) } } } - // always allow cd if it passes above check - if (command[0] !== "cd") { - const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions) - if (action === "deny") { - throw new Error( - `The user has specifically restricted access to this command: "${command.join(" ")}", you are not allowed to execute it. The user has these settings configured: ${JSON.stringify(permissions)}`, - ) - } - if (action === "ask") { - const pattern = (() => { - if (command.length === 0) return - const head = command[0] - // Find first non-flag argument as subcommand - const sub = command.slice(1).find((arg) => !arg.startsWith("-")) - return sub ? `${head} ${sub} *` : `${head} *` - })() - if (pattern) { - askPatterns.add(pattern) - } - } + // cd covered by above check + if (command.length && command[0] !== "cd") { + patterns.add(command.join(" ")) + always.add(BashArity.prefix(command).join(" ") + "*") } } - if (askPatterns.size > 0) { - const patterns = Array.from(askPatterns) - await Permission.ask({ - type: "bash", - pattern: patterns, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: params.command, - metadata: { - command: params.command, - patterns, - }, + if (directories.size > 0) { + await ctx.ask({ + permission: "external_directory", + patterns: Array.from(directories), + always: Array.from(directories).map((x) => x + "*"), + metadata: {}, + }) + } + + if (patterns.size > 0) { + await ctx.ask({ + permission: "bash", + patterns: Array.from(patterns), + always: Array.from(always), + metadata: {}, }) } diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 0227c06f5d52..369cdb45048e 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,8 +1,6 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./codesearch.txt" -import { Config } from "../config/config" -import { Permission } from "../permission" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -52,19 +50,15 @@ export const CodeSearchTool = Tool.define("codesearch", { ), }), async execute(params, ctx) { - const cfg = await Config.get() - if (cfg.permission?.webfetch === "ask") - await Permission.ask({ - type: "codesearch", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Search code for: " + params.query, - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }) + await ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) const codeRequest: McpCodeRequest = { jsonrpc: "2.0", diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 626799746486..787282ecd047 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -8,14 +8,12 @@ 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" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -41,36 +39,18 @@ export const EditTool = Tool.define("edit", { throw new Error("oldString and newString must be different") } - const agent = await Agent.get(ctx.agent) - 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) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Edit file outside working directory: ${filePath}`, - metadata: { - filepath: filePath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filePath, - parentDir, - }, - `File ${filePath} is not in the current working directory`, - ) - } + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir, path.join(parentDir, "*")], + always: [parentDir + "/*"], + metadata: { + filepath: filePath, + parentDir, + }, + }) } let diff = "" @@ -80,19 +60,15 @@ export const EditTool = Tool.define("edit", { 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 ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) await Bun.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, @@ -112,19 +88,15 @@ export const EditTool = Tool.define("edit", { diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(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 ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) await file.write(contentNew) await Bus.publish(File.Event.Edited, { @@ -137,6 +109,26 @@ export const EditTool = Tool.define("edit", { FileTime.read(ctx.sessionID, filePath) }) + 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 + } + + ctx.metadata({ + metadata: { + diff, + filediff, + diagnostics: {}, + }, + }) + let output = "" await LSP.touchFile(filePath, true) const diagnostics = await LSP.diagnostics() @@ -150,18 +142,6 @@ export const EditTool = Tool.define("edit", { output += `\nThis file has errors, please fix\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n\n` } - 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 --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 11c12f19ac47..0c643796defb 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -16,7 +16,17 @@ export const GlobTool = Tool.define("glob", { `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, ), }), - async execute(params) { + async execute(params, ctx) { + await ctx.ask({ + permission: "glob", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + }, + }) + let search = params.path ?? Instance.directory search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index d73bc1616839..4cbc5347f57d 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -14,11 +14,22 @@ export const GrepTool = Tool.define("grep", { path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), }), - async execute(params) { + async execute(params, ctx) { if (!params.pattern) { throw new Error("pattern is required") } + await ctx.ask({ + permission: "grep", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + include: params.include, + }, + }) + const searchPath = params.path || Instance.directory const rgPath = await Ripgrep.filepath() diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index 95c36e74593b..b8638b3e9048 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -40,9 +40,18 @@ export const ListTool = Tool.define("list", { path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), }), - async execute(params) { + async execute(params, ctx) { const searchPath = path.resolve(Instance.directory, params.path || ".") + await ctx.ask({ + permission: "list", + patterns: [searchPath], + always: ["*"], + metadata: { + path: searchPath, + }, + }) + const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const files = [] for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) { diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 2a15ed7e33bb..df4692bf6db4 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -26,7 +26,14 @@ export const LspTool = Tool.define("lsp", { line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), }), - execute: async (args) => { + execute: async (args, ctx) => { + await ctx.ask({ + permission: "lsp", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) const uri = pathToFileURL(file).href const position = { diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 93888f60bd2c..62d9f70f204c 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -3,11 +3,9 @@ import * as path from "path" import * as fs from "fs/promises" import { Tool } from "./tool" import { FileTime } from "../file/time" -import { Permission } from "../permission" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" -import { Agent } from "../agent/agent" import { Patch } from "../patch" import { Filesystem } from "../util/filesystem" import { createTwoFilesPatch } from "diff" @@ -39,7 +37,6 @@ export const PatchTool = Tool.define("patch", { } // Validate file paths and check permissions - const agent = await Agent.get(ctx.agent) const fileChanges: Array<{ filePath: string oldContent: string @@ -55,31 +52,15 @@ export const PatchTool = Tool.define("patch", { if (!Filesystem.contains(Instance.directory, filePath)) { const parentDir = path.dirname(filePath) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Patch file outside working directory: ${filePath}`, - metadata: { - filepath: filePath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filePath, - parentDir, - }, - `File ${filePath} is not in the current working directory`, - ) - } + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir, path.join(parentDir, "*")], + always: [parentDir + "/*"], + metadata: { + filepath: filePath, + parentDir, + }, + }) } switch (hunk.type) { @@ -152,18 +133,14 @@ export const PatchTool = Tool.define("patch", { } // Check permissions if needed - if (agent.permission.edit === "ask") { - await Permission.ask({ - type: "edit", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Apply patch to ${fileChanges.length} files`, - metadata: { - diff: totalDiff, - }, - }) - } + await ctx.ask({ + permission: "edit", + patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), + always: ["*"], + metadata: { + diff: totalDiff, + }, + }) // Apply the changes const changedFiles: string[] = [] diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fd81c4864a4e..847fe3ebe728 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,8 +8,6 @@ import DESCRIPTION from "./read.txt" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Identifier } from "../id/id" -import { Permission } from "../permission" -import { Agent } from "@/agent/agent" import { iife } from "@/util/iife" const DEFAULT_READ_LIMIT = 2000 @@ -28,37 +26,27 @@ export const ReadTool = Tool.define("read", { filepath = path.join(process.cwd(), filepath) } const title = path.relative(Instance.worktree, filepath) - const agent = await Agent.get(ctx.agent) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Access file outside working directory: ${filepath}`, - metadata: { - filepath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filepath, - parentDir, - }, - `File ${filepath} is not in the current working directory`, - ) - } + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir], + always: [parentDir + "/*"], + metadata: { + filepath, + parentDir, + }, + }) } + await ctx.ask({ + permission: "read", + patterns: [filepath], + always: ["*"], + metadata: {}, + }) + const block = iife(() => { const basename = path.basename(filepath) const whitelist = [".env.sample", ".env.example", ".example", ".env.template"] diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f975d52a0d4d..db5152847417 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -2,7 +2,6 @@ import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" -import { ListTool } from "./ls" import { BatchTool } from "./batch" import { ReadTool } from "./read" import { TaskTool } from "./task" @@ -135,27 +134,4 @@ export namespace ToolRegistry { ) return result } - - export async function enabled(agent: Agent.Info): Promise> { - const result: Record = {} - - if (agent.permission.edit === "deny") { - result["edit"] = false - result["write"] = false - } - if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) { - result["bash"] = false - } - if (agent.permission.webfetch === "deny") { - result["webfetch"] = false - result["codesearch"] = false - result["websearch"] = false - } - // Disable skill tool if all skills are denied - if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) { - result["skill"] = false - } - - return result - } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index b56276f61ef4..00a081eaca03 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -2,21 +2,13 @@ import path from "path" import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" -import { Agent } from "../agent/agent" -import { Permission } from "../permission" -import { Wildcard } from "../util/wildcard" import { ConfigMarkdown } from "../config/markdown" -const parameters = z.object({ - name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), -}) - -export const SkillTool: Tool.Info = { - id: "skill", - async init(ctx) { - const skills = await Skill.all() +export const SkillTool = Tool.define("skill", async () => { + const skills = await Skill.all() - // Filter skills by agent permissions if agent provided + // Filter skills by agent permissions if agent provided + /* let accessibleSkills = skills if (ctx?.agent) { const permissions = ctx.agent.permission.skill @@ -25,81 +17,61 @@ export const SkillTool: Tool.Info = { return action !== "deny" }) } + */ - const description = - accessibleSkills.length === 0 - ? "Load a skill to get detailed instructions for a specific task. No skills are currently available." - : [ - "Load a skill to get detailed instructions for a specific task.", - "Skills provide specialized knowledge and step-by-step guidance.", - "Use this when a task matches an available skill's description.", - "", - ...accessibleSkills.flatMap((skill) => [ - ` `, - ` ${skill.name}`, - ` ${skill.description}`, - ` `, - ]), - "", - ].join(" ") - - return { - description, - parameters, - async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) - - const skill = await Skill.get(params.name) - - if (!skill) { - const available = await Skill.all().then((x) => x.map((s) => s.name).join(", ")) - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } + const description = + skills.length === 0 + ? "Load a skill to get detailed instructions for a specific task. No skills are currently available." + : [ + "Load a skill to get detailed instructions for a specific task.", + "Skills provide specialized knowledge and step-by-step guidance.", + "Use this when a task matches an available skill's description.", + "", + ...skills.flatMap((skill) => [ + ` `, + ` ${skill.name}`, + ` ${skill.description}`, + ` `, + ]), + "", + ].join(" ") - // Check permission using Wildcard.all on the skill name - const permissions = agent.permission.skill - const action = Wildcard.all(params.name, permissions) + return { + description, + parameters: z.object({ + name: z + .string() + .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"), + }), + async execute(params, ctx) { + const skill = await Skill.get(params.name) - if (action === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "skill", - ctx.callID, - { skill: params.name }, - `Access to skill "${params.name}" is denied for agent "${agent.name}".`, - ) - } + if (!skill) { + const available = Skill.all().then((x) => Object.keys(x).join(", ")) + throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) + } - if (action === "ask") { - await Permission.ask({ - type: "skill", - pattern: params.name, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Load skill: ${skill.name}`, - metadata: { name: skill.name, description: skill.description }, - }) - } - - // Load and parse skill content - const parsed = await ConfigMarkdown.parse(skill.location) - const dir = path.dirname(skill.location) + await ctx.ask({ + permission: "skill", + patterns: [params.name], + always: [params.name], + metadata: {}, + }) + // Load and parse skill content + const parsed = await ConfigMarkdown.parse(skill.location) + const dir = path.dirname(skill.location) - // Format output similar to plugin pattern - const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join( - "\n", - ) + // Format output similar to plugin pattern + const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n") - return { - title: `Loaded skill: ${skill.name}`, - output, - metadata: { - name: skill.name, - dir, - }, - } - }, - } - }, -} + return { + title: `Loaded skill: ${skill.name}`, + output, + metadata: { + name: skill.name, + dir, + }, + } + }, + } +}) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index bc93f497a917..112edc3dc88a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -29,6 +29,17 @@ export const TaskTool = Tool.define("task", async () => { command: z.string().describe("The command that triggered this task").optional(), }), async execute(params, ctx) { + const config = await Config.get() + await ctx.ask({ + permission: "task", + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + }, + }) + const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) const session = await iife(async () => { @@ -40,6 +51,28 @@ export const TaskTool = Tool.define("task", async () => { return await Session.create({ parentID: ctx.sessionID, title: params.description + ` (@${agent.name} subagent)`, + permission: [ + { + permission: "todowrite", + pattern: "*", + action: "deny", + }, + { + permission: "todoread", + pattern: "*", + action: "deny", + }, + { + permission: "task", + pattern: "*", + action: "deny", + }, + ...(config.experimental?.primary_tools?.map((t) => ({ + pattern: "*", + action: "allow" as const, + permission: t, + })) ?? []), + ], }) }) const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) @@ -88,7 +121,6 @@ export const TaskTool = Tool.define("task", async () => { using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - const config = await Config.get() const result = await SessionPrompt.prompt({ messageID, sessionID: session.id, @@ -102,7 +134,6 @@ export const TaskTool = Tool.define("task", async () => { todoread: false, task: false, ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), - ...agent.tools, }, parts: promptParts, }) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index cea8d53228de..440f1563c707 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -8,9 +8,16 @@ export const TodoWriteTool = Tool.define("todowrite", { parameters: z.object({ todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), }), - async execute(params, opts) { + async execute(params, ctx) { + await ctx.ask({ + permission: "todowrite", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + await Todo.update({ - sessionID: opts.sessionID, + sessionID: ctx.sessionID, todos: params.todos, }) return { @@ -26,8 +33,15 @@ export const TodoWriteTool = Tool.define("todowrite", { export const TodoReadTool = Tool.define("todoread", { description: "Use this tool to read your todo list", parameters: z.object({}), - async execute(_params, opts) { - const todos = await Todo.get(opts.sessionID) + async execute(_params, ctx) { + await ctx.ask({ + permission: "todoread", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + + const todos = await Todo.get(ctx.sessionID) return { title: `${todos.filter((x) => x.status !== "completed").length} todos`, metadata: { diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index acee24902c1f..434a3d426605 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,6 +1,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" +import type { PermissionNext } from "../permission/next" export namespace Tool { interface Metadata { @@ -19,6 +20,7 @@ export namespace Tool { callID?: string extra?: { [key: string]: any } metadata(input: { title?: string; metadata?: M }): void + ask(input: Omit): Promise } export interface Info { id: string diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index cf1940bf8650..634c68f4eeae 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -2,8 +2,6 @@ import z from "zod" import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" -import { Config } from "../config/config" -import { Permission } from "../permission" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -25,20 +23,16 @@ export const WebFetchTool = Tool.define("webfetch", { throw new Error("URL must start with http:// or https://") } - const cfg = await Config.get() - if (cfg.permission?.webfetch === "ask") - await Permission.ask({ - type: "webfetch", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Fetch content from: " + params.url, - metadata: { - url: params.url, - format: params.format, - timeout: params.timeout, - }, - }) + await ctx.ask({ + permission: "webfetch", + patterns: [params.url], + always: ["*"], + metadata: { + url: params.url, + format: params.format, + timeout: params.timeout, + }, + }) const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 4064d12f38fb..f6df36f10f9e 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,8 +1,6 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./websearch.txt" -import { Config } from "../config/config" -import { Permission } from "../permission" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -59,22 +57,18 @@ export const WebSearchTool = Tool.define("websearch", { .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), }), async execute(params, ctx) { - const cfg = await Config.get() - if (cfg.permission?.webfetch === "ask") - await Permission.ask({ - type: "websearch", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Search web for: " + params.query, - metadata: { - query: params.query, - numResults: params.numResults, - livecrawl: params.livecrawl, - type: params.type, - contextMaxCharacters: params.contextMaxCharacters, - }, - }) + await ctx.ask({ + permission: "websearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + numResults: params.numResults, + livecrawl: params.livecrawl, + type: params.type, + contextMaxCharacters: params.contextMaxCharacters, + }, + }) const searchRequest: McpSearchRequest = { jsonrpc: "2.0", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index a0e87299a44e..a0ca6b14f7c7 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -2,14 +2,14 @@ import z from "zod" import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" -import { Permission } from "../permission" +import { createTwoFilesPatch } from "diff" 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 { trimDiff } from "./edit" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -21,55 +21,29 @@ export const WriteTool = Tool.define("write", { filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), }), async execute(params, ctx) { - const agent = await Agent.get(ctx.agent) - const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + /* TODO if (!Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) - if (agent.permission.external_directory === "ask") { - await Permission.ask({ - type: "external_directory", - pattern: [parentDir, path.join(parentDir, "*")], - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Write file outside working directory: ${filepath}`, - metadata: { - filepath, - parentDir, - }, - }) - } else if (agent.permission.external_directory === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "external_directory", - ctx.callID, - { - filepath: filepath, - parentDir, - }, - `File ${filepath} is not in the current working directory`, - ) - } + ... } + */ const file = Bun.file(filepath) const exists = await file.exists() + const contentOld = exists ? await file.text() : "" if (exists) await FileTime.assert(ctx.sessionID, filepath) - 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, - }, - }) + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filepath)], + always: ["*"], + metadata: { + filepath, + diff, + }, + }) await Bun.write(filepath, params.content) await Bus.publish(File.Event.Edited, { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 222bf8367e66..c11ebfbf07d6 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,11 +1,16 @@ import { test, expect } from "bun:test" -import path from "path" -import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" +import { PermissionNext } from "../../src/permission/next" -test("loads built-in agents when no custom agents configured", async () => { +// Helper to evaluate permission for a tool with wildcard pattern +function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { + if (!agent) return undefined + return PermissionNext.evaluate(permission, "*", agent.permission) +} + +test("returns default native agents when no config", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -14,133 +19,430 @@ test("loads built-in agents when no custom agents configured", async () => { const names = agents.map((a) => a.name) expect(names).toContain("build") expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }, + }) +}) + +test("build agent has correct default properties", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build).toBeDefined() + expect(build?.mode).toBe("primary") + expect(build?.native).toBe(true) + expect(evalPerm(build, "edit")).toBe("allow") + expect(evalPerm(build, "bash")).toBe("allow") + }, + }) +}) + +test("plan agent denies edits except .opencode/plan/*", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const plan = await Agent.get("plan") + expect(plan).toBeDefined() + // Wildcard is denied + expect(evalPerm(plan, "edit")).toBe("deny") + // But specific path is allowed + expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission)).toBe("allow") + }, + }) +}) + +test("explore agent denies edit and write", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const explore = await Agent.get("explore") + expect(explore).toBeDefined() + expect(explore?.mode).toBe("subagent") + expect(evalPerm(explore, "edit")).toBe("deny") + expect(evalPerm(explore, "write")).toBe("deny") + expect(evalPerm(explore, "todoread")).toBe("deny") + expect(evalPerm(explore, "todowrite")).toBe("deny") + }, + }) +}) + +test("general agent denies todo tools", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const general = await Agent.get("general") + expect(general).toBeDefined() + expect(general?.mode).toBe("subagent") + expect(general?.hidden).toBe(true) + expect(evalPerm(general, "todoread")).toBe("deny") + expect(evalPerm(general, "todowrite")).toBe("deny") + }, + }) +}) + +test("compaction agent denies all permissions", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const compaction = await Agent.get("compaction") + expect(compaction).toBeDefined() + expect(compaction?.hidden).toBe(true) + expect(evalPerm(compaction, "bash")).toBe("deny") + expect(evalPerm(compaction, "edit")).toBe("deny") + expect(evalPerm(compaction, "read")).toBe("deny") }, }) }) -test("custom subagent works alongside built-in primary agents", async () => { +test("custom agent from config creates new agent", async () => { await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - const agentDir = path.join(opencodeDir, "agent") - await fs.mkdir(agentDir, { recursive: true }) + config: { + agent: { + my_custom_agent: { + model: "openai/gpt-4", + description: "My custom agent", + temperature: 0.5, + top_p: 0.9, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const custom = await Agent.get("my_custom_agent") + expect(custom).toBeDefined() + expect(custom?.model?.providerID).toBe("openai") + expect(custom?.model?.modelID).toBe("gpt-4") + expect(custom?.description).toBe("My custom agent") + expect(custom?.temperature).toBe(0.5) + expect(custom?.topP).toBe(0.9) + expect(custom?.native).toBe(false) + expect(custom?.mode).toBe("all") + }, + }) +}) + +test("custom agent config overrides native agent properties", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { + model: "anthropic/claude-3", + description: "Custom build agent", + temperature: 0.7, + color: "#FF0000", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build).toBeDefined() + expect(build?.model?.providerID).toBe("anthropic") + expect(build?.model?.modelID).toBe("claude-3") + expect(build?.description).toBe("Custom build agent") + expect(build?.temperature).toBe(0.7) + expect(build?.color).toBe("#FF0000") + expect(build?.native).toBe(true) + }, + }) +}) - await Bun.write( - path.join(agentDir, "helper.md"), - `--- -model: test/model -mode: subagent ---- -Helper subagent prompt`, - ) +test("agent disable removes agent from list", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + explore: { disable: true }, + }, }, }) await Instance.provide({ directory: tmp.path, fn: async () => { + const explore = await Agent.get("explore") + expect(explore).toBeUndefined() const agents = await Agent.list() - const helper = agents.find((a) => a.name === "helper") - expect(helper).toBeDefined() - expect(helper?.mode).toBe("subagent") + const names = agents.map((a) => a.name) + expect(names).not.toContain("explore") + }, + }) +}) - // Built-in primary agents should still exist - const build = agents.find((a) => a.name === "build") +test("agent permission config merges with defaults", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { + permission: { + bash: { + "rm -rf *": "deny", + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") expect(build).toBeDefined() - expect(build?.mode).toBe("primary") + // Specific pattern is denied + expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny") + // Edit still allowed + expect(evalPerm(build, "edit")).toBe("allow") + }, + }) +}) + +test("global permission config applies to all agents", async () => { + await using tmp = await tmpdir({ + config: { + permission: { + bash: "deny", + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build).toBeDefined() + expect(evalPerm(build, "bash")).toBe("deny") }, }) }) -test("throws error when all primary agents are disabled", async () => { +test("agent steps/maxSteps config sets steps property", async () => { await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent: { - build: { disable: true }, - plan: { disable: true }, + config: { + agent: { + build: { steps: 50 }, + plan: { maxSteps: 100 }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + const plan = await Agent.get("plan") + expect(build?.steps).toBe(50) + expect(plan?.steps).toBe(100) + }, + }) +}) + +test("agent mode can be overridden", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + explore: { mode: "primary" }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const explore = await Agent.get("explore") + expect(explore?.mode).toBe("primary") + }, + }) +}) + +test("agent name can be overridden", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { name: "Builder" }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build?.name).toBe("Builder") + }, + }) +}) + +test("agent prompt can be set from config", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { prompt: "Custom system prompt" }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build?.prompt).toBe("Custom system prompt") + }, + }) +}) + +test("unknown agent properties are placed into options", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { + random_property: "hello", + another_random: 123, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build?.options.random_property).toBe("hello") + expect(build?.options.another_random).toBe(123) + }, + }) +}) + +test("agent options merge correctly", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { + options: { + custom_option: true, + another_option: "value", }, - }), - ) + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build?.options.custom_option).toBe(true) + expect(build?.options.another_option).toBe("value") + }, + }) +}) + +test("multiple custom agents can be defined", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + agent_a: { + description: "Agent A", + mode: "subagent", + }, + agent_b: { + description: "Agent B", + mode: "primary", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agentA = await Agent.get("agent_a") + const agentB = await Agent.get("agent_b") + expect(agentA?.description).toBe("Agent A") + expect(agentA?.mode).toBe("subagent") + expect(agentB?.description).toBe("Agent B") + expect(agentB?.mode).toBe("primary") + }, + }) +}) + +test("Agent.get returns undefined for non-existent agent", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nonExistent = await Agent.get("does_not_exist") + expect(nonExistent).toBeUndefined() + }, + }) +}) + +test("default permission includes doom_loop and external_directory as ask", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(evalPerm(build, "doom_loop")).toBe("ask") + expect(evalPerm(build, "external_directory")).toBe("ask") }, }) +}) + +test("webfetch is allowed by default", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - try { - await Agent.list() - expect(true).toBe(false) // should not reach here - } catch (e: any) { - expect(e.data?.message).toContain("No primary agents are available") - } + const build = await Agent.get("build") + expect(evalPerm(build, "webfetch")).toBe("allow") }, }) }) -test("does not throw when at least one primary agent remains", async () => { +test("legacy tools config converts to permissions", async () => { await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent: { - build: { disable: true }, + config: { + agent: { + build: { + tools: { + bash: false, + read: false, }, - }), - ) + }, + }, }, }) await Instance.provide({ directory: tmp.path, fn: async () => { - const agents = await Agent.list() - const plan = agents.find((a) => a.name === "plan") - expect(plan).toBeDefined() - expect(plan?.mode).toBe("primary") + const build = await Agent.get("build") + expect(evalPerm(build, "bash")).toBe("deny") + expect(evalPerm(build, "read")).toBe("deny") }, }) }) -test("custom primary agent satisfies requirement when built-ins disabled", async () => { +test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => { await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - const agentDir = path.join(opencodeDir, "agent") - await fs.mkdir(agentDir, { recursive: true }) - - await Bun.write( - path.join(agentDir, "custom.md"), - `--- -model: test/model -mode: primary ---- -Custom primary agent`, - ) - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent: { - build: { disable: true }, - plan: { disable: true }, + config: { + agent: { + build: { + tools: { + write: false, }, - }), - ) + }, + }, }, }) await Instance.provide({ directory: tmp.path, fn: async () => { - const agents = await Agent.list() - const custom = agents.find((a) => a.name === "custom") - expect(custom).toBeDefined() - expect(custom?.mode).toBe("primary") + const build = await Agent.get("build") + expect(evalPerm(build, "edit")).toBe("deny") }, }) }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8871fd50bab3..c2ed3abe8fab 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -205,11 +205,13 @@ test("handles agent configuration", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.agent?.["test_agent"]).toEqual({ - model: "test/model", - temperature: 0.7, - description: "test agent", - }) + expect(config.agent?.["test_agent"]).toEqual( + expect.objectContaining({ + model: "test/model", + temperature: 0.7, + description: "test agent", + }), + ) }, }) }) @@ -292,6 +294,8 @@ test("migrates mode field to agent field", async () => { model: "test/model", temperature: 0.5, mode: "primary", + options: {}, + permission: {}, }) }, }) @@ -318,11 +322,13 @@ Test agent prompt`, directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.agent?.["test"]).toEqual({ - name: "test", - model: "test/model", - prompt: "Test agent prompt", - }) + expect(config.agent?.["test"]).toEqual( + expect.objectContaining({ + name: "test", + model: "test/model", + prompt: "Test agent prompt", + }), + ) }, }) }) @@ -472,7 +478,7 @@ Helper subagent prompt`, directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.agent?.["helper"]).toEqual({ + expect(config.agent?.["helper"]).toMatchObject({ name: "helper", model: "test/model", mode: "subagent", @@ -534,13 +540,142 @@ test("deduplicates duplicate plugins from global and local configs", async () => }) }) -test("compaction config defaults to true when not specified", async () => { +// Legacy tools migration tests + +test("migrates legacy tools config to permissions - allow", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + bash: true, + read: true, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + bash: "allow", + read: "allow", + }) + }, + }) +}) + +test("migrates legacy tools config to permissions - deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + bash: false, + webfetch: false, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + bash: "deny", + webfetch: "deny", + }) + }, + }) +}) + +test("migrates legacy write tool to edit permission", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + write: true, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "allow", + }) + }, + }) +}) + +test("migrates legacy edit tool to edit permission", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + edit: false, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "deny", + }) + }, + }) +}) + +test("migrates legacy patch tool to edit permission", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + patch: true, + }, + }, + }, }), ) }, @@ -549,21 +684,26 @@ test("compaction config defaults to true when not specified", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - // When not specified, compaction should be undefined (defaults handled in usage) - expect(config.compaction).toBeUndefined() + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "allow", + }) }, }) }) -test("compaction config can disable auto compaction", async () => { +test("migrates legacy multiedit tool to edit permission", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", - compaction: { - auto: false, + agent: { + test: { + tools: { + multiedit: false, + }, + }, }, }), ) @@ -573,21 +713,29 @@ test("compaction config can disable auto compaction", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.compaction?.auto).toBe(false) - expect(config.compaction?.prune).toBeUndefined() + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "deny", + }) }, }) }) -test("compaction config can disable prune", async () => { +test("migrates mixed legacy tools config", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", - compaction: { - prune: false, + agent: { + test: { + tools: { + bash: true, + write: true, + read: false, + webfetch: true, + }, + }, }, }), ) @@ -597,22 +745,32 @@ test("compaction config can disable prune", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.compaction?.prune).toBe(false) - expect(config.compaction?.auto).toBeUndefined() + expect(config.agent?.["test"]?.permission).toEqual({ + bash: "allow", + edit: "allow", + read: "deny", + webfetch: "allow", + }) }, }) }) -test("compaction config can disable both auto and prune", async () => { +test("merges legacy tools with existing permission config", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", - compaction: { - auto: false, - prune: false, + agent: { + test: { + permission: { + glob: "allow", + }, + tools: { + bash: true, + }, + }, }, }), ) @@ -622,8 +780,10 @@ test("compaction config can disable both auto and prune", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.compaction?.auto).toBe(false) - expect(config.compaction?.prune).toBe(false) + expect(config.agent?.["test"]?.permission).toEqual({ + glob: "allow", + bash: "allow", + }) }, }) }) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 31cf0ae993d4..ed8c5e344a81 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import * as fs from "fs/promises" import os from "os" import path from "path" +import type { Config } from "../../src/config/config" // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { @@ -10,6 +11,7 @@ function sanitizePath(p: string): string { type TmpDirOptions = { git?: boolean + config?: Partial init?: (dir: string) => Promise dispose?: (dir: string) => Promise } @@ -20,6 +22,15 @@ export async function tmpdir(options?: TmpDirOptions) { await $`git init`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } + if (options?.config) { + await Bun.write( + path.join(dirpath, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + ...options.config, + }), + ) + } const extra = await options?.init?.(dirpath) const realpath = sanitizePath(await fs.realpath(dirpath)) const result = { diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts new file mode 100644 index 000000000000..634e41e72430 --- /dev/null +++ b/packages/opencode/test/permission/arity.test.ts @@ -0,0 +1,33 @@ +import { test, expect } from "bun:test" +import { BashArity } from "../../src/permission/arity" + +test("arity 1 - unknown commands default to first token", () => { + expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"]) + expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"]) +}) + +test("arity 2 - two token commands", () => { + expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]) + expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"]) +}) + +test("arity 3 - three token commands", () => { + expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"]) + expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"]) +}) + +test("longest match wins - nested prefixes", () => { + expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"]) + expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"]) +}) + +test("exact length matches", () => { + expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"]) + expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]) +}) + +test("edge cases", () => { + expect(BashArity.prefix([])).toEqual([]) + expect(BashArity.prefix(["single"])).toEqual(["single"]) + expect(BashArity.prefix(["git"])).toEqual(["git"]) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts new file mode 100644 index 000000000000..31af4cd4500e --- /dev/null +++ b/packages/opencode/test/permission/next.test.ts @@ -0,0 +1,652 @@ +import { test, expect } from "bun:test" +import { PermissionNext } from "../../src/permission/next" +import { Instance } from "../../src/project/instance" +import { Storage } from "../../src/storage/storage" +import { tmpdir } from "../fixture/fixture" + +// fromConfig tests + +test("fromConfig - string value becomes wildcard rule", () => { + const result = PermissionNext.fromConfig({ bash: "allow" }) + expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) +}) + +test("fromConfig - object value converts to rules array", () => { + const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } }) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "deny" }, + ]) +}) + +test("fromConfig - mixed string and object values", () => { + const result = PermissionNext.fromConfig({ + bash: { "*": "allow", rm: "deny" }, + edit: "allow", + webfetch: "ask", + }) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "edit", pattern: "*", action: "allow" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + ]) +}) + +test("fromConfig - empty object", () => { + const result = PermissionNext.fromConfig({}) + expect(result).toEqual([]) +}) + +// merge tests + +test("merge - simple concatenation", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "*", action: "allow" }], + [{ permission: "bash", pattern: "*", action: "deny" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "deny" }, + ]) +}) + +test("merge - adds new permission", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "*", action: "allow" }], + [{ permission: "edit", pattern: "*", action: "deny" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + ]) +}) + +test("merge - concatenates rules for same permission", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "foo", action: "ask" }], + [{ permission: "bash", pattern: "*", action: "deny" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "foo", action: "ask" }, + { permission: "bash", pattern: "*", action: "deny" }, + ]) +}) + +test("merge - multiple rulesets", () => { + const result = PermissionNext.merge( + [{ permission: "bash", pattern: "*", action: "allow" }], + [{ permission: "bash", pattern: "rm", action: "ask" }], + [{ permission: "edit", pattern: "*", action: "allow" }], + ) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "ask" }, + { permission: "edit", pattern: "*", action: "allow" }, + ]) +}) + +test("merge - empty ruleset does nothing", () => { + const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) + expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) +}) + +test("merge - preserves rule order", () => { + const result = PermissionNext.merge( + [ + { permission: "edit", pattern: "src/*", action: "allow" }, + { permission: "edit", pattern: "src/secret/*", action: "deny" }, + ], + [{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }], + ) + expect(result).toEqual([ + { permission: "edit", pattern: "src/*", action: "allow" }, + { permission: "edit", pattern: "src/secret/*", action: "deny" }, + { permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }, + ]) +}) + +test("merge - config permission overrides default ask", () => { + // Simulates: defaults have "*": "ask", config sets bash: "allow" + const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] + const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const merged = PermissionNext.merge(defaults, config) + + // Config's bash allow should override default ask + expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow") + // Other permissions should still be ask (from defaults) + expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask") +}) + +test("merge - config ask overrides default allow", () => { + // Simulates: defaults have bash: "allow", config sets bash: "ask" + const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] + const merged = PermissionNext.merge(defaults, config) + + // Config's ask should override default allow + expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask") +}) + +// evaluate tests + +test("evaluate - exact pattern match", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard pattern match", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) + expect(result).toBe("allow") +}) + +test("evaluate - last matching rule wins", () => { + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - last matching rule wins (wildcard after specific)", () => { + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - glob pattern match", () => { + const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - last matching glob wins", () => { + const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + { permission: "edit", pattern: "src/*", action: "deny" }, + { permission: "edit", pattern: "src/components/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - order matters for specificity", () => { + // If more specific rule comes first, later wildcard overrides it + const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ + { permission: "edit", pattern: "src/components/*", action: "allow" }, + { permission: "edit", pattern: "src/*", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - unknown permission returns ask", () => { + const result = PermissionNext.evaluate("unknown_tool", "anything", [ + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("ask") +}) + +test("evaluate - empty ruleset returns ask", () => { + const result = PermissionNext.evaluate("bash", "rm", []) + expect(result).toBe("ask") +}) + +test("evaluate - no matching pattern returns ask", () => { + const result = PermissionNext.evaluate("edit", "etc/passwd", [ + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("ask") +}) + +test("evaluate - empty rules array returns ask", () => { + const result = PermissionNext.evaluate("bash", "rm", []) + expect(result).toBe("ask") +}) + +test("evaluate - multiple matching patterns, last wins", () => { + const result = PermissionNext.evaluate("edit", "src/secret.ts", [ + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "src/*", action: "allow" }, + { permission: "edit", pattern: "src/secret.ts", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - non-matching patterns are skipped", () => { + const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "test/*", action: "deny" }, + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - exact match at end wins over earlier wildcard", () => { + const result = PermissionNext.evaluate("bash", "/bin/rm", [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "/bin/rm", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard at end overrides earlier exact match", () => { + const result = PermissionNext.evaluate("bash", "/bin/rm", [ + { permission: "bash", pattern: "/bin/rm", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +// wildcard permission tests + +test("evaluate - wildcard permission matches any permission", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard permission with specific pattern", () => { + const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) + expect(result).toBe("deny") +}) + +test("evaluate - glob permission pattern", () => { + const result = PermissionNext.evaluate("mcp_server_tool", "anything", [ + { permission: "mcp_*", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - specific permission and wildcard permission combined", () => { + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - wildcard permission does not match when specific exists", () => { + const result = PermissionNext.evaluate("edit", "src/foo.ts", [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "src/*", action: "allow" }, + ]) + expect(result).toBe("allow") +}) + +test("evaluate - multiple matching permission patterns combine rules", () => { + const result = PermissionNext.evaluate("mcp_dangerous", "anything", [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "mcp_*", pattern: "*", action: "allow" }, + { permission: "mcp_dangerous", pattern: "*", action: "deny" }, + ]) + expect(result).toBe("deny") +}) + +test("evaluate - wildcard permission fallback for unknown tool", () => { + const result = PermissionNext.evaluate("unknown_tool", "anything", [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]) + expect(result).toBe("ask") +}) + +test("evaluate - permission patterns sorted by length regardless of object order", () => { + // specific permission listed before wildcard, but specific should still win + const result = PermissionNext.evaluate("bash", "rm", [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "*", pattern: "*", action: "deny" }, + ]) + // With flat list, last matching rule wins - so "*" matches bash and wins + expect(result).toBe("deny") +}) + +test("evaluate - merges multiple rulesets", () => { + const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] + // approved comes after config, so rm should be denied + const result = PermissionNext.evaluate("bash", "rm", config, approved) + expect(result).toBe("deny") +}) + +// disabled tests + +test("disabled - returns empty set when all tools allowed", () => { + const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) + expect(result.size).toBe(0) +}) + +test("disabled - disables tool when denied", () => { + const result = PermissionNext.disabled( + ["bash", "edit", "read"], + [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "deny" }, + ], + ) + expect(result.has("bash")).toBe(true) + expect(result.has("edit")).toBe(false) + expect(result.has("read")).toBe(false) +}) + +test("disabled - disables edit/write/patch/multiedit when edit denied", () => { + const result = PermissionNext.disabled( + ["edit", "write", "patch", "multiedit", "bash"], + [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + ], + ) + expect(result.has("edit")).toBe(true) + expect(result.has("write")).toBe(true) + expect(result.has("patch")).toBe(true) + expect(result.has("multiedit")).toBe(true) + expect(result.has("bash")).toBe(false) +}) + +test("disabled - does not disable when partially denied", () => { + const result = PermissionNext.disabled( + ["bash"], + [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + ) + expect(result.has("bash")).toBe(false) +}) + +test("disabled - does not disable when action is ask", () => { + const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) + expect(result.size).toBe(0) +}) + +test("disabled - disables when wildcard deny even with specific allow", () => { + // Tool is disabled because evaluate("bash", "*", ...) returns "deny" + // The "echo *" allow rule doesn't match the "*" pattern we're checking + const result = PermissionNext.disabled( + ["bash"], + [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "echo *", action: "allow" }, + ], + ) + expect(result.has("bash")).toBe(true) +}) + +test("disabled - does not disable when wildcard allow after deny", () => { + const result = PermissionNext.disabled( + ["bash"], + [ + { permission: "bash", pattern: "rm *", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ], + ) + expect(result.has("bash")).toBe(false) +}) + +test("disabled - disables multiple tools", () => { + const result = PermissionNext.disabled( + ["bash", "edit", "webfetch"], + [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "webfetch", pattern: "*", action: "deny" }, + ], + ) + expect(result.has("bash")).toBe(true) + expect(result.has("edit")).toBe(true) + expect(result.has("webfetch")).toBe(true) +}) + +test("disabled - wildcard permission denies all tools", () => { + const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) + expect(result.has("bash")).toBe(true) + expect(result.has("edit")).toBe(true) + expect(result.has("read")).toBe(true) +}) + +test("disabled - specific allow overrides wildcard deny", () => { + const result = PermissionNext.disabled( + ["bash", "edit", "read"], + [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ], + ) + expect(result.has("bash")).toBe(false) + expect(result.has("edit")).toBe(true) + expect(result.has("read")).toBe(true) +}) + +// ask tests + +test("ask - resolves immediately when action is allow", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + }) + expect(result).toBeUndefined() + }, + }) +}) + +test("ask - throws RejectedError when action is deny", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect( + PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["rm -rf /"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], + }), + ).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("ask - returns pending promise when action is ask", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const promise = PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }) + // Promise should be pending, not resolved + expect(promise).toBeInstanceOf(Promise) + // Don't await - just verify it returns a promise + }, + }) +}) + +// reply tests + +test("reply - once resolves the pending ask", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise = PermissionNext.ask({ + id: "permission_test1", + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_test1", + reply: "once", + }) + + await expect(askPromise).resolves.toBeUndefined() + }, + }) +}) + +test("reply - reject throws RejectedError", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise = PermissionNext.ask({ + id: "permission_test2", + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_test2", + reply: "reject", + }) + + await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("reply - always persists approval and resolves", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise = PermissionNext.ask({ + id: "permission_test3", + sessionID: "session_test", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_test3", + reply: "always", + }) + + await expect(askPromise).resolves.toBeUndefined() + }, + }) + // Re-provide to reload state with stored permissions + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Stored approval should allow without asking + const result = await PermissionNext.ask({ + sessionID: "session_test2", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + expect(result).toBeUndefined() + }, + }) +}) + +test("reply - reject cancels all pending for same session", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise1 = PermissionNext.ask({ + id: "permission_test4a", + sessionID: "session_same", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + const askPromise2 = PermissionNext.ask({ + id: "permission_test4b", + sessionID: "session_same", + permission: "edit", + patterns: ["foo.ts"], + metadata: {}, + always: [], + ruleset: [], + }) + + // Catch rejections before they become unhandled + const result1 = askPromise1.catch((e) => e) + const result2 = askPromise2.catch((e) => e) + + // Reject the first one + await PermissionNext.reply({ + requestID: "permission_test4a", + reply: "reject", + }) + + // Both should be rejected + expect(await result1).toBeInstanceOf(PermissionNext.RejectedError) + expect(await result2).toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("ask - checks all patterns and stops on first deny", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect( + PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["echo hello", "rm -rf /"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }), + ).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) + +test("ask - allows all patterns when all match allow rules", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["echo hello", "ls -la", "pwd"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + }) + expect(result).toBeUndefined() + }, + }) +}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 9ef7dfb9d8f4..ee82813fb5a3 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test" import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" -import { Permission } from "../../src/permission" import { tmpdir } from "../fixture/fixture" +import type { PermissionNext } from "../../src/permission/next" const ctx = { sessionID: "test", @@ -12,6 +12,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } const projectRoot = path.join(__dirname, "../..") @@ -37,397 +38,164 @@ describe("tool.bash", () => { }) describe("tool.bash permissions", () => { - test("allows command matching allow pattern", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "echo *": "allow", - "*": "deny", - }, - }, - }), - ) - }, - }) + test("asks for bash permission with correct pattern", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() - const result = await bash.execute( + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( { command: "echo hello", description: "Echo hello", }, - ctx, + testCtx, ) - expect(result.metadata.exit).toBe(0) - expect(result.metadata.output).toContain("hello") + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo hello") }, }) }) - test("denies command matching deny pattern", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "curl *": "deny", - "*": "allow", - }, - }, - }), - ) - }, - }) + test("asks for bash permission with multiple commands", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() - await expect( - bash.execute( - { - command: "curl https://example.com", - description: "Fetch URL", - }, - ctx, - ), - ).rejects.toThrow("restricted") - }, - }) - }) - - test("denies all commands with wildcard deny", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "*": "deny", - }, - }, - }), + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "echo foo && echo bar", + description: "Echo twice", + }, + testCtx, ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await BashTool.init() - await expect( - bash.execute( - { - command: "ls", - description: "List files", - }, - ctx, - ), - ).rejects.toThrow("restricted") + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo foo") + expect(requests[0].patterns).toContain("echo bar") }, }) }) - test("more specific pattern overrides general pattern", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "*": "deny", - "ls *": "allow", - "pwd*": "allow", - }, - }, - }), - ) - }, - }) + test("asks for external_directory permission when cd to parent", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() - // ls should be allowed - const result = await bash.execute( - { - command: "ls -la", - description: "List files", + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) }, - ctx, - ) - expect(result.metadata.exit).toBe(0) - - // pwd should be allowed - const pwd = await bash.execute( + } + await bash.execute( { - command: "pwd", - description: "Print working directory", + command: "cd ../", + description: "Change to parent directory", }, - ctx, + testCtx, ) - expect(pwd.metadata.exit).toBe(0) - - // cat should be denied - await expect( - bash.execute( - { - command: "cat /etc/passwd", - description: "Read file", - }, - ctx, - ), - ).rejects.toThrow("restricted") + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() }, }) }) - test("denies dangerous subcommands while allowing safe ones", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "find *": "allow", - "find * -delete*": "deny", - "find * -exec*": "deny", - "*": "deny", - }, - }, - }), - ) - }, - }) + test("asks for external_directory permission when workdir is outside project", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() - // Basic find should work - const result = await bash.execute( + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( { - command: "find . -name '*.ts'", - description: "Find typescript files", + command: "ls", + workdir: "/tmp", + description: "List /tmp", }, - ctx, + testCtx, ) - expect(result.metadata.exit).toBe(0) - - // find -delete should be denied - await expect( - bash.execute( - { - command: "find . -name '*.tmp' -delete", - description: "Delete temp files", - }, - ctx, - ), - ).rejects.toThrow("restricted") - - // find -exec should be denied - await expect( - bash.execute( - { - command: "find . -name '*.ts' -exec cat {} \\;", - description: "Find and cat files", - }, - ctx, - ), - ).rejects.toThrow("restricted") + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain("/tmp") }, }) }) - test("allows git read commands while denying writes", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "git status*": "allow", - "git log*": "allow", - "git diff*": "allow", - "git branch": "allow", - "git commit *": "deny", - "git push *": "deny", - "*": "deny", - }, - }, - }), - ) - }, - }) + test("includes always patterns for auto-approval", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() - // git status should work - const status = await bash.execute( - { - command: "git status", - description: "Git status", + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) }, - ctx, - ) - expect(status.metadata.exit).toBe(0) - - // git log should work - const log = await bash.execute( + } + await bash.execute( { command: "git log --oneline -5", description: "Git log", }, - ctx, + testCtx, ) - expect(log.metadata.exit).toBe(0) - - // git commit should be denied - await expect( - bash.execute( - { - command: "git commit -m 'test'", - description: "Git commit", - }, - ctx, - ), - ).rejects.toThrow("restricted") - - // git push should be denied - await expect( - bash.execute( - { - command: "git push origin main", - description: "Git push", - }, - ctx, - ), - ).rejects.toThrow("restricted") + expect(requests.length).toBe(1) + expect(requests[0].always.length).toBeGreaterThan(0) + expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true) }, }) }) - test("denies external directory access when permission is deny", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - bash: { - "*": "allow", - }, - }, - }), - ) - }, - }) + test("does not ask for bash permission when command is cd only", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() - // Should deny cd to parent directory (cd is checked for external paths) - await expect( - bash.execute( - { - command: "cd ../", - description: "Change to parent directory", - }, - ctx, - ), - ).rejects.toThrow() - }, - }) - }) - - test("denies workdir outside project when external_directory is deny", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - bash: { - "*": "allow", - }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await BashTool.init() - await expect( - bash.execute( - { - command: "ls", - workdir: "/tmp", - description: "List /tmp", - }, - ctx, - ), - ).rejects.toThrow() - }, - }) - }) - - test("handles multiple commands in sequence", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - bash: { - "echo *": "allow", - "curl *": "deny", - "*": "deny", - }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await BashTool.init() - // echo && echo should work - const result = await bash.execute( + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( { - command: "echo foo && echo bar", - description: "Echo twice", + command: "cd .", + description: "Stay in current directory", }, - ctx, + testCtx, ) - expect(result.metadata.output).toContain("foo") - expect(result.metadata.output).toContain("bar") - - // echo && curl should fail (curl is denied) - await expect( - bash.execute( - { - command: "echo hi && curl https://example.com", - description: "Echo then curl", - }, - ctx, - ), - ).rejects.toThrow("restricted") + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeUndefined() }, }) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index f3da666a0914..a79d931575ce 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -11,6 +11,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } const projectRoot = path.join(__dirname, "../..") diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts index 6d7d6db87f58..3d3ec574e60a 100644 --- a/packages/opencode/test/tool/patch.test.ts +++ b/packages/opencode/test/tool/patch.test.ts @@ -3,16 +3,17 @@ import path from "path" import { PatchTool } from "../../src/tool/patch" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { Permission } from "../../src/permission" +import { PermissionNext } from "../../src/permission/next" import * as fs from "fs/promises" const ctx = { sessionID: "test", messageID: "", - toolCallID: "", + callID: "", agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } const patchTool = await PatchTool.init() @@ -59,7 +60,8 @@ describe("tool.patch", () => { patchTool.execute({ patchText: maliciousPatch }, ctx) // TODO: this sucks await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(Permission.pending()[ctx.sessionID]).toBeDefined() + const pending = await PermissionNext.list() + expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined() }, }) }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index eb860d04fcc3..1093a17feaab 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -3,6 +3,7 @@ import path from "path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import type { PermissionNext } from "../../src/permission/next" const ctx = { sessionID: "test", @@ -11,6 +12,7 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), metadata: () => {}, + ask: async () => {}, } describe("tool.read external_directory permission", () => { @@ -18,14 +20,6 @@ describe("tool.read external_directory permission", () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "test.txt"), "hello world") - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - }, - }), - ) }, }) await Instance.provide({ @@ -42,14 +36,6 @@ describe("tool.read external_directory permission", () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content") - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - }, - }), - ) }, }) await Instance.provide({ @@ -62,83 +48,74 @@ describe("tool.read external_directory permission", () => { }) }) - test("denies reading absolute path outside project directory", async () => { + test("asks for external_directory permission when reading absolute path outside project", async () => { await using outerTmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "secret.txt"), "secret data") }, }) - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - }, - }), - ) - }, - }) + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const read = await ReadTool.init() - await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow( - "not in the current working directory", - ) + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true) }, }) }) - test("denies reading relative path that traverses outside project directory", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "deny", - }, - }), - ) - }, - }) + test("asks for external_directory permission when reading relative path outside project", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const read = await ReadTool.init() - await expect(read.execute({ filePath: "../../../etc/passwd" }, ctx)).rejects.toThrow( - "not in the current working directory", - ) + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + // This will fail because file doesn't exist, but we can check if permission was asked + await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {}) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() }, }) }) - test("allows reading outside project directory when external_directory is allow", async () => { - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "external.txt"), "external content") - }, - }) + test("does not ask for external_directory permission when reading inside project", async () => { await using tmp = await tmpdir({ + git: true, init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - permission: { - external_directory: "allow", - }, - }), - ) + await Bun.write(path.join(dir, "internal.txt"), "internal content") }, }) await Instance.provide({ directory: tmp.path, fn: async () => { const read = await ReadTool.init() - const result = await read.execute({ filePath: path.join(outerTmp.path, "external.txt") }, ctx) - expect(result.output).toContain("external content") + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() }, }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b0610b64bc32..f56e8367795a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -55,8 +55,11 @@ import type { PartUpdateResponses, PathGetResponses, PermissionListResponses, + PermissionReplyErrors, + PermissionReplyResponses, PermissionRespondErrors, PermissionRespondResponses, + PermissionRuleset, ProjectCurrentResponses, ProjectListResponses, ProjectUpdateErrors, @@ -728,6 +731,7 @@ export class Session extends HeyApiClient { directory?: string parentID?: string title?: string + permission?: PermissionRuleset }, options?: Options, ) { @@ -739,6 +743,7 @@ export class Session extends HeyApiClient { { in: "query", key: "directory" }, { in: "body", key: "parentID" }, { in: "body", key: "title" }, + { in: "body", key: "permission" }, ], }, ], @@ -1591,6 +1596,8 @@ export class Permission extends HeyApiClient { * Respond to permission * * Approve or deny a permission request from the AI assistant. + * + * @deprecated */ public respond( parameters: { @@ -1626,6 +1633,43 @@ export class Permission extends HeyApiClient { }) } + /** + * Respond to permission request + * + * Approve or deny a permission request from the AI assistant. + */ + public reply( + parameters: { + requestID: string + directory?: string + reply?: "once" | "always" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "body", key: "reply" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * List pending permissions * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 85a3c4286252..10764bebee8a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -451,67 +451,32 @@ export type EventMessagePartRemoved = { } } -export type Permission = { +export type PermissionRequest = { id: string - type: string - pattern?: string | Array sessionID: string - messageID: string - callID?: string - title: string + permission: string + patterns: Array metadata: { [key: string]: unknown } - time: { - created: number + always: Array + tool?: { + messageID: string + callID: string } } -export type EventPermissionUpdated = { - type: "permission.updated" - properties: Permission +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest } export type EventPermissionReplied = { type: "permission.replied" properties: { sessionID: string - permissionID: string - response: string - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string - /** - * Unique identifier for the todo item - */ - id: string -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array + requestID: string + reply: "once" | "always" | "reject" } } @@ -551,6 +516,40 @@ export type EventSessionCompacted = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string + /** + * Unique identifier for the todo item + */ + id: string +} + +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -610,6 +609,16 @@ export type EventCommandExecuted = { } } +export type PermissionAction = "allow" | "deny" | "ask" + +export type PermissionRule = { + permission: string + pattern: string + action: PermissionAction +} + +export type PermissionRuleset = Array + export type Session = { id: string projectID: string @@ -632,6 +641,7 @@ export type Session = { compacting?: number archived?: number } + permission?: PermissionRuleset revert?: { messageID: string partID?: string @@ -756,13 +766,13 @@ export type Event = | EventMessageRemoved | EventMessagePartUpdated | EventMessagePartRemoved - | EventPermissionUpdated + | EventPermissionAsked | EventPermissionReplied - | EventFileEdited - | EventTodoUpdated | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventFileEdited + | EventTodoUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -1183,11 +1193,43 @@ export type ServerConfig = { cors?: Array } +export type PermissionActionConfig = "ask" | "allow" | "deny" + +export type PermissionObjectConfig = { + [key: string]: PermissionActionConfig +} + +export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig + +export type PermissionConfig = + | { + read?: PermissionRuleConfig + edit?: PermissionRuleConfig + glob?: PermissionRuleConfig + grep?: PermissionRuleConfig + list?: PermissionRuleConfig + bash?: PermissionRuleConfig + task?: PermissionRuleConfig + external_directory?: PermissionRuleConfig + todowrite?: PermissionActionConfig + todoread?: PermissionActionConfig + webfetch?: PermissionActionConfig + websearch?: PermissionActionConfig + codesearch?: PermissionActionConfig + lsp?: PermissionRuleConfig + doom_loop?: PermissionActionConfig + [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined + } + | PermissionActionConfig + export type AgentConfig = { model?: string temperature?: number top_p?: number prompt?: string + /** + * @deprecated Use 'permission' field instead + */ tools?: { [key: string]: boolean } @@ -1197,6 +1239,9 @@ export type AgentConfig = { */ description?: string mode?: "subagent" | "primary" | "all" + options?: { + [key: string]: unknown + } /** * Hex color code for the agent (e.g., #FF5733) */ @@ -1204,27 +1249,12 @@ export type AgentConfig = { /** * Maximum number of agentic iterations before forcing text-only response */ + steps?: number + /** + * @deprecated Use 'steps' field instead. + */ maxSteps?: number - permission?: { - edit?: "ask" | "allow" | "deny" - bash?: - | "ask" - | "allow" - | "deny" - | { - [key: string]: "ask" | "allow" | "deny" - } - skill?: - | "ask" - | "allow" - | "deny" - | { - [key: string]: "ask" | "allow" | "deny" - } - webfetch?: "ask" | "allow" | "deny" - doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" - } + permission?: PermissionConfig [key: string]: | unknown | string @@ -1236,28 +1266,12 @@ export type AgentConfig = { | "subagent" | "primary" | "all" - | string - | number | { - edit?: "ask" | "allow" | "deny" - bash?: - | "ask" - | "allow" - | "deny" - | { - [key: string]: "ask" | "allow" | "deny" - } - skill?: - | "ask" - | "allow" - | "deny" - | { - [key: string]: "ask" | "allow" | "deny" - } - webfetch?: "ask" | "allow" | "deny" - doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + [key: string]: unknown } + | string + | number + | PermissionConfig | undefined } @@ -1578,26 +1592,7 @@ export type Config = { */ instructions?: Array layout?: LayoutConfig - permission?: { - edit?: "ask" | "allow" | "deny" - bash?: - | "ask" - | "allow" - | "deny" - | { - [key: string]: "ask" | "allow" | "deny" - } - skill?: - | "ask" - | "allow" - | "deny" - | { - [key: string]: "ask" | "allow" | "deny" - } - webfetch?: "ask" | "allow" | "deny" - doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" - } + permission?: PermissionConfig tools?: { [key: string]: boolean } @@ -1886,34 +1881,19 @@ export type Agent = { mode: "subagent" | "primary" | "all" native?: boolean hidden?: boolean - default?: boolean topP?: number temperature?: number color?: string - permission: { - edit: "ask" | "allow" | "deny" - bash: { - [key: string]: "ask" | "allow" | "deny" - } - skill: { - [key: string]: "ask" | "allow" | "deny" - } - webfetch?: "ask" | "allow" | "deny" - doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" - } + permission: PermissionRuleset model?: { modelID: string providerID: string } prompt?: string - tools: { - [key: string]: boolean - } options: { [key: string]: unknown } - maxSteps?: number + steps?: number } export type McpStatusConnected = { @@ -2457,6 +2437,7 @@ export type SessionCreateData = { body?: { parentID?: string title?: string + permission?: PermissionRuleset } path?: never query?: { @@ -2972,6 +2953,9 @@ export type SessionPromptData = { } agent?: string noReply?: boolean + /** + * @deprecated tools and permissions have been merged, you can set permissions on the session itself now + */ tools?: { [key: string]: boolean } @@ -3156,6 +3140,9 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean + /** + * @deprecated tools and permissions have been merged, you can set permissions on the session itself now + */ tools?: { [key: string]: boolean } @@ -3391,6 +3378,41 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] +export type PermissionReplyData = { + body?: { + reply: "once" | "always" | "reject" + } + path: { + requestID: string + } + query?: { + directory?: string + } + url: "/permission/{requestID}/reply" +} + +export type PermissionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] + +export type PermissionReplyResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] + export type PermissionListData = { body?: never path?: never @@ -3404,7 +3426,7 @@ export type PermissionListResponses = { /** * List of pending permissions */ - 200: Array + 200: Array } export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 973c217fd18f..efa90d0b72f6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,3 +1,4 @@ +<<<<<<< HEAD { "openapi": "3.1.1", "info": { @@ -9750,3 +9751,6 @@ } } } +======= +{} +>>>>>>> 4f732c838 (feat: add command-aware permission request system for granular tool approval) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index ac80dada73e3..d0e8afefd64a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -455,8 +455,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const permission = createMemo(() => { const next = data.store.permission?.[props.message.sessionID]?.[0] - if (!next) return undefined - if (next.callID !== part.callID) return undefined + if (!next || !next.tool) return undefined + if (next.tool!.callID !== part.callID) return undefined return next }) @@ -732,19 +732,20 @@ ToolRegistry.register({ const childToolPart = createMemo(() => { const perm = childPermission() - if (!perm) return undefined + if (!perm || !perm.tool) return undefined const sessionId = childSessionId() if (!sessionId) return undefined // Find the tool part that matches the permission's callID const messages = data.store.message[sessionId] ?? [] - for (const msg of messages) { - const parts = data.store.part[msg.id] ?? [] - for (const part of parts) { - if (part.type === "tool" && (part as ToolPart).callID === perm.callID) { - return { part: part as ToolPart, message: msg } - } + const message = messages.findLast((m) => m.id === perm.tool!.messageID) + if (!message) return undefined + const parts = data.store.part[message.id] ?? [] + for (const part of parts) { + if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) { + return { part: part as ToolPart, message } } } + return undefined }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0ef1f135c77f..8285b98229b9 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -2,7 +2,7 @@ import { AssistantMessage, Message as MessageType, Part as PartType, - type Permission, + type PermissionRequest, TextPart, ToolPart, } from "@opencode-ai/sdk/v2/client" @@ -132,7 +132,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyPermissions: Permission[] = [] + const emptyPermissions: PermissionRequest[] = [] const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = [] const idle = { type: "idle" as const } @@ -235,16 +235,18 @@ export function SessionTurn( if (props.stepsExpanded) return emptyPermissionParts const next = nextPermission() - if (!next) return emptyPermissionParts - - for (const message of assistantMessages()) { - const parts = data.store.part[message.id] ?? emptyParts - for (const part of parts) { - if (part?.type !== "tool") continue - const tool = part as ToolPart - if (tool.callID === next.callID) return [{ part: tool, message }] - } + if (!next || !next.tool) return emptyPermissionParts + + const message = assistantMessages().findLast((m) => m.id === next.tool!.messageID) + if (!message) return emptyPermissionParts + + const parts = data.store.part[message.id] ?? emptyParts + for (const part of parts) { + if (part?.type !== "tool") continue + const tool = part as ToolPart + if (tool.callID === next.tool?.callID) return [{ part: tool, message }] } + return emptyPermissionParts }) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 3292ba579f07..9f7ec813f941 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2" +import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -14,7 +14,7 @@ type Data = { [sessionID: string]: PreloadMultiFileDiffResult[] } permission?: { - [sessionID: string]: Permission[] + [sessionID: string]: PermissionRequest[] } message: { [sessionID: string]: Message[] From 99794c25b0cc233c6e6caf7017f2839762731da4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 1 Jan 2026 22:54:43 +0000 Subject: [PATCH 014/138] chore: generate --- packages/sdk/openapi.json | 600 ++++++++++++++++++++------------------ 1 file changed, 321 insertions(+), 279 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index efa90d0b72f6..3393d1c8618f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,4 +1,3 @@ -<<<<<<< HEAD { "openapi": "3.1.1", "info": { @@ -968,6 +967,9 @@ }, "title": { "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" } } } @@ -2038,6 +2040,7 @@ "type": "boolean" }, "tools": { + "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", "type": "object", "propertyNames": { "type": "string" @@ -2413,6 +2416,7 @@ "type": "boolean" }, "tools": { + "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", "type": "object", "propertyNames": { "type": "string" @@ -2832,6 +2836,7 @@ } ], "summary": "Respond to permission", + "deprecated": true, "description": "Approve or deny a permission request from the AI assistant.", "responses": { "200": { @@ -2889,6 +2894,84 @@ ] } }, + "/permission/{requestID}/reply": { + "post": { + "operationId": "permission.reply", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Respond to permission request", + "description": "Approve or deny a permission request from the AI assistant.", + "responses": { + "200": { + "description": "Permission processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["reply"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" + } + ] + } + }, "/permission": { "get": { "operationId": "permission.list", @@ -2911,7 +2994,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Permission" + "$ref": "#/components/schemas/PermissionRequest" } } } @@ -6363,39 +6446,25 @@ }, "required": ["type", "properties"] }, - "Permission": { + "PermissionRequest": { "type": "object", "properties": { "id": { - "type": "string" - }, - "type": { - "type": "string" - }, - "pattern": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "type": "string", + "pattern": "^per.*" }, "sessionID": { - "type": "string" - }, - "messageID": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, - "callID": { + "permission": { "type": "string" }, - "title": { - "type": "string" + "patterns": { + "type": "array", + "items": { + "type": "string" + } }, "metadata": { "type": "object", @@ -6404,103 +6473,46 @@ }, "additionalProperties": {} }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number" - } - }, - "required": ["created"] - } - }, - "required": ["id", "type", "sessionID", "messageID", "title", "metadata", "time"] - }, - "Event.permission.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.updated" - }, - "properties": { - "$ref": "#/components/schemas/Permission" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" + "always": { + "type": "array", + "items": { + "type": "string" + } }, - "properties": { + "tool": { "type": "object", "properties": { - "sessionID": { - "type": "string" - }, - "permissionID": { + "messageID": { "type": "string" }, - "response": { + "callID": { "type": "string" } }, - "required": ["sessionID", "permissionID", "response"] + "required": ["messageID", "callID"] } }, - "required": ["type", "properties"] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] }, - "Event.file.edited": { + "Event.permission.asked": { "type": "object", "properties": { "type": { "type": "string", - "const": "file.edited" + "const": "permission.asked" }, "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] + "$ref": "#/components/schemas/PermissionRequest" } }, "required": ["type", "properties"] }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - }, - "id": { - "description": "Unique identifier for the todo item", - "type": "string" - } - }, - "required": ["content", "status", "priority", "id"] - }, - "Event.todo.updated": { + "Event.permission.replied": { "type": "object", "properties": { "type": { "type": "string", - "const": "todo.updated" + "const": "permission.replied" }, "properties": { "type": "object", @@ -6508,14 +6520,15 @@ "sessionID": { "type": "string" }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } + "requestID": { + "type": "string" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] } }, - "required": ["sessionID", "todos"] + "required": ["sessionID", "requestID", "reply"] } }, "required": ["type", "properties"] @@ -6623,6 +6636,72 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + }, + "id": { + "description": "Unique identifier for the todo item", + "type": "string" + } + }, + "required": ["content", "status", "priority", "id"] + }, + "Event.todo.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "todo.updated" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"] + } + }, + "required": ["type", "properties"] + }, "Event.tui.prompt.append": { "type": "object", "properties": { @@ -6764,6 +6843,31 @@ }, "required": ["type", "properties"] }, + "PermissionAction": { + "type": "string", + "enum": ["allow", "deny", "ask"] + }, + "PermissionRule": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/PermissionAction" + } + }, + "required": ["permission", "pattern", "action"] + }, + "PermissionRuleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRule" + } + }, "Session": { "type": "object", "properties": { @@ -6835,6 +6939,9 @@ }, "required": ["created", "updated"] }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, "revert": { "type": "object", "properties": { @@ -7202,25 +7309,25 @@ "$ref": "#/components/schemas/Event.message.part.removed" }, { - "$ref": "#/components/schemas/Event.permission.updated" + "$ref": "#/components/schemas/Event.permission.asked" }, { "$ref": "#/components/schemas/Event.permission.replied" }, { - "$ref": "#/components/schemas/Event.file.edited" + "$ref": "#/components/schemas/Event.session.status" }, { - "$ref": "#/components/schemas/Event.todo.updated" + "$ref": "#/components/schemas/Event.session.idle" }, { - "$ref": "#/components/schemas/Event.session.status" + "$ref": "#/components/schemas/Event.session.compacted" }, { - "$ref": "#/components/schemas/Event.session.idle" + "$ref": "#/components/schemas/Event.file.edited" }, { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/Event.todo.updated" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -7805,6 +7912,89 @@ }, "additionalProperties": false }, + "PermissionActionConfig": { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + "PermissionObjectConfig": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/PermissionActionConfig" + } + }, + "PermissionRuleConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + { + "$ref": "#/components/schemas/PermissionObjectConfig" + } + ] + }, + "PermissionConfig": { + "anyOf": [ + { + "type": "object", + "properties": { + "read": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "edit": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "glob": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "grep": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "list": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "bash": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "task": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "external_directory": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "todowrite": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "todoread": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "webfetch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "websearch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "codesearch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "lsp": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "doom_loop": { + "$ref": "#/components/schemas/PermissionActionConfig" + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/PermissionRuleConfig" + } + }, + { + "$ref": "#/components/schemas/PermissionActionConfig" + } + ] + }, "AgentConfig": { "type": "object", "properties": { @@ -7821,6 +8011,7 @@ "type": "string" }, "tools": { + "description": "@deprecated Use 'permission' field instead", "type": "object", "propertyNames": { "type": "string" @@ -7840,73 +8031,32 @@ "type": "string", "enum": ["subagent", "primary", "all"] }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, "color": { "description": "Hex color code for the agent (e.g., #FF5733)", "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, - "maxSteps": { + "steps": { "description": "Maximum number of agentic iterations before forcing text-only response", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, + "maxSteps": { + "description": "@deprecated Use 'steps' field instead.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - } - ] - }, - "skill": { - "anyOf": [ - { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "doom_loop": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - } + "$ref": "#/components/schemas/PermissionConfig" } }, "additionalProperties": {} @@ -8601,61 +8751,7 @@ "$ref": "#/components/schemas/LayoutConfig" }, "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - } - ] - }, - "skill": { - "anyOf": [ - { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "doom_loop": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - } + "$ref": "#/components/schemas/PermissionConfig" }, "tools": { "type": "object", @@ -9471,9 +9567,6 @@ "hidden": { "type": "boolean" }, - "default": { - "type": "boolean" - }, "topP": { "type": "number" }, @@ -9484,46 +9577,7 @@ "type": "string" }, "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "bash": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - }, - "skill": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - }, - "webfetch": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "doom_loop": { - "type": "string", - "enum": ["ask", "allow", "deny"] - }, - "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] - } - }, - "required": ["edit", "bash", "skill"] + "$ref": "#/components/schemas/PermissionRuleset" }, "model": { "type": "object", @@ -9540,15 +9594,6 @@ "prompt": { "type": "string" }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, "options": { "type": "object", "propertyNames": { @@ -9556,13 +9601,13 @@ }, "additionalProperties": {} }, - "maxSteps": { + "steps": { "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 } }, - "required": ["name", "mode", "permission", "tools", "options"] + "required": ["name", "mode", "permission", "options"] }, "MCPStatusConnected": { "type": "object", @@ -9751,6 +9796,3 @@ } } } -======= -{} ->>>>>>> 4f732c838 (feat: add command-aware permission request system for granular tool approval) From 7760b33956b719ac58a4b97a78804e8b824487d5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 17:13:31 -0600 Subject: [PATCH 015/138] ignore: comment out repo default for ask --- .opencode/opencode.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index f547e874dd78..210b37b8c280 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,7 +10,7 @@ "options": {}, }, }, - "permission": "ask", + // "permission": "ask", "mcp": { "context7": { "type": "remote", From 76186d19f3d45134ce5d2fac5959e1b02aadbb21 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 17:27:23 -0600 Subject: [PATCH 016/138] fix: ensure new permissions changes work for special case bash commands like rm, cd, etc --- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 30 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6671c939c409..46058b6658ea 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -119,7 +119,7 @@ export const BashTool = Tool.define("bash", async () => { process.platform === "win32" && resolved.match(/^\/[a-z]\//) ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") : resolved - directories.add(normalized) + if (!Filesystem.contains(Instance.directory, normalized)) directories.add(normalized) } } } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index ee82813fb5a3..2eb17a9fc949 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -147,6 +147,36 @@ describe("tool.bash permissions", () => { }) }) + test("does not ask for external_directory permission when rm inside project", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + + await Bun.write(path.join(tmp.path, "tmpfile"), "x") + + await bash.execute( + { + command: "rm tmpfile", + description: "Remove tmpfile", + }, + testCtx, + ) + + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }, + }) + }) + test("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ From 7aa1dbe8731c687e09401e9a59a19ebd380182b5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 1 Jan 2026 17:53:20 -0600 Subject: [PATCH 017/138] test: fix bash test --- packages/opencode/src/tool/bash.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 46058b6658ea..965e8d5450fc 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -108,6 +108,7 @@ export const BashTool = Tool.define("bash", async () => { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue const resolved = await $`realpath ${arg}` + .cwd(cwd) .quiet() .nothrow() .text() From 680db7b9e4a9dbcaadbd25cf2220fb5d7f538c05 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Thu, 1 Jan 2026 21:26:34 -0300 Subject: [PATCH 018/138] Desktop: Improve Resize Handle (#6608) --- packages/app/src/pages/layout.tsx | 18 ++++++++----- packages/ui/src/components/resize-handle.css | 28 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7aa1e244856a..4629cd9b6035 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1034,13 +1034,20 @@ export default function Layout(props: ParentProps) {
+
+ +
-
Date: Thu, 1 Jan 2026 19:53:29 -0500 Subject: [PATCH 019/138] fix: windows fallback for "less" cmd in `session list` (#6515) --- packages/opencode/src/cli/cmd/session.ts | 31 +++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index c8b5b0336607..c6a1fd4138f2 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -4,7 +4,36 @@ import { Session } from "../../session" import { bootstrap } from "../bootstrap" import { UI } from "../ui" import { Locale } from "../../util/locale" +import { Flag } from "../../flag/flag" import { EOL } from "os" +import path from "path" + +function pagerCmd(): string[] { + const lessOptions = ["-R", "-S"] + if (process.platform !== "win32") { + return ["less", ...lessOptions] + } + + // user could have less installed via other options + const lessOnPath = Bun.which("less") + if (lessOnPath) { + if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions] + } + + if (Flag.OPENCODE_GIT_BASH_PATH) { + const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe") + if (Bun.file(less).size) return [less, ...lessOptions] + } + + const git = Bun.which("git") + if (git) { + const less = path.join(git, "..", "..", "usr", "bin", "less.exe") + if (Bun.file(less).size) return [less, ...lessOptions] + } + + // Fall back to Windows built-in more (via cmd.exe) + return ["cmd", "/c", "more"] +} export const SessionCommand = cmd({ command: "session", @@ -58,7 +87,7 @@ export const SessionListCommand = cmd({ if (shouldPaginate) { const proc = Bun.spawn({ - cmd: ["less", "-R", "-S"], + cmd: pagerCmd(), stdin: "pipe", stdout: "inherit", stderr: "inherit", From 05eee679a33a5e6c07534037d725705f2b5807b0 Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Thu, 1 Jan 2026 19:56:23 -0500 Subject: [PATCH 020/138] feat: add assistant metadata to session export (#6611) --- .../src/cli/cmd/tui/routes/session/index.tsx | 133 +++----- .../cli/cmd/tui/ui/dialog-export-options.tsx | 64 +++- .../src/cli/cmd/tui/util/transcript.ts | 98 ++++++ .../opencode/test/cli/tui/transcript.test.ts | 297 ++++++++++++++++++ 4 files changed, 498 insertions(+), 94 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/transcript.ts create mode 100644 packages/opencode/test/cli/tui/transcript.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8a6c5cdd254f..94ba05614c58 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -68,6 +68,7 @@ import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" import { PermissionPrompt } from "./permission" import { DialogExportOptions } from "../../ui/dialog-export-options" +import { formatTranscript } from "../../util/transcript" addDefaultParsers(parsers.parsers) @@ -134,6 +135,7 @@ export function Session() { const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) + const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) @@ -712,47 +714,17 @@ export function Session() { category: "Session", onSelect: async (dialog) => { try { - // Format session transcript as markdown const sessionData = session() const sessionMessages = messages() - - let transcript = `# ${sessionData.title}\n\n` - transcript += `**Session ID:** ${sessionData.id}\n` - transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` - transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` - transcript += `---\n\n` - - for (const msg of sessionMessages) { - const parts = sync.data.part[msg.id] ?? [] - const role = msg.role === "user" ? "User" : "Assistant" - transcript += `## ${role}\n\n` - - for (const part of parts) { - if (part.type === "text" && !part.synthetic) { - transcript += `${part.text}\n\n` - } else if (part.type === "reasoning") { - if (showThinking()) { - transcript += `_Thinking:_\n\n${part.text}\n\n` - } - } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n` - if (showDetails() && part.state.input) { - transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` - } - if (showDetails() && part.state.status === "completed" && part.state.output) { - transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` - } - if (showDetails() && part.state.status === "error" && part.state.error) { - transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` - } - transcript += `\n\`\`\`\n\n` - } - } - - transcript += `---\n\n` - } - - // Copy to clipboard + const transcript = formatTranscript( + sessionData, + sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), + { + thinking: showThinking(), + toolDetails: showDetails(), + assistantMetadata: showAssistantMetadata(), + }, + ) await Clipboard.copy(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch (error) { @@ -762,75 +734,56 @@ export function Session() { }, }, { - title: "Export session transcript to file", + title: "Export session transcript", value: "session.export", keybind: "session_export", category: "Session", onSelect: async (dialog) => { try { - // Format session transcript as markdown const sessionData = session() const sessionMessages = messages() const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` - const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails()) + const options = await DialogExportOptions.show( + dialog, + defaultFilename, + showThinking(), + showDetails(), + showAssistantMetadata(), + false, + ) if (options === null) return - const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options - - let transcript = `# ${sessionData.title}\n\n` - transcript += `**Session ID:** ${sessionData.id}\n` - transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` - transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` - transcript += `---\n\n` - - for (const msg of sessionMessages) { - const parts = sync.data.part[msg.id] ?? [] - const role = msg.role === "user" ? "User" : "Assistant" - transcript += `## ${role}\n\n` - - for (const part of parts) { - if (part.type === "text" && !part.synthetic) { - transcript += `${part.text}\n\n` - } else if (part.type === "reasoning") { - if (includeThinking) { - transcript += `_Thinking:_\n\n${part.text}\n\n` - } - } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n` - if (includeToolDetails && part.state.input) { - transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` - } - if (includeToolDetails && part.state.status === "completed" && part.state.output) { - transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` - } - if (includeToolDetails && part.state.status === "error" && part.state.error) { - transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` - } - transcript += `\n\`\`\`\n\n` - } - } + const transcript = formatTranscript( + sessionData, + sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), + { + thinking: options.thinking, + toolDetails: options.toolDetails, + assistantMetadata: options.assistantMetadata, + }, + ) - transcript += `---\n\n` - } + if (options.openWithoutSaving) { + // Just open in editor without saving + await Editor.open({ value: transcript, renderer }) + } else { + const exportDir = process.cwd() + const filename = options.filename.trim() + const filepath = path.join(exportDir, filename) - // Save to file in current working directory - const exportDir = process.cwd() - const filename = customFilename.trim() - const filepath = path.join(exportDir, filename) + await Bun.write(filepath, transcript) - await Bun.write(filepath, transcript) + // Open with EDITOR if available + const result = await Editor.open({ value: transcript, renderer }) + if (result !== undefined) { + await Bun.write(filepath, result) + } - // Open with EDITOR if available - const result = await Editor.open({ value: transcript, renderer }) - if (result !== undefined) { - // User edited the file, save the changes - await Bun.write(filepath, result) + toast.show({ message: `Session exported to ${filename}`, variant: "success" }) } - - toast.show({ message: `Session exported to ${filename}`, variant: "success" }) } catch (error) { toast.show({ message: "Failed to export session", variant: "error" }) } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 874a236ee4c2..90699e1f0ba8 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -9,7 +9,15 @@ export type DialogExportOptionsProps = { defaultFilename: string defaultThinking: boolean defaultToolDetails: boolean - onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void + defaultAssistantMetadata: boolean + defaultOpenWithoutSaving: boolean + onConfirm?: (options: { + filename: string + thinking: boolean + toolDetails: boolean + assistantMetadata: boolean + openWithoutSaving: boolean + }) => void onCancel?: () => void } @@ -20,7 +28,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const [store, setStore] = createStore({ thinking: props.defaultThinking, toolDetails: props.defaultToolDetails, - active: "filename" as "filename" | "thinking" | "toolDetails", + assistantMetadata: props.defaultAssistantMetadata, + openWithoutSaving: props.defaultOpenWithoutSaving, + active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", }) useKeyboard((evt) => { @@ -29,10 +39,18 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { filename: textarea.plainText, thinking: store.thinking, toolDetails: store.toolDetails, + assistantMetadata: store.assistantMetadata, + openWithoutSaving: store.openWithoutSaving, }) } if (evt.name === "tab") { - const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"] + const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ + "filename", + "thinking", + "toolDetails", + "assistantMetadata", + "openWithoutSaving", + ] const currentIndex = order.indexOf(store.active) const nextIndex = (currentIndex + 1) % order.length setStore("active", order[nextIndex]) @@ -41,6 +59,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { if (evt.name === "space") { if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) + if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) evt.preventDefault() } }) @@ -71,6 +91,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { filename: textarea.plainText, thinking: store.thinking, toolDetails: store.toolDetails, + assistantMetadata: store.assistantMetadata, + openWithoutSaving: store.openWithoutSaving, }) }} height={3} @@ -108,6 +130,30 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { Include tool details + setStore("active", "assistantMetadata")} + > + + {store.assistantMetadata ? "[x]" : "[ ]"} + + Include assistant metadata + + setStore("active", "openWithoutSaving")} + > + + {store.openWithoutSaving ? "[x]" : "[ ]"} + + Open without saving + @@ -130,14 +176,24 @@ DialogExportOptions.show = ( defaultFilename: string, defaultThinking: boolean, defaultToolDetails: boolean, + defaultAssistantMetadata: boolean, + defaultOpenWithoutSaving: boolean, ) => { - return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => { + return new Promise<{ + filename: string + thinking: boolean + toolDetails: boolean + assistantMetadata: boolean + openWithoutSaving: boolean + } | null>((resolve) => { dialog.replace( () => ( resolve(options)} onCancel={() => resolve(null)} /> diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts new file mode 100644 index 000000000000..8f986c33792c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -0,0 +1,98 @@ +import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" +import { Locale } from "@/util/locale" + +export type TranscriptOptions = { + thinking: boolean + toolDetails: boolean + assistantMetadata: boolean +} + +export type SessionInfo = { + id: string + title: string + time: { + created: number + updated: number + } +} + +export type MessageWithParts = { + info: UserMessage | AssistantMessage + parts: Part[] +} + +export function formatTranscript( + session: SessionInfo, + messages: MessageWithParts[], + options: TranscriptOptions, +): string { + let transcript = `# ${session.title}\n\n` + transcript += `**Session ID:** ${session.id}\n` + transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n` + transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n` + transcript += `---\n\n` + + for (const msg of messages) { + transcript += formatMessage(msg.info, msg.parts, options) + transcript += `---\n\n` + } + + return transcript +} + +export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[], options: TranscriptOptions): string { + let result = "" + + if (msg.role === "user") { + result += `## User\n\n` + } else { + result += formatAssistantHeader(msg, options.assistantMetadata) + } + + for (const part of parts) { + result += formatPart(part, options) + } + + return result +} + +export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: boolean): string { + if (!includeMetadata) { + return `## Assistant\n\n` + } + + const duration = + msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : "" + + return `## Assistant (${Locale.titlecase(msg.agent)} · ${msg.modelID}${duration ? ` · ${duration}` : ""})\n\n` +} + +export function formatPart(part: Part, options: TranscriptOptions): string { + if (part.type === "text" && !part.synthetic) { + return `${part.text}\n\n` + } + + if (part.type === "reasoning") { + if (options.thinking) { + return `_Thinking:_\n\n${part.text}\n\n` + } + return "" + } + + if (part.type === "tool") { + let result = `\`\`\`\nTool: ${part.tool}\n` + if (options.toolDetails && part.state.input) { + result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (options.toolDetails && part.state.status === "completed" && part.state.output) { + result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (options.toolDetails && part.state.status === "error" && part.state.error) { + result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + result += `\n\`\`\`\n\n` + return result + } + + return "" +} diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts new file mode 100644 index 000000000000..2cb29e1a899d --- /dev/null +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from "bun:test" +import { + formatAssistantHeader, + formatMessage, + formatPart, + formatTranscript, +} from "../../../src/cli/cmd/tui/util/transcript" +import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" + +describe("transcript", () => { + describe("formatAssistantHeader", () => { + const baseMsg: AssistantMessage = { + id: "msg_123", + sessionID: "ses_123", + role: "assistant", + agent: "build", + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "", + parentID: "msg_parent", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000, completed: 1005400 }, + } + + test("includes metadata when enabled", () => { + const result = formatAssistantHeader(baseMsg, true) + expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n") + }) + + test("excludes metadata when disabled", () => { + const result = formatAssistantHeader(baseMsg, false) + expect(result).toBe("## Assistant\n\n") + }) + + test("handles missing completed time", () => { + const msg = { ...baseMsg, time: { created: 1000000 } } + const result = formatAssistantHeader(msg as AssistantMessage, true) + expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n") + }) + + test("titlecases agent name", () => { + const msg = { ...baseMsg, agent: "plan" } + const result = formatAssistantHeader(msg, true) + expect(result).toContain("Plan") + }) + }) + + describe("formatPart", () => { + const options = { thinking: true, toolDetails: true, assistantMetadata: true } + + test("formats text part", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "text", + text: "Hello world", + } + const result = formatPart(part, options) + expect(result).toBe("Hello world\n\n") + }) + + test("skips synthetic text parts", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "text", + text: "Synthetic content", + synthetic: true, + } + const result = formatPart(part, options) + expect(result).toBe("") + }) + + test("formats reasoning when thinking enabled", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "reasoning", + text: "Let me think...", + time: { start: 1000 }, + } + const result = formatPart(part, options) + expect(result).toBe("_Thinking:_\n\nLet me think...\n\n") + }) + + test("skips reasoning when thinking disabled", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "reasoning", + text: "Let me think...", + time: { start: 1000 }, + } + const result = formatPart(part, { ...options, thinking: false }) + expect(result).toBe("") + }) + + test("formats tool part with details", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "ls" }, + output: "file1.txt\nfile2.txt", + title: "List files", + metadata: {}, + time: { start: 1000, end: 1100 }, + }, + } + const result = formatPart(part, options) + expect(result).toContain("Tool: bash") + expect(result).toContain("**Input:**") + expect(result).toContain('"command": "ls"') + expect(result).toContain("**Output:**") + expect(result).toContain("file1.txt") + }) + + test("formats tool part without details when disabled", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "ls" }, + output: "file1.txt", + title: "List files", + metadata: {}, + time: { start: 1000, end: 1100 }, + }, + } + const result = formatPart(part, { ...options, toolDetails: false }) + expect(result).toContain("Tool: bash") + expect(result).not.toContain("**Input:**") + expect(result).not.toContain("**Output:**") + }) + + test("formats tool error", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "error", + input: { command: "invalid" }, + error: "Command failed", + time: { start: 1000, end: 1100 }, + }, + } + const result = formatPart(part, options) + expect(result).toContain("**Error:**") + expect(result).toContain("Command failed") + }) + }) + + describe("formatMessage", () => { + const options = { thinking: true, toolDetails: true, assistantMetadata: true } + + test("formats user message", () => { + const msg: UserMessage = { + id: "msg_123", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + time: { created: 1000000 }, + } + const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hello" }] + const result = formatMessage(msg, parts, options) + expect(result).toContain("## User") + expect(result).toContain("Hello") + }) + + test("formats assistant message with metadata", () => { + const msg: AssistantMessage = { + id: "msg_123", + sessionID: "ses_123", + role: "assistant", + agent: "build", + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "", + parentID: "msg_parent", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000, completed: 1005400 }, + } + const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }] + const result = formatMessage(msg, parts, options) + expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)") + expect(result).toContain("Hi there") + }) + }) + + describe("formatTranscript", () => { + test("formats complete transcript", () => { + const session = { + id: "ses_abc123", + title: "Test Session", + time: { created: 1000000000000, updated: 1000000001000 }, + } + const messages = [ + { + info: { + id: "msg_1", + sessionID: "ses_abc123", + role: "user" as const, + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + time: { created: 1000000000000 }, + }, + parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Hello" }], + }, + { + info: { + id: "msg_2", + sessionID: "ses_abc123", + role: "assistant" as const, + agent: "build", + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "", + parentID: "msg_1", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000000100, completed: 1000000000600 }, + }, + parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }], + }, + ] + const options = { thinking: false, toolDetails: false, assistantMetadata: true } + + const result = formatTranscript(session, messages, options) + + expect(result).toContain("# Test Session") + expect(result).toContain("**Session ID:** ses_abc123") + expect(result).toContain("## User") + expect(result).toContain("Hello") + expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)") + expect(result).toContain("Hi!") + expect(result).toContain("---") + }) + + test("formats transcript without assistant metadata", () => { + const session = { + id: "ses_abc123", + title: "Test Session", + time: { created: 1000000000000, updated: 1000000001000 }, + } + const messages = [ + { + info: { + id: "msg_1", + sessionID: "ses_abc123", + role: "assistant" as const, + agent: "build", + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "", + parentID: "msg_0", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000000100, completed: 1000000000600 }, + }, + parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }], + }, + ] + const options = { thinking: false, toolDetails: false, assistantMetadata: false } + + const result = formatTranscript(session, messages, options) + + expect(result).toContain("## Assistant\n\n") + expect(result).not.toContain("Build") + expect(result).not.toContain("claude-sonnet-4-20250514") + }) + }) +}) From 4f1ef9391061621c7f0a844894d210ee2a289e89 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 1 Jan 2026 20:42:06 -0500 Subject: [PATCH 021/138] ignore --- .opencode/opencode.jsonc | 1 - AGENTS.md | 11 ++--------- packages/opencode/src/server/server.ts | 1 - 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 210b37b8c280..ad9925767d4f 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,7 +10,6 @@ "options": {}, }, }, - // "permission": "ask", "mcp": { "context7": { "type": "remote", diff --git a/AGENTS.md b/AGENTS.md index 7fc710f6de27..87d59d4c9234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,4 @@ -## Debugging - - To test opencode in the `packages/opencode` directory you can run `bun dev` - -## SDK - -To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts - -## Tool Calling - +- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. +- the default branch in this repo is `dev` diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9d75308c1c15..4c6dac415bb2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -20,7 +20,6 @@ import { LSP } from "../lsp" import { Format } from "../format" import { MessageV2 } from "../session/message-v2" import { TuiRoute } from "./tui" -import { Permission } from "../permission" import { Instance } from "../project/instance" import { Vcs } from "../project/vcs" import { Agent } from "../agent/agent" From 963f407062ccde868b6abb5a21178dea861bc4ca Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 1 Jan 2026 21:01:00 -0500 Subject: [PATCH 022/138] tui: improve permission error handling and evaluation logic --- .opencode/opencode.jsonc | 5 +++ .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/permission/next.ts | 31 +++++++++++-------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index ad9925767d4f..6008ab9bc0c8 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,6 +10,11 @@ "options": {}, }, }, + "permission": { + "bash": { + "ls foo": "ask", + }, + }, "mcp": { "context7": { "type": "remote", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 94ba05614c58..4b3b67a317f8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1406,7 +1406,7 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) - const denied = createMemo(() => error()?.includes("rejected permission")) + const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule")) return ( Wildcard.match(request.permission, r.permission))) + if (rule.action === "ask") { const id = input.id ?? Identifier.ascending("permission") return new Promise((resolve, reject) => { const info: Request = { @@ -139,7 +140,7 @@ export namespace PermissionNext { Bus.publish(Event.Asked, info) }) } - if (action === "allow") continue + if (rule.action === "allow") continue } }, ) @@ -195,7 +196,7 @@ export namespace PermissionNext { for (const [id, pending] of Object.entries(s.pending)) { if (pending.info.sessionID !== sessionID) continue const ok = pending.info.patterns.every( - (pattern) => evaluate(pending.info.permission, pattern, s.approved) === "allow", + (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow", ) if (!ok) continue delete s.pending[id] @@ -215,13 +216,13 @@ export namespace PermissionNext { }, ) - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Action { + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { const merged = merge(...rulesets) log.info("evaluate", { permission, pattern, ruleset: merged }) const match = merged.findLast( (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), ) - return match?.action ?? "ask" + return match ?? { action: "allow", permission, pattern: "*" } } const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] @@ -230,7 +231,7 @@ export namespace PermissionNext { const result = new Set() for (const tool of tools) { const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - if (evaluate(permission, "*", ruleset) === "deny") { + if (evaluate(permission, "*", ruleset).action === "deny") { result.add(tool) } } @@ -238,11 +239,15 @@ export namespace PermissionNext { } export class RejectedError extends Error { - constructor(public readonly reason?: string) { + constructor() { + super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`) + } + } + + export class AutoRejectedError extends Error { + constructor(public readonly ruleset: Ruleset) { super( - reason !== undefined - ? reason - : `The user rejected permission to use this specific tool call. You may try again with different parameters.`, + `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`, ) } } From db8d83b53d12690ed8034d7670ad3782a2a399d0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 1 Jan 2026 21:01:34 -0500 Subject: [PATCH 023/138] tui: fix permission tests for new evaluate function signature --- packages/opencode/test/agent/agent.test.ts | 6 +-- .../opencode/test/permission/next.test.ts | 54 +++++++++---------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index c11ebfbf07d6..1036d3d71b83 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -7,7 +7,7 @@ import { PermissionNext } from "../../src/permission/next" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { if (!agent) return undefined - return PermissionNext.evaluate(permission, "*", agent.permission) + return PermissionNext.evaluate(permission, "*", agent.permission).action } test("returns default native agents when no config", async () => { @@ -53,7 +53,7 @@ test("plan agent denies edits except .opencode/plan/*", async () => { // Wildcard is denied expect(evalPerm(plan, "edit")).toBe("deny") // But specific path is allowed - expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission)).toBe("allow") + expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission).action).toBe("allow") }, }) }) @@ -201,7 +201,7 @@ test("agent permission config merges with defaults", async () => { const build = await Agent.get("build") expect(build).toBeDefined() // Specific pattern is denied - expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny") + expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") // Edit still allowed expect(evalPerm(build, "edit")).toBe("allow") }, diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 31af4cd4500e..b4ad692abbe5 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -113,9 +113,9 @@ test("merge - config permission overrides default ask", () => { const merged = PermissionNext.merge(defaults, config) // Config's bash allow should override default ask - expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow") + expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow") // Other permissions should still be ask (from defaults) - expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask") + expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask") }) test("merge - config ask overrides default allow", () => { @@ -125,19 +125,19 @@ test("merge - config ask overrides default allow", () => { const merged = PermissionNext.merge(defaults, config) // Config's ask should override default allow - expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask") + expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask") }) // evaluate tests test("evaluate - exact pattern match", () => { const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - wildcard pattern match", () => { const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - last matching rule wins", () => { @@ -145,7 +145,7 @@ test("evaluate - last matching rule wins", () => { { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, ]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - last matching rule wins (wildcard after specific)", () => { @@ -153,14 +153,14 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => { { permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - glob pattern match", () => { const result = PermissionNext.evaluate("edit", "src/foo.ts", [ { permission: "edit", pattern: "src/*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - last matching glob wins", () => { @@ -168,7 +168,7 @@ test("evaluate - last matching glob wins", () => { { permission: "edit", pattern: "src/*", action: "deny" }, { permission: "edit", pattern: "src/components/*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - order matters for specificity", () => { @@ -177,31 +177,31 @@ test("evaluate - order matters for specificity", () => { { permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "deny" }, ]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - unknown permission returns ask", () => { const result = PermissionNext.evaluate("unknown_tool", "anything", [ { permission: "bash", pattern: "*", action: "allow" }, ]) - expect(result).toBe("ask") + expect(result.action).toBe("ask") }) test("evaluate - empty ruleset returns ask", () => { const result = PermissionNext.evaluate("bash", "rm", []) - expect(result).toBe("ask") + expect(result.action).toBe("ask") }) test("evaluate - no matching pattern returns ask", () => { const result = PermissionNext.evaluate("edit", "etc/passwd", [ { permission: "edit", pattern: "src/*", action: "allow" }, ]) - expect(result).toBe("ask") + expect(result.action).toBe("ask") }) test("evaluate - empty rules array returns ask", () => { const result = PermissionNext.evaluate("bash", "rm", []) - expect(result).toBe("ask") + expect(result.action).toBe("ask") }) test("evaluate - multiple matching patterns, last wins", () => { @@ -210,7 +210,7 @@ test("evaluate - multiple matching patterns, last wins", () => { { permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/secret.ts", action: "deny" }, ]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - non-matching patterns are skipped", () => { @@ -219,7 +219,7 @@ test("evaluate - non-matching patterns are skipped", () => { { permission: "edit", pattern: "test/*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - exact match at end wins over earlier wildcard", () => { @@ -227,7 +227,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => { { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "/bin/rm", action: "deny" }, ]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - wildcard at end overrides earlier exact match", () => { @@ -235,26 +235,26 @@ test("evaluate - wildcard at end overrides earlier exact match", () => { { permission: "bash", pattern: "/bin/rm", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) // wildcard permission tests test("evaluate - wildcard permission matches any permission", () => { const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - wildcard permission with specific pattern", () => { const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - glob permission pattern", () => { const result = PermissionNext.evaluate("mcp_server_tool", "anything", [ { permission: "mcp_*", pattern: "*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - specific permission and wildcard permission combined", () => { @@ -262,7 +262,7 @@ test("evaluate - specific permission and wildcard permission combined", () => { { permission: "*", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - wildcard permission does not match when specific exists", () => { @@ -270,7 +270,7 @@ test("evaluate - wildcard permission does not match when specific exists", () => { permission: "*", pattern: "*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "allow" }, ]) - expect(result).toBe("allow") + expect(result.action).toBe("allow") }) test("evaluate - multiple matching permission patterns combine rules", () => { @@ -279,7 +279,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => { { permission: "mcp_*", pattern: "*", action: "allow" }, { permission: "mcp_dangerous", pattern: "*", action: "deny" }, ]) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - wildcard permission fallback for unknown tool", () => { @@ -287,7 +287,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => { { permission: "*", pattern: "*", action: "ask" }, { permission: "bash", pattern: "*", action: "allow" }, ]) - expect(result).toBe("ask") + expect(result.action).toBe("ask") }) test("evaluate - permission patterns sorted by length regardless of object order", () => { @@ -297,7 +297,7 @@ test("evaluate - permission patterns sorted by length regardless of object order { permission: "*", pattern: "*", action: "deny" }, ]) // With flat list, last matching rule wins - so "*" matches bash and wins - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) test("evaluate - merges multiple rulesets", () => { @@ -305,7 +305,7 @@ test("evaluate - merges multiple rulesets", () => { const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] // approved comes after config, so rm should be denied const result = PermissionNext.evaluate("bash", "rm", config, approved) - expect(result).toBe("deny") + expect(result.action).toBe("deny") }) // disabled tests From 2aaea71eb380a0e982d5d49a02c9df32826ca616 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 1 Jan 2026 21:18:28 -0500 Subject: [PATCH 024/138] tui: add heap snapshot option to system menu for debugging memory usage --- packages/opencode/src/cli/cmd/tui/app.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 35b33b4a09f7..eb89e411ddc0 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -33,6 +33,7 @@ import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" +import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { @@ -475,6 +476,20 @@ function App() { dialog.clear() }, }, + { + title: "Write heap snapshot", + category: "System", + value: "app.heap_snapshot", + onSelect: (dialog) => { + const path = writeHeapSnapshot() + toast.show({ + variant: "info", + message: `Heap snapshot written to ${path}`, + duration: 5000, + }) + dialog.clear() + }, + }, { title: "Suspend terminal", value: "terminal.suspend", From dad9c917d2fba1bd42503995ca2561dc868c98a1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 1 Jan 2026 21:28:11 -0500 Subject: [PATCH 025/138] tui: fix memory leaks in session management and improve permission error handling --- packages/opencode/src/permission/next.ts | 2 +- .../opencode/src/session/MEMORY_LEAK_FIXES.md | 108 ++++++++++++++++++ packages/opencode/src/session/prompt.ts | 3 + packages/opencode/src/session/retry.ts | 15 ++- .../opencode/test/permission/next.test.ts | 4 +- 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/session/MEMORY_LEAK_FIXES.md diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index e823e7429871..6223d54f2424 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -222,7 +222,7 @@ export namespace PermissionNext { const match = merged.findLast( (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), ) - return match ?? { action: "allow", permission, pattern: "*" } + return match ?? { action: "ask", permission, pattern: "*" } } const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] diff --git a/packages/opencode/src/session/MEMORY_LEAK_FIXES.md b/packages/opencode/src/session/MEMORY_LEAK_FIXES.md new file mode 100644 index 000000000000..86ec3fb7da8b --- /dev/null +++ b/packages/opencode/src/session/MEMORY_LEAK_FIXES.md @@ -0,0 +1,108 @@ +# Memory Leak Fixes Plan + +## Summary + +This document outlines the memory leak issues identified in the session module and the proposed fixes. + +## Issues Identified + +### Issue 1: Instance Dispose Callback Missing Callback Rejection (HIGH) + +**File**: `prompt.ts:69-73` + +**Problem**: When an instance is disposed, the dispose callback only aborts the AbortControllers but doesn't reject the pending promise callbacks. This leaves hanging promises that never resolve or reject. + +**Current Code**: +```typescript +async (current) => { + for (const item of Object.values(current)) { + item.abort.abort() + } +}, +``` + +**Fix**: Add callback rejection in the dispose handler: +```typescript +async (current) => { + for (const item of Object.values(current)) { + item.abort.abort() + for (const callback of item.callbacks) { + callback.reject() + } + } +}, +``` + +--- + +### Issue 2: Abort Listener Not Removed on Timeout (MEDIUM) + +**File**: `retry.ts:10-22` + +**Problem**: If the timeout resolves before the abort signal fires, the abort event listener remains attached to the signal. While `{ once: true }` ensures it fires only once if aborted, it doesn't remove the listener if the timeout fires first. This causes a minor memory leak for long-lived signals. + +**Current Code**: +```typescript +export async function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, Math.min(ms, RETRY_MAX_DELAY)) + signal.addEventListener( + "abort", + () => { + clearTimeout(timeout) + reject(new DOMException("Aborted", "AbortError")) + }, + { once: true }, + ) + }) +} +``` + +**Fix**: Store the abort handler and remove it when timeout resolves: +```typescript +export async function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abortHandler = () => { + clearTimeout(timeout) + reject(new DOMException("Aborted", "AbortError")) + } + const timeout = setTimeout(() => { + signal.removeEventListener("abort", abortHandler) + resolve() + }, Math.min(ms, RETRY_MAX_DELAY)) + signal.addEventListener("abort", abortHandler, { once: true }) + }) +} +``` + +--- + +### Issue 3: Orphaned AbortControllers (LOW - Optional) + +**Files**: +- `summary.ts:102`, `summary.ts:143` +- `prompt.ts:884-892`, `prompt.ts:945-953` + +**Problem**: New `AbortController()` instances are created inline and passed to functions, but the controllers are never stored or explicitly aborted. While this isn't a significant leak (GC handles them when streams complete), it's a code smell. + +**Example**: +```typescript +abort: new AbortController().signal, +``` + +**Recommendation**: Leave as-is. The overhead is minimal and the code is clearer. The streams complete naturally and the objects are garbage collected. + +--- + +## Implementation Checklist + +- [ ] Fix Issue 1: Add callback rejection in `prompt.ts` dispose handler +- [ ] Fix Issue 2: Clean up abort listener in `retry.ts` sleep function +- [ ] (Optional) Issue 3: No action needed + +## Testing Notes + +After implementing fixes: +1. Verify existing tests pass +2. Manually test session cancellation during active processing +3. Verify instance disposal properly cleans up all pending sessions diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d4fef6f7a1cd..b635cee7fb9a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -69,6 +69,9 @@ export namespace SessionPrompt { async (current) => { for (const item of Object.values(current)) { item.abort.abort() + for (const callback of item.callbacks) { + callback.reject() + } } }, ) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 2c18edffef2d..dd0fe2380223 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -9,15 +9,18 @@ export namespace SessionRetry { export async function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, Math.min(ms, RETRY_MAX_DELAY)) - signal.addEventListener( - "abort", + const abortHandler = () => { + clearTimeout(timeout) + reject(new DOMException("Aborted", "AbortError")) + } + const timeout = setTimeout( () => { - clearTimeout(timeout) - reject(new DOMException("Aborted", "AbortError")) + signal.removeEventListener("abort", abortHandler) + resolve() }, - { once: true }, + Math.min(ms, RETRY_MAX_DELAY), ) + signal.addEventListener("abort", abortHandler, { once: true }) }) } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index b4ad692abbe5..f654ca924752 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -451,7 +451,7 @@ test("ask - throws RejectedError when action is deny", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], }), - ).rejects.toBeInstanceOf(PermissionNext.RejectedError) + ).rejects.toBeInstanceOf(PermissionNext.AutoRejectedError) }, }) }) @@ -628,7 +628,7 @@ test("ask - checks all patterns and stops on first deny", async () => { { permission: "bash", pattern: "rm *", action: "deny" }, ], }), - ).rejects.toBeInstanceOf(PermissionNext.RejectedError) + ).rejects.toBeInstanceOf(PermissionNext.AutoRejectedError) }, }) }) From 07c008fe3dc2f4e69affeb16f97516fee152c9c2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 2 Jan 2026 02:28:48 +0000 Subject: [PATCH 026/138] chore: generate --- .../opencode/src/session/MEMORY_LEAK_FIXES.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/MEMORY_LEAK_FIXES.md b/packages/opencode/src/session/MEMORY_LEAK_FIXES.md index 86ec3fb7da8b..1c36f2462ca2 100644 --- a/packages/opencode/src/session/MEMORY_LEAK_FIXES.md +++ b/packages/opencode/src/session/MEMORY_LEAK_FIXES.md @@ -13,6 +13,7 @@ This document outlines the memory leak issues identified in the session module a **Problem**: When an instance is disposed, the dispose callback only aborts the AbortControllers but doesn't reject the pending promise callbacks. This leaves hanging promises that never resolve or reject. **Current Code**: + ```typescript async (current) => { for (const item of Object.values(current)) { @@ -22,6 +23,7 @@ async (current) => { ``` **Fix**: Add callback rejection in the dispose handler: + ```typescript async (current) => { for (const item of Object.values(current)) { @@ -42,6 +44,7 @@ async (current) => { **Problem**: If the timeout resolves before the abort signal fires, the abort event listener remains attached to the signal. While `{ once: true }` ensures it fires only once if aborted, it doesn't remove the listener if the timeout fires first. This causes a minor memory leak for long-lived signals. **Current Code**: + ```typescript export async function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -59,6 +62,7 @@ export async function sleep(ms: number, signal: AbortSignal): Promise { ``` **Fix**: Store the abort handler and remove it when timeout resolves: + ```typescript export async function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -66,10 +70,13 @@ export async function sleep(ms: number, signal: AbortSignal): Promise { clearTimeout(timeout) reject(new DOMException("Aborted", "AbortError")) } - const timeout = setTimeout(() => { - signal.removeEventListener("abort", abortHandler) - resolve() - }, Math.min(ms, RETRY_MAX_DELAY)) + const timeout = setTimeout( + () => { + signal.removeEventListener("abort", abortHandler) + resolve() + }, + Math.min(ms, RETRY_MAX_DELAY), + ) signal.addEventListener("abort", abortHandler, { once: true }) }) } @@ -79,13 +86,15 @@ export async function sleep(ms: number, signal: AbortSignal): Promise { ### Issue 3: Orphaned AbortControllers (LOW - Optional) -**Files**: +**Files**: + - `summary.ts:102`, `summary.ts:143` - `prompt.ts:884-892`, `prompt.ts:945-953` **Problem**: New `AbortController()` instances are created inline and passed to functions, but the controllers are never stored or explicitly aborted. While this isn't a significant leak (GC handles them when streams complete), it's a code smell. **Example**: + ```typescript abort: new AbortController().signal, ``` @@ -103,6 +112,7 @@ abort: new AbortController().signal, ## Testing Notes After implementing fixes: + 1. Verify existing tests pass 2. Manually test session cancellation during active processing 3. Verify instance disposal properly cleans up all pending sessions From b84a1f714bf0b81efdf89a0dd6e35fa2b3e8692a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 1 Jan 2026 21:35:14 -0500 Subject: [PATCH 027/138] tui: remove memory leak fixes documentation after implementation --- .../src/cli/cmd/tui/routes/session/index.tsx | 17 ++- .../opencode/src/session/MEMORY_LEAK_FIXES.md | 118 ------------------ 2 files changed, 11 insertions(+), 124 deletions(-) delete mode 100644 packages/opencode/src/session/MEMORY_LEAK_FIXES.md diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 4b3b67a317f8..d049ec4373cf 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1447,10 +1447,11 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child ) } -function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void }) { +function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) { const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( {props.children} + + {error()} + ) } @@ -1483,7 +1487,7 @@ function Bash(props: ToolProps) { return ( - + $ {props.input.command} {output()} @@ -1514,7 +1518,7 @@ function Write(props: ToolProps) { return ( - + ) { ? () => navigate({ type: "session", sessionID: props.metadata.sessionId! }) : undefined } + part={props.part} > @@ -1685,7 +1690,7 @@ function Edit(props: ToolProps) { return ( - + ) { return ( - + {props.output?.trim()} @@ -1754,7 +1759,7 @@ function TodoWrite(props: ToolProps) { return ( - + {(todo) => } diff --git a/packages/opencode/src/session/MEMORY_LEAK_FIXES.md b/packages/opencode/src/session/MEMORY_LEAK_FIXES.md deleted file mode 100644 index 1c36f2462ca2..000000000000 --- a/packages/opencode/src/session/MEMORY_LEAK_FIXES.md +++ /dev/null @@ -1,118 +0,0 @@ -# Memory Leak Fixes Plan - -## Summary - -This document outlines the memory leak issues identified in the session module and the proposed fixes. - -## Issues Identified - -### Issue 1: Instance Dispose Callback Missing Callback Rejection (HIGH) - -**File**: `prompt.ts:69-73` - -**Problem**: When an instance is disposed, the dispose callback only aborts the AbortControllers but doesn't reject the pending promise callbacks. This leaves hanging promises that never resolve or reject. - -**Current Code**: - -```typescript -async (current) => { - for (const item of Object.values(current)) { - item.abort.abort() - } -}, -``` - -**Fix**: Add callback rejection in the dispose handler: - -```typescript -async (current) => { - for (const item of Object.values(current)) { - item.abort.abort() - for (const callback of item.callbacks) { - callback.reject() - } - } -}, -``` - ---- - -### Issue 2: Abort Listener Not Removed on Timeout (MEDIUM) - -**File**: `retry.ts:10-22` - -**Problem**: If the timeout resolves before the abort signal fires, the abort event listener remains attached to the signal. While `{ once: true }` ensures it fires only once if aborted, it doesn't remove the listener if the timeout fires first. This causes a minor memory leak for long-lived signals. - -**Current Code**: - -```typescript -export async function sleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, Math.min(ms, RETRY_MAX_DELAY)) - signal.addEventListener( - "abort", - () => { - clearTimeout(timeout) - reject(new DOMException("Aborted", "AbortError")) - }, - { once: true }, - ) - }) -} -``` - -**Fix**: Store the abort handler and remove it when timeout resolves: - -```typescript -export async function sleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const abortHandler = () => { - clearTimeout(timeout) - reject(new DOMException("Aborted", "AbortError")) - } - const timeout = setTimeout( - () => { - signal.removeEventListener("abort", abortHandler) - resolve() - }, - Math.min(ms, RETRY_MAX_DELAY), - ) - signal.addEventListener("abort", abortHandler, { once: true }) - }) -} -``` - ---- - -### Issue 3: Orphaned AbortControllers (LOW - Optional) - -**Files**: - -- `summary.ts:102`, `summary.ts:143` -- `prompt.ts:884-892`, `prompt.ts:945-953` - -**Problem**: New `AbortController()` instances are created inline and passed to functions, but the controllers are never stored or explicitly aborted. While this isn't a significant leak (GC handles them when streams complete), it's a code smell. - -**Example**: - -```typescript -abort: new AbortController().signal, -``` - -**Recommendation**: Leave as-is. The overhead is minimal and the code is clearer. The streams complete naturally and the objects are garbage collected. - ---- - -## Implementation Checklist - -- [ ] Fix Issue 1: Add callback rejection in `prompt.ts` dispose handler -- [ ] Fix Issue 2: Clean up abort listener in `retry.ts` sleep function -- [ ] (Optional) Issue 3: No action needed - -## Testing Notes - -After implementing fixes: - -1. Verify existing tests pass -2. Manually test session cancellation during active processing -3. Verify instance disposal properly cleans up all pending sessions From 78940d5b7ee2f3e5020f87b400db1785b37a7d71 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 1 Jan 2026 08:48:35 -0600 Subject: [PATCH 028/138] wip(app): file context --- packages/app/src/app.tsx | 9 +- .../app/src/components/dialog-select-file.tsx | 10 +- packages/app/src/components/prompt-input.tsx | 121 ++++++- packages/app/src/context/file.tsx | 282 ++++++++++++++++ packages/app/src/context/prompt.tsx | 64 +++- packages/app/src/pages/session.tsx | 307 ++++++++++++------ packages/console/app/src/config.ts | 10 +- packages/ui/src/components/code.tsx | 105 +++++- 8 files changed, 774 insertions(+), 134 deletions(-) create mode 100644 packages/app/src/context/file.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b4bae7dc87b7..e41575e7ad48 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -16,6 +16,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk" import { ServerProvider, useServer } from "@/context/server" import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" +import { FileProvider } from "@/context/file" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" @@ -88,9 +89,11 @@ export function App() { component={(p) => ( - - - + + + + + )} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index b27afdc8bc58..8e68a3eb805f 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -6,11 +6,11 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" +import { useFile } from "@/context/file" export function DialogSelectFile() { const layout = useLayout() - const local = useLocal() + const file = useFile() const dialog = useDialog() const params = useParams() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) @@ -20,11 +20,13 @@ export function DialogSelectFile() { x} onSelect={(path) => { if (path) { - tabs().open("file://" + path) + const value = file.tab(path) + tabs().open(value) + file.load(path) } dialog.close() }} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 855eb31e145e..967b176064a9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -3,6 +3,7 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" +import { useFile, type FileSelection } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, @@ -83,6 +84,7 @@ export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() const local = useLocal() + const files = useFile() const prompt = usePrompt() const layout = useLayout() const params = useParams() @@ -126,6 +128,11 @@ export const PromptInput: Component = (props) => { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) + const activeFile = createMemo(() => { + const tab = tabs().active() + if (!tab) return + return files.pathFromTab(tab) + }) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const status = createMemo( () => @@ -303,10 +310,10 @@ export const PromptInput: Component = (props) => { event.preventDefault() setStore("dragging", false) - const files = event.dataTransfer?.files - if (!files) return + const dropped = event.dataTransfer?.files + if (!dropped) return - for (const file of Array.from(files)) { + for (const file of Array.from(dropped)) { if (ACCEPTED_FILE_TYPES.includes(file.type)) { await addImageAttachment(file) } @@ -360,8 +367,8 @@ export const PromptInput: Component = (props) => { } = useFilteredList({ items: async (query) => { const agents = agentList() - const files = await local.file.searchFilesAndDirectories(query) - const fileOptions: AtOption[] = files.map((path) => ({ type: "file", path, display: path })) + const paths = await files.searchFilesAndDirectories(query) + const fileOptions: AtOption[] = paths.map((path) => ({ type: "file", path, display: path })) return [...agents, ...fileOptions] }, key: atKey, @@ -1205,6 +1212,41 @@ export const PromptInput: Component = (props) => { }, })) + const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) + + const contextFileParts: Array<{ + id: string + type: "file" + mime: string + url: string + filename?: string + }> = [] + + const addContextFile = (path: string, selection?: FileSelection) => { + const absolute = toAbsolutePath(path) + const query = selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" + const url = `file://${absolute}${query}` + if (usedUrls.has(url)) return + usedUrls.add(url) + contextFileParts.push({ + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(path), + }) + } + + const activePath = activeFile() + if (activePath && prompt.context.activeTab()) { + addContextFile(activePath) + } + + for (const item of prompt.context.items()) { + if (item.type !== "file") continue + addContextFile(item.path, item.selection) + } + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ id: Identifier.ascending("part"), type: "file" as const, @@ -1214,7 +1256,6 @@ export const PromptInput: Component = (props) => { })) const isShellMode = store.mode === "shell" - tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) setStore("imageAttachments", []) @@ -1274,7 +1315,13 @@ export const PromptInput: Component = (props) => { type: "text" as const, text, } - const requestParts = [textPart, ...fileAttachmentParts, ...agentAttachmentParts, ...imageAttachmentParts] + const requestParts = [ + textPart, + ...fileAttachmentParts, + ...contextFileParts, + ...agentAttachmentParts, + ...imageAttachmentParts, + ] const optimisticParts = requestParts.map((part) => ({ ...part, sessionID: existing.id, @@ -1413,6 +1460,66 @@ export const PromptInput: Component = (props) => {
+ 0 || !!activeFile()}> +
+ + {(path) => ( +
+ +
+ {getDirectory(path())} + {getFilename(path())} + active +
+ prompt.context.removeActive()} + /> +
+ )} +
+ + + + + {(item) => ( +
+ +
+ {getDirectory(item.path)} + {getFilename(item.path)} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + +
+ prompt.context.remove(item.key)} + /> +
+ )} +
+
+
0}>
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx new file mode 100644 index 000000000000..a26f97c2a50d --- /dev/null +++ b/packages/app/src/context/file.tsx @@ -0,0 +1,282 @@ +import { createMemo, onCleanup } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { FileContent } from "@opencode-ai/sdk/v2" +import { showToast } from "@opencode-ai/ui/toast" +import { useParams } from "@solidjs/router" +import { getFilename } from "@opencode-ai/util/path" +import { useSDK } from "./sdk" +import { useSync } from "./sync" +import { persisted } from "@/utils/persist" + +export type FileSelection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type SelectedLineRange = { + start: number + end: number + side?: "additions" | "deletions" + endSide?: "additions" | "deletions" +} + +export type FileViewState = { + scrollTop?: number + scrollLeft?: number + selectedLines?: SelectedLineRange | null +} + +export type FileState = { + path: string + name: string + loaded?: boolean + loading?: boolean + error?: string + content?: FileContent +} + +function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +function stripQueryAndHash(input: string) { + const hashIndex = input.indexOf("#") + const queryIndex = input.indexOf("?") + + if (hashIndex !== -1 && queryIndex !== -1) { + return input.slice(0, Math.min(hashIndex, queryIndex)) + } + + if (hashIndex !== -1) return input.slice(0, hashIndex) + if (queryIndex !== -1) return input.slice(0, queryIndex) + return input +} + +export function selectionFromLines(range: SelectedLineRange): FileSelection { + const startLine = Math.min(range.start, range.end) + const endLine = Math.max(range.start, range.end) + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} + +function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { + if (range.start <= range.end) return range + + const startSide = range.side + const endSide = range.endSide ?? startSide + + return { + ...range, + start: range.end, + end: range.start, + side: endSide, + endSide: startSide !== endSide ? startSide : undefined, + } +} + +export const { use: useFile, provider: FileProvider } = createSimpleContext({ + name: "File", + init: () => { + const sdk = useSDK() + const sync = useSync() + const params = useParams() + + const directory = createMemo(() => sync.data.path.directory) + + function normalize(input: string) { + const root = directory() + const prefix = root.endsWith("/") ? root : root + "/" + + let path = stripQueryAndHash(stripFileProtocol(input)) + + if (path.startsWith(prefix)) { + path = path.slice(prefix.length) + } + + if (path.startsWith(root)) { + path = path.slice(root.length) + } + + if (path.startsWith("./")) { + path = path.slice(2) + } + + if (path.startsWith("/")) { + path = path.slice(1) + } + + return path + } + + function tab(input: string) { + const path = normalize(input) + return `file://${path}` + } + + function pathFromTab(tabValue: string) { + if (!tabValue.startsWith("file://")) return + return normalize(tabValue) + } + + const inflight = new Map>() + + const [store, setStore] = createStore<{ + file: Record + }>({ + file: {}, + }) + + const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`) + + const [view, setView, _, ready] = persisted( + viewKey(), + createStore<{ + file: Record + }>({ + file: {}, + }), + ) + + function ensure(path: string) { + if (!path) return + if (store.file[path]) return + setStore("file", path, { path, name: getFilename(path) }) + } + + function load(input: string, options?: { force?: boolean }) { + const path = normalize(input) + if (!path) return Promise.resolve() + + ensure(path) + + const current = store.file[path] + if (!options?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(path) + if (pending) return pending + + setStore( + "file", + path, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + + const promise = sdk.client.file + .read({ path }) + .then((x) => { + setStore( + "file", + path, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.content = x.data + }), + ) + }) + .catch((e) => { + setStore( + "file", + path, + produce((draft) => { + draft.loading = false + draft.error = e.message + }), + ) + showToast({ + variant: "error", + title: "Failed to load file", + description: e.message, + }) + }) + .finally(() => { + inflight.delete(path) + }) + + inflight.set(path, promise) + return promise + } + + const stop = sdk.event.listen((e) => { + const event = e.details + if (event.type !== "file.watcher.updated") return + const path = normalize(event.properties.file) + if (!path) return + if (path.startsWith(".git/")) return + if (!store.file[path]) return + load(path, { force: true }) + }) + + const get = (input: string) => store.file[normalize(input)] + + const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop + const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft + const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines + + const setScrollTop = (input: string, top: number) => { + const path = normalize(input) + setView("file", path, (current) => { + if (current?.scrollTop === top) return current + return { + ...(current ?? {}), + scrollTop: top, + } + }) + } + + const setScrollLeft = (input: string, left: number) => { + const path = normalize(input) + setView("file", path, (current) => { + if (current?.scrollLeft === left) return current + return { + ...(current ?? {}), + scrollLeft: left, + } + }) + } + + const setSelectedLines = (input: string, range: SelectedLineRange | null) => { + const path = normalize(input) + const next = range ? normalizeSelectedLines(range) : null + setView("file", path, (current) => { + if (current?.selectedLines === next) return current + return { + ...(current ?? {}), + selectedLines: next, + } + }) + } + + onCleanup(() => stop()) + + return { + ready, + normalize, + tab, + pathFromTab, + get, + load, + scrollTop, + scrollLeft, + setScrollTop, + setScrollLeft, + selectedLines, + setSelectedLines, + searchFiles: (query: string) => + sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)), + searchFilesAndDirectories: (query: string) => + sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)), + } + }, +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 25d8146eaed7..f77f62e3ca5e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -2,7 +2,7 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createMemo } from "solid-js" import { useParams } from "@solidjs/router" -import { TextSelection } from "./local" +import type { FileSelection } from "@/context/file" import { persisted } from "@/utils/persist" interface PartBase { @@ -18,7 +18,7 @@ export interface TextPart extends PartBase { export interface FileAttachmentPart extends PartBase { type: "file" path: string - selection?: TextSelection + selection?: FileSelection } export interface AgentPart extends PartBase { @@ -37,8 +37,24 @@ export interface ImageAttachmentPart { export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart export type Prompt = ContentPart[] +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection +} + +export type ContextItem = FileContextItem + export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (promptA.length !== promptB.length) return false for (let i = 0; i < promptA.length; i++) { @@ -48,8 +64,11 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "text" && partA.content !== (partB as TextPart).content) { return false } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false + if (partA.type === "file") { + const fileA = partA as FileAttachmentPart + const fileB = partB as FileAttachmentPart + if (fileA.path !== fileB.path) return false + if (!isSelectionEqual(fileA.selection, fileB.selection)) return false } if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) { return false @@ -61,7 +80,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { return true } -function cloneSelection(selection?: TextSelection) { +function cloneSelection(selection?: FileSelection) { if (!selection) return undefined return { ...selection } } @@ -84,24 +103,57 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( name: "Prompt", init: () => { const params = useParams() - const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`) const [store, setStore, _, ready] = persisted( name(), createStore<{ prompt: Prompt cursor?: number + context: { + activeTab: boolean + items: (ContextItem & { key: string })[] + } }>({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, + context: { + activeTab: true, + items: [], + }, }), ) + function keyForItem(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + return `${item.type}:${item.path}:${start}:${end}` + } + return { ready, current: createMemo(() => store.prompt), cursor: createMemo(() => store.cursor), dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + context: { + activeTab: createMemo(() => store.context.activeTab), + items: createMemo(() => store.context.items), + addActive() { + setStore("context", "activeTab", true) + }, + removeActive() { + setStore("context", "activeTab", false) + }, + add(item: ContextItem) { + const key = keyForItem(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + }, set(prompt: Prompt, cursorPosition?: number) { const next = clonePrompt(prompt) batch(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f738fec33e2f..f0e6a6e1de6a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -5,8 +5,8 @@ import { Show, Match, Switch, - createResource, createMemo, + createResource, createEffect, on, createRenderEffect, @@ -14,7 +14,8 @@ import { } from "solid-js" import { Dynamic } from "solid-js/web" -import { useLocal, type LocalFile } from "@/context/local" +import { useLocal } from "@/context/local" +import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" @@ -276,6 +277,7 @@ function Header(props: { onMobileMenuToggle?: () => void }) { export default function Page() { const layout = useLayout() const local = useLocal() + const file = useFile() const sync = useSync() const terminal = useTerminal() const dialog = useDialog() @@ -289,6 +291,58 @@ export default function Page() { const permission = usePermission() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) + + function normalizeTab(tab: string) { + if (!tab.startsWith("file://")) return tab + return file.tab(tab) + } + + function normalizeTabs(list: string[]) { + const seen = new Set() + const next: string[] = [] + for (const item of list) { + const value = normalizeTab(item) + if (seen.has(value)) continue + seen.add(value) + next.push(value) + } + return next + } + + const openTab = (value: string) => { + const next = normalizeTab(value) + tabs().open(next) + + const path = file.pathFromTab(next) + if (path) file.load(path) + } + + createEffect(() => { + const active = tabs().active() + if (!active) return + + const path = file.pathFromTab(active) + if (path) file.load(path) + }) + + createEffect(() => { + const current = tabs().all() + if (current.length === 0) return + + const next = normalizeTabs(current) + if (same(current, next)) return + + tabs().setAll(next) + + const active = tabs().active() + if (!active) return + if (!active.startsWith("file://")) return + + const normalized = normalizeTab(active) + if (active === normalized) return + tabs().setActive(normalized) + }) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) @@ -322,7 +376,6 @@ export default function Page() { ) const [store, setStore] = createStore({ - clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, userInteracted: false, @@ -659,30 +712,6 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) }) - const resetClickTimer = () => { - if (!store.clickTimer) return - clearTimeout(store.clickTimer) - setStore("clickTimer", undefined) - } - - const startClickTimer = () => { - const newClickTimer = setTimeout(() => { - setStore("clickTimer", undefined) - }, 300) - setStore("clickTimer", newClickTimer as unknown as number) - } - - const handleTabClick = async (tab: string) => { - if (store.clickTimer) { - resetClickTimer() - } else { - if (tab.startsWith("file://")) { - local.file.open(tab.replace("file://", "")) - } - startClickTimer() - } - } - const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -748,57 +777,24 @@ export default function Page() { ) } - const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => { + const FileVisual = (props: { path: string; active?: boolean }): JSX.Element => { return (
- - {props.file.name} - - + {getFilename(props.path)}
) } - const SortableTab = (props: { - tab: string - onTabClick: (tab: string) => void - onTabClose: (tab: string) => void - }): JSX.Element => { + const SortableTab = (props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element => { const sortable = createSortable(props.tab) - const [file] = createResource( - () => props.tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) + const path = createMemo(() => file.pathFromTab(props.tab)) return ( // @ts-ignore
@@ -811,11 +807,8 @@ export default function Page() { } hideCloseButton - onClick={() => props.onTabClick(props.tab)} > - - {(f) => } - + {(p) => }
@@ -1377,7 +1370,7 @@ export default function Page() { > - +
@@ -1414,9 +1407,7 @@ export default function Page() { - - {(tab) => } - + {(tab) => }
{(tab) => { - const [file] = createResource( - () => tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, + let scroll: HTMLDivElement | undefined + let scrollFrame: number | undefined + let pendingTop: number | undefined + + const path = createMemo(() => file.pathFromTab(tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + return file.selectedLines(p) ?? null + }) + const selection = createMemo(() => { + const range = selectedLines() + if (!range) return + return selectionFromLines(range) + }) + const selectionLabel = createMemo(() => { + const sel = selection() + if (!sel) return + if (sel.startLine === sel.endLine) return `L${sel.startLine}` + return `L${sel.startLine}-${sel.endLine}` + }) + + const restoreScroll = () => { + const el = scroll + const p = path() + if (!el || !p) return + + const top = file.scrollTop(p) + if (top === undefined) return + if (el.scrollTop === top) return + el.scrollTop = top + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + const p = path() + if (!p) return + + pendingTop = event.currentTarget.scrollTop + if (scrollFrame !== undefined) return + + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined + + const top = pendingTop + pendingTop = undefined + if (top === undefined) return + + file.setScrollTop(p, top) + }) + } + + createEffect( + on( + () => state()?.loaded, + (loaded) => { + if (!loaded) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => file.ready(), + (ready) => { + if (!ready) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), ) + + onCleanup(() => { + if (scrollFrame === undefined) return + cancelAnimationFrame(scrollFrame) + }) + return ( - - - - {(f) => ( - { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > + + {(sel) => ( +
+ +
+ )} +
+ + + { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + }} + overflow="scroll" + class="select-text pb-40" + /> + + +
Loading...
+
+ + {(err) =>
{err()}
}
@@ -1493,19 +1596,11 @@ export default function Page() { - {(draggedFile) => { - const [file] = createResource( - () => draggedFile(), - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) + {(tab) => { + const path = createMemo(() => file.pathFromTab(tab())) return (
- {(f) => } + {(p) => }
) }} diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index bf20681ae15d..8d7da0b977f5 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/sst/opencode", starsFormatted: { - compact: "41K", - full: "41,000", + compact: "45K", + full: "45,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "450", - commits: "6,000", - monthlyUsers: "400,000", + contributors: "500", + commits: "6,500", + monthlyUsers: "650,000", }, } as const diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index fda08260fca3..ed7db368c3b4 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,18 +1,52 @@ -import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, splitProps } from "solid-js" +import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" +import { ComponentProps, createEffect, createMemo, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" +type SelectionSide = "additions" | "deletions" + export type CodeProps = FileOptions & { file: FileContents annotations?: LineAnnotation[] + selectedLines?: SelectedLineRange | null class?: string classList?: ComponentProps<"div">["classList"] } +function findElement(node: Node | null): HTMLElement | undefined { + if (!node) return + if (node instanceof HTMLElement) return node + return node.parentElement ?? undefined +} + +function findLineNumber(node: Node | null): number | undefined { + const element = findElement(node) + if (!element) return + + const line = element.closest("[data-line]") + if (!(line instanceof HTMLElement)) return + + const value = parseInt(line.dataset.line ?? "", 10) + if (Number.isNaN(value)) return + + return value +} + +function findSide(node: Node | null): SelectionSide | undefined { + const element = findElement(node) + if (!element) return + + const code = element.closest("[data-code]") + if (!(code instanceof HTMLElement)) return + + if (code.hasAttribute("data-deletions")) return "deletions" + return "additions" +} + export function Code(props: CodeProps) { let container!: HTMLDivElement - const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"]) + + const [local, others] = splitProps(props, ["file", "class", "classList", "annotations", "selectedLines"]) const file = createMemo( () => @@ -25,6 +59,57 @@ export function Code(props: CodeProps) { ), ) + const getRoot = () => { + const host = container.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + + const root = host.shadowRoot + if (!root) return + + return root + } + + const handleMouseUp = () => { + if (props.enableLineSelection !== true) return + + const root = getRoot() + if (!root) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed) return + + const anchor = selection.anchorNode + const focus = selection.focusNode + if (!anchor || !focus) return + if (!root.contains(anchor) || !root.contains(focus)) return + + const start = findLineNumber(anchor) + const end = findLineNumber(focus) + if (start === undefined || end === undefined) return + + const startSide = findSide(anchor) + const endSide = findSide(focus) + const side = startSide ?? endSide + + const range: SelectedLineRange = { + start, + end, + } + + if (side) range.side = side + if (endSide && side && endSide !== side) range.endSide = endSide + + file().setSelectedLines(range) + } + + createEffect(() => { + const current = file() + + onCleanup(() => { + current.cleanUp() + }) + }) + createEffect(() => { container.innerHTML = "" file().render({ @@ -34,6 +119,20 @@ export function Code(props: CodeProps) { }) }) + createEffect(() => { + file().setSelectedLines(local.selectedLines ?? null) + }) + + createEffect(() => { + if (props.enableLineSelection !== true) return + + container.addEventListener("mouseup", handleMouseUp) + + onCleanup(() => { + container.removeEventListener("mouseup", handleMouseUp) + }) + }) + return (
Date: Thu, 1 Jan 2026 10:52:26 -0600 Subject: [PATCH 029/138] wip(desktop): progress --- packages/app/src/context/layout.tsx | 78 ++++++- packages/app/src/pages/layout.tsx | 22 +- packages/app/src/pages/session.tsx | 199 +++++++++++++++--- packages/ui/src/components/session-review.tsx | 23 +- 4 files changed, 257 insertions(+), 65 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 613a0e0c172e..6a9258b4cf5a 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -23,11 +23,28 @@ export function getAvatarColors(key?: string) { } } +function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + type SessionTabs = { active?: string all: string[] } +type SessionScroll = { + x: number + y: number +} + +type SessionView = { + scroll: Record + reviewOpen?: string[] +} + export type LocalProject = Partial & { worktree: string; expanded: boolean } export type ReviewDiffStyle = "unified" | "split" @@ -39,7 +56,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const globalSync = useGlobalSync() const server = useServer() const [store, setStore, _, ready] = persisted( - "layout.v4", + "layout.v6", createStore({ sidebar: { opened: false, @@ -56,7 +73,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( session: { width: 600, }, + mobileSidebar: { + opened: false, + }, sessionTabs: {} as Record, + sessionView: {} as Record, }), ) @@ -182,11 +203,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { if (!store.session) { setStore("session", { width }) - } else { - setStore("session", "width", width) + return } + setStore("session", "width", width) + }, + }, + mobileSidebar: { + opened: createMemo(() => store.mobileSidebar?.opened ?? false), + show() { + setStore("mobileSidebar", "opened", true) + }, + hide() { + setStore("mobileSidebar", "opened", false) + }, + toggle() { + setStore("mobileSidebar", "opened", (x) => !x) }, }, + view(sessionKey: string) { + const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) + return { + scroll(tab: string) { + return s().scroll?.[tab] + }, + setScroll(tab: string, pos: SessionScroll) { + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { scroll: { [tab]: pos } }) + return + } + + const prev = current.scroll?.[tab] + if (prev?.x === pos.x && prev?.y === pos.y) return + setStore("sessionView", sessionKey, "scroll", tab, pos) + }, + review: { + open: createMemo(() => s().reviewOpen), + setOpen(open: string[]) { + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open }) + return + } + + if (same(current.reviewOpen, open)) return + setStore("sessionView", sessionKey, "reviewOpen", open) + }, + }, + } + }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { @@ -256,11 +321,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (current.active !== tab) return const index = current.all.findIndex((f) => f === tab) - if (index <= 0) { - setStore("sessionTabs", sessionKey, "active", undefined) - return - } - setStore("sessionTabs", sessionKey, "active", current.all[index - 1]) + const next = all[index - 1] ?? all[0] + setStore("sessionTabs", sessionKey, "active", next) }) }, move(tab: string, to: number) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4629cd9b6035..e237d21845d5 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -62,17 +62,9 @@ export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, activeDraggable: undefined as string | undefined, - mobileSidebarOpen: false, mobileProjectsExpanded: {} as Record, }) - const mobileSidebar = { - open: () => store.mobileSidebarOpen, - show: () => setStore("mobileSidebarOpen", true), - hide: () => setStore("mobileSidebarOpen", false), - toggle: () => setStore("mobileSidebarOpen", (x) => !x), - } - const mobileProjects = { expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), @@ -468,13 +460,13 @@ export default function Layout(props: ParentProps) { if (!directory) return const lastSession = store.lastSession[directory] navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) - mobileSidebar.hide() + layout.mobileSidebar.hide() } function navigateToSession(session: Session | undefined) { if (!session) return navigate(`/${params.dir}/session/${session?.id}`) - mobileSidebar.hide() + layout.mobileSidebar.hide() } function openProject(directory: string, navigate = true) { @@ -1064,18 +1056,18 @@ export default function Layout(props: ParentProps) {
{ - if (e.target === e.currentTarget) mobileSidebar.hide() + if (e.target === e.currentTarget) layout.mobileSidebar.hide() }} />
e.stopPropagation()} > diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f0e6a6e1de6a..7f02032229f9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -84,7 +84,7 @@ function same(a: readonly T[], b: readonly T[]) { return a.every((x, i) => x === b[i]) } -function Header(props: { onMobileMenuToggle?: () => void }) { +function Header() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() @@ -113,7 +113,7 @@ function Header(props: { onMobileMenuToggle?: () => void }) { @@ -291,6 +291,7 @@ export default function Page() { const permission = usePermission() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) + const view = createMemo(() => layout.view(sessionKey())) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -822,6 +823,8 @@ export default function Page() { .filter((tab) => tab !== "context"), ) + const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review") + const showTabs = createMemo( () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()), ) @@ -829,8 +832,19 @@ export default function Page() { const activeTab = createMemo(() => { const active = tabs().active() if (active) return active - if (diffs().length > 0) return "review" - return tabs().all()[0] ?? "review" + if (reviewTab()) return "review" + + const first = openedTabs()[0] + if (first) return first + if (contextOpen()) return "context" + return "review" + }) + + createEffect(() => { + if (!layout.ready()) return + if (tabs().active()) return + if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return + tabs().setActive(activeTab()) }) const mobileWorking = createMemo(() => status().type !== "idle") @@ -1209,8 +1223,63 @@ export default function Page() { ) } + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view()?.scroll("context") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll("context", next) + }) + } + + createEffect( + on( + () => messages().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + return ( -
+
{ + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + >
{(stat) => } @@ -1271,6 +1340,79 @@ export default function Page() { ) } + const ReviewTab = () => { + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const restoreScroll = () => { + const el = scroll + console.log("restoreScroll", el) + if (!el) return + + const s = view().scroll("review") + console.log("restoreScroll", s) + if (!s) return + + console.log("restoreScroll", el.scrollTop, s.y) + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll("review", next) + }) + } + + createEffect( + on( + () => diffs().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + open={view().review.open()} + onOpenChange={view().review.setOpen} + classes={{ + root: "pb-40", + header: "px-6", + container: "px-6", + }} + diffs={diffs()} + diffStyle={layout.review.diffStyle()} + onDiffStyleChange={layout.review.setDiffStyle} + /> + ) + } + return (
@@ -1300,6 +1442,8 @@ export default function Page() { diffs={diffs()} diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} + open={view().review.open()} + onOpenChange={view().review.setOpen} classes={{ root: "pb-32", header: "px-4", @@ -1373,7 +1517,7 @@ export default function Page() {
- +
@@ -1425,19 +1569,10 @@ export default function Page() {
- +
- +
@@ -1452,7 +1587,7 @@ export default function Page() { {(tab) => { let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined - let pendingTop: number | undefined + let pending: { x: number; y: number } | undefined const path = createMemo(() => file.pathFromTab(tab)) const state = createMemo(() => { @@ -1480,30 +1615,30 @@ export default function Page() { const restoreScroll = () => { const el = scroll - const p = path() - if (!el || !p) return + if (!el) return + + const s = view()?.scroll(tab) + if (!s) return - const top = file.scrollTop(p) - if (top === undefined) return - if (el.scrollTop === top) return - el.scrollTop = top + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x } const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - const p = path() - if (!p) return - - pendingTop = event.currentTarget.scrollTop + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } if (scrollFrame !== undefined) return scrollFrame = requestAnimationFrame(() => { scrollFrame = undefined - const top = pendingTop - pendingTop = undefined - if (top === undefined) return + const next = pending + pending = undefined + if (!next) return - file.setScrollTop(p, top) + view().setScroll(tab, next) }) } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 9e6c633f445a..e6d40341f88c 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -20,6 +20,10 @@ export interface SessionReviewProps { split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void + open?: string[] + onOpenChange?: (open: string[]) => void + scrollRef?: (el: HTMLDivElement) => void + onScroll?: JSX.EventHandlerUnion class?: string classList?: Record classes?: { root?: string; header?: string; container?: string } @@ -33,26 +37,25 @@ export const SessionReview = (props: SessionReviewProps) => { open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), }) + const open = () => props.open ?? store.open const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const handleChange = (open: string[]) => { + props.onOpenChange?.(open) + if (props.open !== undefined) return setStore("open", open) } const handleExpandOrCollapseAll = () => { - if (store.open.length > 0) { - setStore("open", []) - } else { - setStore( - "open", - props.diffs.map((d) => d.file), - ) - } + const next = open().length > 0 ? [] : props.diffs.map((d) => d.file) + handleChange(next) } return (
{ @@ -91,7 +94,7 @@ export const SessionReview = (props: SessionReviewProps) => { [props.classes?.container ?? ""]: !!props.classes?.container, }} > - + {(diff) => ( From 6647b1e22f534e39fe4872e47881f8f0539e2217 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:11:42 -0600 Subject: [PATCH 030/138] wip(desktop): progress --- packages/app/AGENTS.md | 31 +- packages/app/src/components/session/index.ts | 6 + .../session/session-context-tab.tsx | 419 +++++++++ .../src/components/session/session-header.tsx | 212 +++++ .../components/session/session-new-view.tsx | 35 + .../components/session/session-review-tab.tsx | 81 ++ .../session/session-sortable-tab.tsx | 48 ++ .../session/session-sortable-terminal-tab.tsx | 27 + packages/app/src/pages/session.tsx | 796 +----------------- 9 files changed, 855 insertions(+), 800 deletions(-) create mode 100644 packages/app/src/components/session/index.ts create mode 100644 packages/app/src/components/session/session-context-tab.tsx create mode 100644 packages/app/src/components/session/session-header.tsx create mode 100644 packages/app/src/components/session/session-new-view.tsx create mode 100644 packages/app/src/components/session/session-review-tab.tsx create mode 100644 packages/app/src/components/session/session-sortable-tab.tsx create mode 100644 packages/app/src/components/session/session-sortable-terminal-tab.tsx diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 3137bddc2578..ca19456fec6e 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -1,28 +1,13 @@ -# Agent Guidelines for @opencode/app +## Debugging -## Build/Test Commands +- To test the opencode app, use the playwrite mcp server, the app is already + running at http://localhost:3000 +- NEVER try to restart the app, or the server process, EVER. -- **Development**: `bun run dev` (starts Vite dev server on port 3000) -- **Build**: `bun run build` (production build) -- **Preview**: `bun run serve` (preview production build) -- **Validation**: Use `bun run typecheck` only - do not build or run project for validation -- **Testing**: Do not create or run automated tests +## SolidJS -## Code Style +- Always prefer `createStore` over multiple `createSignal` calls -- **Framework**: SolidJS with TypeScript -- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`) -- **Formatting**: Prettier configured with semicolons disabled, 120 character line width -- **Components**: Use function declarations, splitProps for component props -- **Types**: Define interfaces for component props, avoid `any` type -- **CSS**: TailwindCSS with custom CSS variables theme system -- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names -- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/` +## Tool Calling -## Key Dependencies - -- SolidJS, @solidjs/router, @kobalte/core (UI primitives) -- TailwindCSS 4.x with @tailwindcss/vite -- Custom theme system with CSS variables - -No special rules files found. +- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts new file mode 100644 index 000000000000..5e5c349d2eff --- /dev/null +++ b/packages/app/src/components/session/index.ts @@ -0,0 +1,6 @@ +export { SessionHeader } from "./session-header" +export { SessionContextTab } from "./session-context-tab" +export { SessionReviewTab } from "./session-review-tab" +export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTerminalTab } from "./session-sortable-terminal-tab" +export { NewSessionView } from "./session-new-view" diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx new file mode 100644 index 000000000000..b157eb228fc8 --- /dev/null +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -0,0 +1,419 @@ +import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +import type { JSX } from "solid-js" +import { useParams } from "@solidjs/router" +import { DateTime } from "luxon" +import { useSync } from "@/context/sync" +import { useLayout } from "@/context/layout" +import { checksum } from "@opencode-ai/util/encode" +import { Icon } from "@opencode-ai/ui/icon" +import { Accordion } from "@opencode-ai/ui/accordion" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" +import { Code } from "@opencode-ai/ui/code" +import { Markdown } from "@opencode-ai/ui/markdown" +import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" + +interface SessionContextTabProps { + messages: () => Message[] + visibleUserMessages: () => UserMessage[] + view: () => ReturnType["view"]> + info: () => ReturnType["session"]["get"]> +} + +export function SessionContextTab(props: SessionContextTabProps) { + const params = useParams() + const sync = useSync() + + const ctx = createMemo(() => { + const last = props.messages().findLast((x) => { + if (x.role !== "assistant") return false + const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write + return total > 0 + }) as AssistantMessage + if (!last) return + + const provider = sync.data.provider.all.find((x) => x.id === last.providerID) + const model = provider?.models[last.modelID] + const limit = model?.limit.context + + const input = last.tokens.input + const output = last.tokens.output + const reasoning = last.tokens.reasoning + const cacheRead = last.tokens.cache.read + const cacheWrite = last.tokens.cache.write + const total = input + output + reasoning + cacheRead + cacheWrite + const usage = limit ? Math.round((total / limit) * 100) : null + + return { + message: last, + provider, + model, + limit, + input, + output, + reasoning, + cacheRead, + cacheWrite, + total, + usage, + } + }) + + const cost = createMemo(() => { + const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) + + const counts = createMemo(() => { + const all = props.messages() + const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) + const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) + return { + all: all.length, + user, + assistant, + } + }) + + const systemPrompt = createMemo(() => { + const msg = props.visibleUserMessages().findLast((m) => !!m.system) + const system = msg?.system + if (!system) return + const trimmed = system.trim() + if (!trimmed) return + return trimmed + }) + + const number = (value: number | null | undefined) => { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString() + } + + const percent = (value: number | null | undefined) => { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toString() + "%" + } + + const time = (value: number | undefined) => { + if (!value) return "—" + return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED) + } + + const providerLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.provider?.name ?? c.message.providerID + }) + + const modelLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + if (c.model?.name) return c.model.name + return c.message.modelID + }) + + const breakdown = createMemo( + on( + () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], + () => { + const c = ctx() + if (!c) return [] + const input = c.input + if (!input) return [] + + const out = { + system: systemPrompt()?.length ?? 0, + user: 0, + assistant: 0, + tool: 0, + } + + for (const msg of props.messages()) { + const parts = (sync.data.part[msg.id] ?? []) as Part[] + + if (msg.role === "user") { + for (const part of parts) { + if (part.type === "text") out.user += part.text.length + if (part.type === "file") out.user += part.source?.text.value.length ?? 0 + if (part.type === "agent") out.user += part.source?.value.length ?? 0 + } + continue + } + + if (msg.role === "assistant") { + for (const part of parts) { + if (part.type === "text") out.assistant += part.text.length + if (part.type === "reasoning") out.assistant += part.text.length + if (part.type === "tool") { + out.tool += Object.keys(part.state.input).length * 16 + if (part.state.status === "pending") out.tool += part.state.raw.length + if (part.state.status === "completed") out.tool += part.state.output.length + if (part.state.status === "error") out.tool += part.state.error.length + } + } + } + } + + const estimateTokens = (chars: number) => Math.ceil(chars / 4) + const system = estimateTokens(out.system) + const user = estimateTokens(out.user) + const assistant = estimateTokens(out.assistant) + const tool = estimateTokens(out.tool) + const estimated = system + user + assistant + tool + + const pct = (tokens: number) => (tokens / input) * 100 + const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%" + + const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => { + return [ + { + key: "system", + label: "System", + tokens: tokens.system, + width: pct(tokens.system), + percent: pctLabel(tokens.system), + color: "var(--syntax-info)", + }, + { + key: "user", + label: "User", + tokens: tokens.user, + width: pct(tokens.user), + percent: pctLabel(tokens.user), + color: "var(--syntax-success)", + }, + { + key: "assistant", + label: "Assistant", + tokens: tokens.assistant, + width: pct(tokens.assistant), + percent: pctLabel(tokens.assistant), + color: "var(--syntax-property)", + }, + { + key: "tool", + label: "Tool Calls", + tokens: tokens.tool, + width: pct(tokens.tool), + percent: pctLabel(tokens.tool), + color: "var(--syntax-warning)", + }, + { + key: "other", + label: "Other", + tokens: tokens.other, + width: pct(tokens.other), + percent: pctLabel(tokens.other), + color: "var(--syntax-comment)", + }, + ].filter((x) => x.tokens > 0) + } + + if (estimated <= input) { + return build({ system, user, assistant, tool, other: input - estimated }) + } + + const scale = input / estimated + const scaled = { + system: Math.floor(system * scale), + user: Math.floor(user * scale), + assistant: Math.floor(assistant * scale), + tool: Math.floor(tool * scale), + } + const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool + return build({ ...scaled, other: Math.max(0, input - scaledTotal) }) + }, + ), + ) + + function Stat(statProps: { label: string; value: JSX.Element }) { + return ( +
+
{statProps.label}
+
{statProps.value}
+
+ ) + } + + const stats = createMemo(() => { + const c = ctx() + const count = counts() + return [ + { label: "Session", value: props.info()?.title ?? params.id ?? "—" }, + { label: "Messages", value: count.all.toLocaleString() }, + { label: "Provider", value: providerLabel() }, + { label: "Model", value: modelLabel() }, + { label: "Context Limit", value: number(c?.limit) }, + { label: "Total Tokens", value: number(c?.total) }, + { label: "Usage", value: percent(c?.usage) }, + { label: "Input Tokens", value: number(c?.input) }, + { label: "Output Tokens", value: number(c?.output) }, + { label: "Reasoning Tokens", value: number(c?.reasoning) }, + { label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` }, + { label: "User Messages", value: count.user.toLocaleString() }, + { label: "Assistant Messages", value: count.assistant.toLocaleString() }, + { label: "Total Cost", value: cost() }, + { label: "Session Created", value: time(props.info()?.time.created) }, + { label: "Last Activity", value: time(c?.message.time.created) }, + ] satisfies { label: string; value: JSX.Element }[] + }) + + function RawMessageContent(msgProps: { message: Message }) { + const file = createMemo(() => { + const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[] + const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2) + return { + name: `${msgProps.message.role}-${msgProps.message.id}.json`, + contents, + cacheKey: checksum(contents), + } + }) + + return + } + + function RawMessage(msgProps: { message: Message }) { + return ( + + + +
+
+ {msgProps.message.role} • {msgProps.message.id} +
+
+
{time(msgProps.message.time.created)}
+ +
+
+
+
+ +
+ +
+
+
+ ) + } + + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = props.view()?.scroll("context") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + props.view().setScroll("context", next) + }) + } + + createEffect( + on( + () => props.messages().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( +
{ + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > +
+
+ {(stat) => } +
+ + 0}> +
+
Context Breakdown
+
+ + {(segment) => ( +
+ )} + +
+
+ + {(segment) => ( +
+
+
{segment.label}
+
{segment.percent}
+
+ )} + +
+ +
+ + + + {(prompt) => ( +
+
System Prompt
+
+ +
+
+ )} +
+ +
+
Raw messages
+ + {(message) => } + +
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx new file mode 100644 index 000000000000..0a0b536075a1 --- /dev/null +++ b/packages/app/src/components/session/session-header.tsx @@ -0,0 +1,212 @@ +import { createMemo, createResource, Show } from "solid-js" +import { A, useNavigate, useParams } from "@solidjs/router" +import { useLayout } from "@/context/layout" +import { useCommand } from "@/context/command" +import { useServer } from "@/context/server" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSync } from "@/context/sync" +import { useGlobalSDK } from "@/context/global-sdk" +import { getFilename } from "@opencode-ai/util/path" +import { base64Encode } from "@opencode-ai/util/encode" +import { iife } from "@opencode-ai/util/iife" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Button } from "@opencode-ai/ui/button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { Select } from "@opencode-ai/ui/select" +import { Popover } from "@opencode-ai/ui/popover" +import { TextField } from "@opencode-ai/ui/text-field" +import { DialogSelectServer } from "@/components/dialog-select-server" +import { SessionLspIndicator } from "@/components/session-lsp-indicator" +import { SessionMcpIndicator } from "@/components/session-mcp-indicator" +import type { Session } from "@opencode-ai/sdk/v2/client" + +export function SessionHeader() { + const globalSDK = useGlobalSDK() + const layout = useLayout() + const params = useParams() + const navigate = useNavigate() + const command = useCommand() + const server = useServer() + const dialog = useDialog() + const sync = useSync() + + const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) + const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") + const branch = createMemo(() => sync.data.vcs?.branch) + + function navigateToProject(directory: string) { + navigate(`/${base64Encode(directory)}`) + } + + function navigateToSession(session: Session | undefined) { + if (!session) return + navigate(`/${params.dir}/session/${session.id}`) + } + + return ( +
+ +
+
+
+ + project.worktree)} - current={sync.directory} - label={(x) => { - const name = getFilename(x) - const b = x === sync.directory ? branch() : undefined - return b ? `${name}:${b}` : name - }} - onSelect={(x) => (x ? navigateToProject(x) : undefined)} - class="text-14-regular text-text-base" - variant="ghost" - > - {/* @ts-ignore */} - {(i) => ( -
- -
{getFilename(i)}
-
- )} - -
/
-
-