From 382399c1f3f2ddcc3068e92fadb72fe6f5ea2c33 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 5 Dec 2025 22:34:05 -0500 Subject: [PATCH 01/18] feat: add experimental.cache_command_markdown_files option Add new experimental option to disable caching of command markdown files. When set to false, command files are re-read from disk on each execution, allowing changes to be reflected without restarting opencode. - Add cache_command_markdown_files boolean option to experimental config (defaults to true) - Export Config.loadCommand function for reuse in command loading - Refactor Command.get() and Command.list() to support dynamic reloading - Add comprehensive test coverage for the new feature --- packages/opencode/src/command/index.ts | 54 ++++ packages/opencode/src/config/config.ts | 154 +++++----- .../test/config/command-cache.test.ts | 265 ++++++++++++++++++ 3 files changed, 399 insertions(+), 74 deletions(-) create mode 100644 packages/opencode/test/config/command-cache.test.ts diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 214617784db0..f12f3bc757bd 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -69,11 +69,65 @@ export namespace Command { return result }) + function createBuiltInCommands() { + return { + [Default.INIT]: { + name: Default.INIT, + description: "create/update AGENTS.md", + template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), + }, + [Default.REVIEW]: { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + template: PROMPT_REVIEW.replace("${path}", Instance.worktree), + subtask: true, + }, + } as Record + } + + async function loadFreshCommands(): Promise> { + const result = createBuiltInCommands() + const directories = await Config.directories() + + // Reload commands from markdown files in all config directories + for (const dir of directories) { + const freshCommands = await Config.loadCommand(dir) + for (const [name, command] of Object.entries(freshCommands)) { + result[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + template: command.template, + subtask: command.subtask, + } + } + } + + return result + } + export async function get(name: string) { + const cfg = await Config.get() + + // If caching is disabled, reload commands fresh from config each time + if (cfg.experimental?.cache_command_markdown_files === false) { + const fresh = await loadFreshCommands() + return fresh[name] + } + return state().then((x) => x[name]) } export async function list() { + const cfg = await Config.get() + + // If caching is disabled, reload commands fresh from config each time + if (cfg.experimental?.cache_command_markdown_files === false) { + const fresh = await loadFreshCommands() + return Object.values(fresh) + } + return state().then((x) => Object.values(x)) } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d38de8a94078..1747c848ae34 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -177,43 +177,44 @@ export namespace Config { ).catch(() => {}) } - const COMMAND_GLOB = new Bun.Glob("command/**/*.md") - async function loadCommand(dir: string) { - const result: Record = {} - for await (const item of COMMAND_GLOB.scan({ - absolute: true, - followSymlinks: true, - dot: true, - cwd: dir, - })) { - const md = await ConfigMarkdown.parse(item) - if (!md.data) continue - - const name = (() => { - const patterns = ["/.opencode/command/", "/command/"] - const pattern = patterns.find((p) => item.includes(p)) - - if (pattern) { - const index = item.indexOf(pattern) - return item.slice(index + pattern.length, -3) - } - return path.basename(item, ".md") - })() - - const config = { - name, - ...md.data, - template: md.content.trim(), - } - const parsed = Command.safeParse(config) - if (parsed.success) { - result[config.name] = parsed.data - continue - } - throw new InvalidError({ path: item }, { cause: parsed.error }) - } - return result - } + const COMMAND_GLOB = new Bun.Glob("command/**/*.md") + + export async function loadCommand(dir: string) { + const result: Record = {} + for await (const item of COMMAND_GLOB.scan({ + absolute: true, + followSymlinks: true, + dot: true, + cwd: dir, + })) { + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue + + const name = (() => { + const patterns = ["/.opencode/command/", "/command/"] + const pattern = patterns.find((p) => item.includes(p)) + + if (pattern) { + const index = item.indexOf(pattern) + return item.slice(index + pattern.length, -3) + } + return path.basename(item, ".md") + })() + + const config = { + name, + ...md.data, + template: md.content.trim(), + } + const parsed = Command.safeParse(config) + if (parsed.success) { + result[config.name] = parsed.data + continue + } + throw new InvalidError({ path: item }, { cause: parsed.error }) + } + return result + } const AGENT_GLOB = new Bun.Glob("agent/**/*.md") async function loadAgent(dir: string) { @@ -650,43 +651,48 @@ export namespace Config { url: z.string().optional().describe("Enterprise URL"), }) .optional(), - experimental: z - .object({ - hook: z - .object({ - file_edited: z - .record( - z.string(), - z - .object({ - command: z.string().array(), - environment: z.record(z.string(), z.string()).optional(), - }) - .array(), - ) - .optional(), - session_completed: z - .object({ - command: z.string().array(), - environment: z.record(z.string(), z.string()).optional(), - }) - .array() - .optional(), - }) - .optional(), - chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"), - disable_paste_summary: z.boolean().optional(), - batch_tool: z.boolean().optional().describe("Enable the batch tool"), - openTelemetry: z - .boolean() - .optional() - .describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"), - primary_tools: z - .array(z.string()) - .optional() - .describe("Tools that should only be available to primary agents."), - }) - .optional(), + experimental: z + .object({ + hook: z + .object({ + file_edited: z + .record( + z.string(), + z + .object({ + command: z.string().array(), + environment: z.record(z.string(), z.string()).optional(), + }) + .array(), + ) + .optional(), + session_completed: z + .object({ + command: z.string().array(), + environment: z.record(z.string(), z.string()).optional(), + }) + .array() + .optional(), + }) + .optional(), + chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"), + disable_paste_summary: z.boolean().optional(), + batch_tool: z.boolean().optional().describe("Enable the batch tool"), + openTelemetry: z + .boolean() + .optional() + .describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"), + primary_tools: z + .array(z.string()) + .optional() + .describe("Tools that should only be available to primary agents."), + cache_command_markdown_files: z + .boolean() + .optional() + .default(true) + .describe("Cache command markdown files on first load. Set to false to reload command files on every execution."), + }) + .optional(), }) .strict() .meta({ diff --git a/packages/opencode/test/config/command-cache.test.ts b/packages/opencode/test/config/command-cache.test.ts new file mode 100644 index 000000000000..b64e641786da --- /dev/null +++ b/packages/opencode/test/config/command-cache.test.ts @@ -0,0 +1,265 @@ +import { test, expect } from "bun:test" +import { Command } from "../../src/command/index" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +test("commands are cached by default (cache_command_markdown_files not set)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + const commandDir = path.join(opencodeDir, "command") + await fs.mkdir(commandDir, { recursive: true }) + + await Bun.write( + path.join(commandDir, "test.md"), + `--- +description: Original command +--- +Original template`, + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cmd1 = await Command.get("test") + expect(cmd1?.template).toBe("Original template") + + // Modify the markdown file + const commandFile = path.join(tmp.path, ".opencode", "command", "test.md") + await Bun.write( + commandFile, + `--- +description: Modified command +--- +Modified template`, + ) + + // Should still return the cached version + const cmd2 = await Command.get("test") + expect(cmd2?.template).toBe("Original template") + }, + }) +}) + +test("commands reload when cache_command_markdown_files is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + const commandDir = path.join(opencodeDir, "command") + await fs.mkdir(commandDir, { recursive: true }) + + await Bun.write( + path.join(commandDir, "test.md"), + `--- +description: Original command +--- +Original template`, + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + cache_command_markdown_files: false, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cmd1 = await Command.get("test") + expect(cmd1?.template).toBe("Original template") + + // Modify the markdown file + const commandFile = path.join(tmp.path, ".opencode", "command", "test.md") + await Bun.write( + commandFile, + `--- +description: Modified command +--- +Modified template`, + ) + + // Should return the fresh version + const cmd2 = await Command.get("test") + expect(cmd2?.template).toBe("Modified template") + }, + }) +}) + +test("built-in commands are always available regardless of cache setting", 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", + experimental: { + cache_command_markdown_files: false, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const init = await Command.get(Command.Default.INIT) + expect(init?.name).toBe("init") + expect(init?.description).toContain("AGENTS.md") + + const review = await Command.get(Command.Default.REVIEW) + expect(review?.name).toBe("review") + expect(review?.description).toContain("review changes") + }, + }) +}) + +test("Command.list() respects cache setting", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + const commandDir = path.join(opencodeDir, "command") + await fs.mkdir(commandDir, { recursive: true }) + + await Bun.write( + path.join(commandDir, "cmd1.md"), + `--- +description: Command 1 +--- +Template 1`, + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + cache_command_markdown_files: false, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const list1 = await Command.list() + const hasCmd1 = list1.some((c) => c.name === "cmd1") + expect(hasCmd1).toBe(true) + + // Add a new command file + const commandDir = path.join(tmp.path, ".opencode", "command") + await Bun.write( + path.join(commandDir, "cmd2.md"), + `--- +description: Command 2 +--- +Template 2`, + ) + + // Should reflect the new command in the list + const list2 = await Command.list() + const hasCmd2 = list2.some((c) => c.name === "cmd2") + expect(hasCmd2).toBe(true) + }, + }) +}) + +test("command descriptions and metadata are correctly loaded when cache_command_markdown_files is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + const commandDir = path.join(opencodeDir, "command") + await fs.mkdir(commandDir, { recursive: true }) + + await Bun.write( + path.join(commandDir, "test.md"), + `--- +description: Test command +agent: test_agent +model: test/model +subtask: true +--- +Test template content`, + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + cache_command_markdown_files: false, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cmd = await Command.get("test") + expect(cmd?.name).toBe("test") + expect(cmd?.description).toBe("Test command") + expect(cmd?.agent).toBe("test_agent") + expect(cmd?.model).toBe("test/model") + expect(cmd?.subtask).toBe(true) + expect(cmd?.template).toBe("Test template content") + }, + }) +}) + +test("nested command directories work correctly with dynamic reload", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + const commandDir = path.join(opencodeDir, "command", "nested") + await fs.mkdir(commandDir, { recursive: true }) + + await Bun.write( + path.join(commandDir, "test.md"), + `--- +description: Nested command +--- +Nested template`, + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + cache_command_markdown_files: false, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cmd = await Command.get("nested/test") + expect(cmd?.name).toBe("nested/test") + expect(cmd?.template).toBe("Nested template") + }, + }) +}) From 52bda6976961337f6132a58bc0a9bc9914d6339d Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 5 Dec 2025 22:38:57 -0500 Subject: [PATCH 02/18] refactor: remove code churn and duplication - Remove formatting changes in config.ts (keep original indentation) - Eliminate duplicate built-in command creation logic in command/index.ts - Extract createBuiltInCommands() to be shared between cached and fresh loading - Preserve all functionality while reducing code duplication --- packages/opencode/src/command/index.ts | 29 +++++++------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index f12f3bc757bd..eb86fadee00d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -38,10 +38,8 @@ export namespace Command { REVIEW: "review", } as const - const state = Instance.state(async () => { - const cfg = await Config.get() - - const result: Record = { + function createBuiltInCommands() { + return { [Default.INIT]: { name: Default.INIT, description: "create/update AGENTS.md", @@ -53,7 +51,12 @@ export namespace Command { template: PROMPT_REVIEW.replace("${path}", Instance.worktree), subtask: true, }, - } + } as Record + } + + const state = Instance.state(async () => { + const cfg = await Config.get() + const result = createBuiltInCommands() for (const [name, command] of Object.entries(cfg.command ?? {})) { result[name] = { @@ -69,22 +72,6 @@ export namespace Command { return result }) - function createBuiltInCommands() { - return { - [Default.INIT]: { - name: Default.INIT, - description: "create/update AGENTS.md", - template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), - }, - [Default.REVIEW]: { - name: Default.REVIEW, - description: "review changes [commit|branch|pr], defaults to uncommitted", - template: PROMPT_REVIEW.replace("${path}", Instance.worktree), - subtask: true, - }, - } as Record - } - async function loadFreshCommands(): Promise> { const result = createBuiltInCommands() const directories = await Config.directories() From a4716f3524708086fcf6df7bd418c69e08aee768 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 5 Dec 2025 22:42:40 -0500 Subject: [PATCH 03/18] - overkill tests --- .../test/config/command-cache.test.ts | 265 ------------------ 1 file changed, 265 deletions(-) delete mode 100644 packages/opencode/test/config/command-cache.test.ts diff --git a/packages/opencode/test/config/command-cache.test.ts b/packages/opencode/test/config/command-cache.test.ts deleted file mode 100644 index b64e641786da..000000000000 --- a/packages/opencode/test/config/command-cache.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { test, expect } from "bun:test" -import { Command } from "../../src/command/index" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" -import path from "path" -import fs from "fs/promises" - -test("commands are cached by default (cache_command_markdown_files not set)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - const commandDir = path.join(opencodeDir, "command") - await fs.mkdir(commandDir, { recursive: true }) - - await Bun.write( - path.join(commandDir, "test.md"), - `--- -description: Original command ---- -Original template`, - ) - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const cmd1 = await Command.get("test") - expect(cmd1?.template).toBe("Original template") - - // Modify the markdown file - const commandFile = path.join(tmp.path, ".opencode", "command", "test.md") - await Bun.write( - commandFile, - `--- -description: Modified command ---- -Modified template`, - ) - - // Should still return the cached version - const cmd2 = await Command.get("test") - expect(cmd2?.template).toBe("Original template") - }, - }) -}) - -test("commands reload when cache_command_markdown_files is false", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - const commandDir = path.join(opencodeDir, "command") - await fs.mkdir(commandDir, { recursive: true }) - - await Bun.write( - path.join(commandDir, "test.md"), - `--- -description: Original command ---- -Original template`, - ) - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - experimental: { - cache_command_markdown_files: false, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const cmd1 = await Command.get("test") - expect(cmd1?.template).toBe("Original template") - - // Modify the markdown file - const commandFile = path.join(tmp.path, ".opencode", "command", "test.md") - await Bun.write( - commandFile, - `--- -description: Modified command ---- -Modified template`, - ) - - // Should return the fresh version - const cmd2 = await Command.get("test") - expect(cmd2?.template).toBe("Modified template") - }, - }) -}) - -test("built-in commands are always available regardless of cache setting", 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", - experimental: { - cache_command_markdown_files: false, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const init = await Command.get(Command.Default.INIT) - expect(init?.name).toBe("init") - expect(init?.description).toContain("AGENTS.md") - - const review = await Command.get(Command.Default.REVIEW) - expect(review?.name).toBe("review") - expect(review?.description).toContain("review changes") - }, - }) -}) - -test("Command.list() respects cache setting", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - const commandDir = path.join(opencodeDir, "command") - await fs.mkdir(commandDir, { recursive: true }) - - await Bun.write( - path.join(commandDir, "cmd1.md"), - `--- -description: Command 1 ---- -Template 1`, - ) - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - experimental: { - cache_command_markdown_files: false, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const list1 = await Command.list() - const hasCmd1 = list1.some((c) => c.name === "cmd1") - expect(hasCmd1).toBe(true) - - // Add a new command file - const commandDir = path.join(tmp.path, ".opencode", "command") - await Bun.write( - path.join(commandDir, "cmd2.md"), - `--- -description: Command 2 ---- -Template 2`, - ) - - // Should reflect the new command in the list - const list2 = await Command.list() - const hasCmd2 = list2.some((c) => c.name === "cmd2") - expect(hasCmd2).toBe(true) - }, - }) -}) - -test("command descriptions and metadata are correctly loaded when cache_command_markdown_files is false", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - const commandDir = path.join(opencodeDir, "command") - await fs.mkdir(commandDir, { recursive: true }) - - await Bun.write( - path.join(commandDir, "test.md"), - `--- -description: Test command -agent: test_agent -model: test/model -subtask: true ---- -Test template content`, - ) - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - experimental: { - cache_command_markdown_files: false, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const cmd = await Command.get("test") - expect(cmd?.name).toBe("test") - expect(cmd?.description).toBe("Test command") - expect(cmd?.agent).toBe("test_agent") - expect(cmd?.model).toBe("test/model") - expect(cmd?.subtask).toBe(true) - expect(cmd?.template).toBe("Test template content") - }, - }) -}) - -test("nested command directories work correctly with dynamic reload", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - const commandDir = path.join(opencodeDir, "command", "nested") - await fs.mkdir(commandDir, { recursive: true }) - - await Bun.write( - path.join(commandDir, "test.md"), - `--- -description: Nested command ---- -Nested template`, - ) - - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - experimental: { - cache_command_markdown_files: false, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const cmd = await Command.get("nested/test") - expect(cmd?.name).toBe("nested/test") - expect(cmd?.template).toBe("Nested template") - }, - }) -}) From d33f97239cd58ba5633a5ecbf6c07e7f27c706d0 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 8 Dec 2025 08:15:20 -0500 Subject: [PATCH 04/18] Fix TypeScript error: remove cacheKey from FileContents interface usage --- packages/desktop/src/pages/session.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 81f4dc1cbc42..1e86868fdcb2 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -31,7 +31,7 @@ import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" -import { checksum } from "@opencode-ai/util/encode" + export default function Page() { const layout = useLayout() @@ -493,7 +493,6 @@ export default function Page() { file={{ name: f().path, contents: f().content?.content ?? "", - cacheKey: checksum(f().content?.content ?? ""), }} overflow="scroll" class="pb-40" From 367b1d70bdd59e181e1ac2be71ca4c6a2e129877 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 12 Dec 2025 13:30:18 -0500 Subject: [PATCH 05/18] tidy: revert unwanted change. --- packages/desktop/src/pages/session.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 85ab8c1e04e7..5dae4ce55d91 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -32,7 +32,7 @@ import { useSession, type LocalPTY } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" - +import { checksum } from "@opencode-ai/util/encode" export default function Page() { const layout = useLayout() @@ -415,7 +415,6 @@ export default function Page() { messages={session.messages.user()} current={session.messages.active()} onMessageSelect={session.messages.setActive} - working={session.working()} wide={wide()} /> Date: Thu, 18 Dec 2025 19:19:45 -0500 Subject: [PATCH 06/18] fix: add zod dependency to tauri package --- bun.lock | 1 + packages/tauri/package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 90d3dc420d10..f07b602327fe 100644 --- a/bun.lock +++ b/bun.lock @@ -367,6 +367,7 @@ "@tauri-apps/plugin-updater": "~2", "@tauri-apps/plugin-window-state": "~2", "solid-js": "catalog:", + "zod": "catalog:", }, "devDependencies": { "@actions/artifact": "4.0.0", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index f7e3051eeee9..cfb1bc3dba2c 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -23,7 +23,8 @@ "@tauri-apps/plugin-store": "~2", "@tauri-apps/plugin-updater": "~2", "@tauri-apps/plugin-window-state": "~2", - "solid-js": "catalog:" + "solid-js": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@actions/artifact": "4.0.0", From 01bafa8b5297dff6a8165202dd621fd4f063c99f Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 24 Dec 2025 11:40:33 -0500 Subject: [PATCH 07/18] Merge dev branch into feat/experimental-dont-cache-markdown Resolves merge conflicts, updates dependencies and code across packages. Includes UI improvements, markdown caching changes, and various bug fixes. --- bun.lock | 1 - packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 9e125a2eff2d..2a6368e5c6a9 100644 --- a/bun.lock +++ b/bun.lock @@ -388,7 +388,6 @@ "typescript": "catalog:", }, }, - "packages/ui": { "name": "@opencode-ai/ui", "version": "1.0.195", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0a31394ed9c8..ec07d92bd930 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1595,6 +1595,10 @@ export type Config = { * Tools that should only be available to primary agents. */ primary_tools?: Array + /** + * Cache command markdown files on first load. Set to false to reload command files on every execution. + */ + cache_command_markdown_files?: boolean /** * Continue the agent loop when a tool call is denied */ From 7b6c0521fe9653a37719ba2f5b5bd50d61689d05 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 29 Dec 2025 13:12:04 -0500 Subject: [PATCH 08/18] Fix Link component to use TextAttributes.UNDERLINE instead of underline prop --- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 1f798c54cca2..cc3938796cf6 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -1,5 +1,6 @@ import type { JSX } from "solid-js" import type { RGBA } from "@opentui/core" +import { TextAttributes } from "@opentui/core" import open from "open" export interface LinkProps { @@ -18,7 +19,7 @@ export function Link(props: LinkProps) { return ( { open(props.href).catch(() => {}) }} From 28e6b77c51395de86da1a9c1eb28f8287e104d7d Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 29 Dec 2025 14:54:11 -0500 Subject: [PATCH 09/18] Revert link.tsx changes to preserve pre-existing underline error --- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index cc3938796cf6..1f798c54cca2 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -1,6 +1,5 @@ import type { JSX } from "solid-js" import type { RGBA } from "@opentui/core" -import { TextAttributes } from "@opentui/core" import open from "open" export interface LinkProps { @@ -19,7 +18,7 @@ export function Link(props: LinkProps) { return ( { open(props.href).catch(() => {}) }} From 34b764d1ae13887631f263f7bb21be4805bd6f96 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 31 Dec 2025 13:28:11 -0500 Subject: [PATCH 10/18] Fix command loading after merge - use config state instead of private function --- packages/opencode/src/command/index.ts | 30 ++++++++++++++------------ 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index ff5ca995e763..303f6827f232 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -127,20 +127,22 @@ export namespace Command { async function loadFreshCommands(): Promise> { const result = createBuiltInCommands() - const directories = await Config.directories() - - // Reload commands from markdown files in all config directories - for (const dir of directories) { - const freshCommands = await Config.loadCommand(dir) - for (const [name, command] of Object.entries(freshCommands)) { - result[name] = { - name, - agent: command.agent, - model: command.model, - description: command.description, - template: command.template, - subtask: command.subtask, - } + const cfg = await Config.get() + + // Get fresh config to reload commands from markdown files + const freshConfig = await Config.state() + + // Extract commands from the fresh config + const freshCommands = freshConfig.config.command || {} + for (const [name, command] of Object.entries(freshCommands)) { + result[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + template: command.template, + subtask: command.subtask, + hints: Command.hints(command.template), } } From 0e3b352a31b0b190f34588be61cfa3cb8f81038d Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 19 Jan 2026 02:35:59 -0500 Subject: [PATCH 11/18] fix: loadFreshCommands now actually reloads from disk The previous implementation called Config.state() which returns cached config. Now it properly: 1. Exports Config.reloadCommands (alias to loadCommand) 2. Iterates through config directories and calls reloadCommands(dir) for each, ensuring markdown command files are re-read from disk --- packages/opencode/src/command/index.ts | 27 +++++++++++++++++++------- packages/opencode/src/config/config.ts | 3 +++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 140b1dc4166e..1d81046c7a5d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -127,13 +127,9 @@ export namespace Command { async function loadFreshCommands(): Promise> { const result = createBuiltInCommands() const cfg = await Config.get() - - // Get fresh config to reload commands from markdown files - const freshConfig = await Config.state() - - // Extract commands from the fresh config - const freshCommands = freshConfig.config.command || {} - for (const [name, command] of Object.entries(freshCommands)) { + + // Load commands from config file (non-markdown) + for (const [name, command] of Object.entries(cfg.command ?? {})) { result[name] = { name, agent: command.agent, @@ -145,6 +141,23 @@ export namespace Command { } } + // Reload commands from markdown files in each config directory + const directories = await Config.directories() + for (const dir of directories) { + const commands = await Config.reloadCommands(dir) + for (const [name, command] of Object.entries(commands)) { + result[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + template: command.template, + subtask: command.subtask, + hints: Command.hints(command.template), + } + } + } + return result } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 234ae6ed5ede..6e347b04b202 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1257,4 +1257,7 @@ export namespace Config { export async function directories() { return state().then((x) => x.directories) } + + // Re-export loadCommand for use when cache_command_markdown_files is false + export const reloadCommands = loadCommand } From 49b1e973eca0fc975ca77ddcc8c902ac2ff6e224 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 24 Feb 2026 09:55:29 -0500 Subject: [PATCH 12/18] fix: preserve unknown command properties in loadFreshCommands Use spread operator to keep all command properties instead of only extracting known ones. This allows feature branches to add new frontmatter properties without breaking when merged. --- packages/opencode/src/command/index.ts | 12 ++---------- packages/opencode/src/config/config.ts | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 4789082da90b..bb5871887fe2 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -150,12 +150,8 @@ export namespace Command { // Load commands from config file (non-markdown) for (const [name, command] of Object.entries(cfg.command ?? {})) { result[name] = { + ...command, name, - agent: command.agent, - model: command.model, - description: command.description, - template: command.template, - subtask: command.subtask, hints: Command.hints(command.template), } } @@ -166,12 +162,8 @@ export namespace Command { const commands = await Config.reloadCommands(dir) for (const [name, command] of Object.entries(commands)) { result[name] = { + ...command, name, - agent: command.agent, - model: command.model, - description: command.description, - template: command.template, - subtask: command.subtask, hints: Command.hints(command.template), } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index df383c2abc57..d6d0907ce182 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -670,7 +670,7 @@ export namespace Config { agent: z.string().optional(), model: ModelId.optional(), subtask: z.boolean().optional(), - }) + }).catchall(z.any()) export type Command = z.infer export const Skills = z.object({ From 53e281d3eefcd4cc24aba1875add18521bdd06ad Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Thu, 26 Feb 2026 10:04:11 -0500 Subject: [PATCH 13/18] perf: use mtime-based caching for command reloading When cache_command_markdown_files is disabled, only reload commands whose files have changed since last access. This avoids re-parsing all markdown files on every command invocation. --- packages/opencode/src/command/index.ts | 181 +++++++++++++++++++++++-- 1 file changed, 172 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index bb5871887fe2..76cc8ced5ee6 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,12 +1,17 @@ import { BusEvent } from "@/bus/bus-event" import z from "zod" import { Config } from "../config/config" +import { ConfigMarkdown } from "../config/markdown" import { Instance } from "../project/instance" import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import { MCP } from "../mcp" import { Skill } from "../skill" +import path from "path" +import fs from "fs/promises" +import { existsSync } from "fs" +import { Glob } from "../util/glob" export namespace Command { export const Event = { @@ -80,6 +85,59 @@ export namespace Command { } as Record } + const commandCache = new Map() + + function rel(item: string, patterns: string[]) { + const normalizedItem = item.replaceAll("\\", "/") + for (const pattern of patterns) { + const index = normalizedItem.indexOf(pattern) + if (index === -1) continue + return normalizedItem.slice(index + pattern.length) + } + } + + function trim(file: string) { + const ext = path.extname(file) + return ext.length ? file.slice(0, -ext.length) : file + } + + async function findCommandFile(name: string): Promise<{ path: string } | null> { + const directories = await Config.directories() + for (const dir of directories) { + const subdirs = ["command", "commands", ".opencode/command", ".opencode/commands"] + for (const subdir of subdirs) { + const filePath = path.join(dir, subdir, name + ".md") + if (existsSync(filePath)) { + return { path: filePath } + } + const nested = path.join(dir, subdir, name + "/index.md") + if (existsSync(nested)) { + return { path: nested } + } + } + } + return null + } + + async function loadSingleCommand(filePath: string): Promise { + const md = await ConfigMarkdown.parse(filePath).catch(() => null) + if (!md) return null + + const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] + const file = rel(filePath, patterns) ?? path.basename(filePath) + const cmdName = trim(file) + + const config = { name: cmdName, ...md.data, template: md.content.trim() } + const parsed = Config.Command.safeParse(config) + if (!parsed.success) return null + + return { + ...parsed.data, + name: cmdName, + hints: hints(parsed.data.template), + } + } + const state = Instance.state(async () => { const cfg = await Config.get() const result = createBuiltInCommands() @@ -172,27 +230,132 @@ export namespace Command { return result } + async function loadFreshCommandsWithMtime(): Promise> { + const result = createBuiltInCommands() + const cfg = await Config.get() + + for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { ...command, name, hints: Command.hints(command.template) } + } + + const directories = await Config.directories() + for (const dir of directories) { + const subdirs = ["command", "commands", ".opencode/command", ".opencode/commands"] + for (const subdir of subdirs) { + const cmdDir = path.join(dir, subdir) + if (!existsSync(cmdDir)) continue + + const files = await Glob.scan("**/*.md", { cwd: cmdDir, absolute: true, dot: true, symlink: true }) + for (const filePath of files) { + const stat = await fs.stat(filePath) + const mtime = stat.mtimeMs + const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] + const file = rel(filePath, patterns) ?? path.basename(filePath) + const cmdName = trim(file) + + const cached = commandCache.get(cmdName) + if (cached && cached.filePath === filePath && cached.mtime === mtime) { + result[cmdName] = cached.command + continue + } + + const command = await loadSingleCommand(filePath) + if (command) { + commandCache.set(cmdName, { command, mtime, filePath }) + result[cmdName] = command + } + } + } + } + + for (const [name, prompt] of Object.entries(await MCP.prompts())) { + if (!result[name]) { + result[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return new Promise(async (resolve, reject) => { + const template = await MCP.getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ).catch(reject) + resolve( + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ) + }) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + } + + for (const skill of await Skill.all()) { + if (!result[skill.name]) { + result[skill.name] = { + name: skill.name, + description: skill.description, + source: "skill", + get template() { + return skill.content + }, + hints: [], + } + } + } + + return result + } + export async function get(name: string) { const cfg = await Config.get() - // If caching is disabled, reload commands fresh from config each time - if (cfg.experimental?.cache_command_markdown_files === false) { - const fresh = await loadFreshCommands() + if (cfg.experimental?.cache_command_markdown_files !== false) { + return state().then((x) => x[name]) + } + + const builtIn = createBuiltInCommands() + if (builtIn[name]) return builtIn[name] + + if (cfg.command?.[name]) { + return { ...cfg.command[name], name, hints: Command.hints(cfg.command[name].template) } + } + + const cached = commandCache.get(name) + const fileInfo = await findCommandFile(name) + + if (!fileInfo) { + const fresh = await loadFreshCommandsWithMtime() return fresh[name] } - return state().then((x) => x[name]) + const stat = await fs.stat(fileInfo.path) + const mtime = stat.mtimeMs + + if (cached && cached.filePath === fileInfo.path && cached.mtime === mtime) { + return cached.command + } + + const command = await loadSingleCommand(fileInfo.path) + if (command) { + commandCache.set(name, { command, mtime, filePath: fileInfo.path }) + } + return command } export async function list() { const cfg = await Config.get() - // If caching is disabled, reload commands fresh from config each time - if (cfg.experimental?.cache_command_markdown_files === false) { - const fresh = await loadFreshCommands() - return Object.values(fresh) + if (cfg.experimental?.cache_command_markdown_files !== false) { + return state().then((x) => Object.values(x)) } - return state().then((x) => Object.values(x)) + const fresh = await loadFreshCommandsWithMtime() + return Object.values(fresh) } } From 84a48f105304cf0e54d1f1754c9e5fe0f0fcf582 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 7 Mar 2026 08:15:05 -0500 Subject: [PATCH 14/18] Fix cache_command_markdown_files setting When experimental.cache_command_markdown_files is false, commands should be reloaded from disk on each execution. The previous code short-circuited by returning cfg.command[name] before checking the filesystem, which contained stale cached data. Now we check for the markdown file on disk first, and only fall back to cfg.command if no markdown file exists (for commands defined in opencode.json or other non-markdown sources). --- packages/opencode/src/command/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 76cc8ced5ee6..39c8f25a9319 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -322,13 +322,14 @@ export namespace Command { const builtIn = createBuiltInCommands() if (builtIn[name]) return builtIn[name] - if (cfg.command?.[name]) { - return { ...cfg.command[name], name, hints: Command.hints(cfg.command[name].template) } - } - const cached = commandCache.get(name) const fileInfo = await findCommandFile(name) + // Only use cfg.command as fallback if no markdown file exists on disk + if (!fileInfo && cfg.command?.[name]) { + return { ...cfg.command[name], name, hints: Command.hints(cfg.command[name].template) } + } + if (!fileInfo) { const fresh = await loadFreshCommandsWithMtime() return fresh[name] From e8c6aeef2f5d457273b19334480406a9d005b243 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 7 Mar 2026 08:15:53 -0500 Subject: [PATCH 15/18] Add null check for command lookup Command.get() can return null when a command is not found. Added early return with error to handle this case. --- packages/opencode/src/session/prompt.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4f77920cc987..577effd8e2c2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1746,6 +1746,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) + if (!command) { + throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) + } const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] From 33ca8bdeee191c6e390fce7a7d04954f76b1d777 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 24 Mar 2026 10:26:02 -0400 Subject: [PATCH 16/18] Fix type errors in command/index.ts after merge --- packages/opencode/src/command/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 7ef166371cec..f4bbc0de447a 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" +import { Instance } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, ServiceMap } from "effect" import z from "zod" @@ -165,8 +165,7 @@ export namespace Command { // When experimental.cache_command_markdown_files is explicitly false, // bypass the cache and load fresh commands if (cfg.experimental?.cache_command_markdown_files === false) { - const ctx = yield* InstanceContext - const state = yield* init(ctx) + const state = yield* init(Instance.current) return state.commands[name] } const state = yield* InstanceState.get(cache) @@ -178,8 +177,7 @@ export namespace Command { // When experimental.cache_command_markdown_files is explicitly false, // bypass the cache and load fresh commands if (cfg.experimental?.cache_command_markdown_files === false) { - const ctx = yield* InstanceContext - const state = yield* init(ctx) + const state = yield* init(Instance.current) return Object.values(state.commands) } const state = yield* InstanceState.get(cache) From 44e9f41f27fab340db082947f27d61960ed75038 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 24 Mar 2026 10:30:51 -0400 Subject: [PATCH 17/18] Fix config tests to expect name field in markdown-loaded commands --- packages/opencode/test/config/config.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index eb9c763fa757..abe2ddd592ff 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -620,11 +620,13 @@ Nested command template`, const config = await Config.get() expect(config.command?.["hello"]).toEqual({ + name: "hello", description: "Test command", template: "Hello from singular command", }) expect(config.command?.["nested/child"]).toEqual({ + name: "nested/child", description: "Nested command", template: "Nested command template", }) @@ -665,11 +667,13 @@ Nested command template`, const config = await Config.get() expect(config.command?.["hello"]).toEqual({ + name: "hello", description: "Test command", template: "Hello from plural commands", }) expect(config.command?.["nested/child"]).toEqual({ + name: "nested/child", description: "Nested command", template: "Nested command template", }) From 1fd34daa1097e2ea80b04e2c18e5e6467f37ef5f Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 24 Mar 2026 16:36:43 -0400 Subject: [PATCH 18/18] feat: Complete implementation of cache_command_markdown_files: false Replace placeholder implementation with full working version: - Add commandCache Map for mtime-based caching of individual commands - Add findCommandFile() to search for command files in config directories - Add loadSingleCommand() to parse individual markdown command files - Add loadFreshCommands() to reload all commands from disk - Add loadFreshCommandsWithMtime() for efficient mtime-checked reloading - Modify Command.get() to check disk and mtime when flag is false - Modify Command.list() to use loadFreshCommandsWithMtime() When cache_command_markdown_files is false, commands are reloaded from disk on each execution with mtime checking to avoid unnecessary re-parsing. This is a complete implementation that replaces the previous placeholder which only checked the flag but still used cached cfg.command data. --- packages/opencode/src/command/index.ts | 244 ++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index f4bbc0de447a..805db0db2710 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,7 +5,12 @@ import { Instance } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, ServiceMap } from "effect" import z from "zod" +import path from "path" +import fs from "fs/promises" +import { existsSync } from "fs" import { Config } from "../config/config" +import { ConfigMarkdown } from "../config/markdown" +import { Glob } from "../util/glob" import { MCP } from "../mcp" import { Skill } from "../skill" import { Log } from "../util/log" @@ -51,12 +56,211 @@ export namespace Command { // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } + // Cache for command file mtimes when cache_command_markdown_files is false + const commandCache = new Map() + + function rel(item: string, patterns: string[]) { + const normalizedItem = item.replaceAll("\\", "/") + for (const pattern of patterns) { + const index = normalizedItem.indexOf(pattern) + if (index === -1) continue + return normalizedItem.slice(index + pattern.length) + } + } + + function trim(file: string) { + const ext = path.extname(file) + return ext.length ? file.slice(0, -ext.length) : file + } + + async function findCommandFile(name: string): Promise<{ path: string } | null> { + const directories = await Config.directories() + for (const dir of directories) { + const subdirs = ["command", "commands", ".opencode/command", ".opencode/commands"] + for (const subdir of subdirs) { + const filePath = path.join(dir, subdir, name + ".md") + if (existsSync(filePath)) { + return { path: filePath } + } + const nested = path.join(dir, subdir, name + "/index.md") + if (existsSync(nested)) { + return { path: nested } + } + } + } + return null + } + + async function loadSingleCommand(filePath: string): Promise { + const md = await ConfigMarkdown.parse(filePath).catch(() => null) + if (!md) return null + + const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] + const file = rel(filePath, patterns) ?? path.basename(filePath) + const cmdName = trim(file) + + const config = { name: cmdName, ...md.data, template: md.content.trim() } + const parsed = Config.Command.safeParse(config) + if (!parsed.success) return null + + return { + ...parsed.data, + name: cmdName, + hints: hints(parsed.data.template), + } + } + + async function loadFreshCommands(): Promise> { + const result: Record = {} + const cfg = await Config.get() + + // Load commands from config file (non-markdown) + for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { + ...command, + name, + hints: Command.hints(command.template), + } + } + + // Reload commands from markdown files in each config directory + const directories = await Config.directories() + for (const dir of directories) { + const commands = await Config.reloadCommands(dir) + for (const [name, command] of Object.entries(commands)) { + result[name] = { + ...command, + name, + hints: Command.hints(command.template), + } + } + } + + return result + } + + async function loadFreshCommandsWithMtime(): Promise> { + const result: Record = createBuiltInCommands() + const cfg = await Config.get() + + // Load commands from config file (non-markdown) + for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { ...command, name, hints: Command.hints(command.template) } + } + + // Load commands from markdown files with mtime checking + const directories = await Config.directories() + for (const dir of directories) { + const subdirs = ["command", "commands", ".opencode/command", ".opencode/commands"] + for (const subdir of subdirs) { + const cmdDir = path.join(dir, subdir) + if (!existsSync(cmdDir)) continue + + const files = await Glob.scan("**/*.md", { cwd: cmdDir, absolute: true, dot: true, symlink: true }) + for (const filePath of files) { + const stat = await fs.stat(filePath) + const mtime = stat.mtimeMs + const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] + const file = rel(filePath, patterns) ?? path.basename(filePath) + const cmdName = trim(file) + + const cached = commandCache.get(cmdName) + if (cached && cached.filePath === filePath && cached.mtime === mtime) { + result[cmdName] = cached.command + continue + } + + const command = await loadSingleCommand(filePath) + if (command) { + commandCache.set(cmdName, { command, mtime, filePath }) + result[cmdName] = command + } + } + } + } + + // Add MCP prompts + for (const [name, prompt] of Object.entries(await MCP.prompts())) { + if (!result[name]) { + result[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return new Promise(async (resolve, reject) => { + const template = await MCP.getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ).catch(reject) + resolve( + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ) + }) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + } + + // Add skills + for (const skill of await Skill.all()) { + if (!result[skill.name]) { + result[skill.name] = { + name: skill.name, + description: skill.description, + source: "skill", + get template() { + return skill.content + }, + hints: [], + } + } + } + + return result + } + + function createBuiltInCommands(): Record { + return { + [Default.INIT]: { + name: Default.INIT, + description: "create/update AGENTS.md", + source: "command", + get template() { + return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) + }, + hints: hints(PROMPT_INITIALIZE), + }, + [Default.REVIEW]: { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", + get template() { + return PROMPT_REVIEW.replace("${path}", Instance.worktree) + }, + subtask: true, + hints: hints(PROMPT_REVIEW), + }, + } + } + export function hints(template: string) { const result: string[] = [] const numbered = template.match(/\$\d+/g) if (numbered) { for (const match of [...new Set(numbered)].sort()) result.push(match) } + const extended = template.match(/\$\{(\d+|\d*\.\.\d*)\}/g) + if (extended) { + for (const match of [...new Set(extended)].sort()) { + if (!result.includes(match)) result.push(match) + } + } if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") return result } @@ -163,10 +367,38 @@ export namespace Command { const get = Effect.fn("Command.get")(function* (name: string) { const cfg = yield* Effect.promise(() => Config.get()) // When experimental.cache_command_markdown_files is explicitly false, - // bypass the cache and load fresh commands + // reload commands from disk on each call if (cfg.experimental?.cache_command_markdown_files === false) { - const state = yield* init(Instance.current) - return state.commands[name] + const builtIn = createBuiltInCommands() + if (builtIn[name]) return builtIn[name] + + const cached = commandCache.get(name) + const fileInfo = yield* Effect.promise(() => findCommandFile(name)) + + // Only use cfg.command as fallback if no markdown file exists on disk + if (!fileInfo && cfg.command?.[name]) { + return { ...cfg.command[name], name, hints: Command.hints(cfg.command[name].template) } + } + + if (!fileInfo) { + return undefined + } + + // Check if we have a cached version with valid mtime + if (cached && cached.filePath === fileInfo.path) { + const stat = yield* Effect.promise(() => fs.stat(fileInfo.path)) + if (stat.mtimeMs === cached.mtime) { + return cached.command + } + } + + // Load fresh from disk + const command = yield* Effect.promise(() => loadSingleCommand(fileInfo.path)) + if (command) { + const stat = yield* Effect.promise(() => fs.stat(fileInfo.path)) + commandCache.set(name, { command, mtime: stat.mtimeMs, filePath: fileInfo.path }) + } + return command ?? undefined } const state = yield* InstanceState.get(cache) return state.commands[name] @@ -175,10 +407,10 @@ export namespace Command { const list = Effect.fn("Command.list")(function* () { const cfg = yield* Effect.promise(() => Config.get()) // When experimental.cache_command_markdown_files is explicitly false, - // bypass the cache and load fresh commands + // reload commands from disk on each call if (cfg.experimental?.cache_command_markdown_files === false) { - const state = yield* init(Instance.current) - return Object.values(state.commands) + const fresh = yield* Effect.promise(() => loadFreshCommandsWithMtime()) + return Object.values(fresh) } const state = yield* InstanceState.get(cache) return Object.values(state.commands)