From 2729705594b9429ce44cf371dbf7268ac3457d8b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 17 Jan 2026 05:23:11 -0600 Subject: [PATCH 01/14] fix(app): archive session sometimes flaky --- packages/app/src/context/global-sync.tsx | 2 ++ packages/app/src/pages/layout.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 74641a0a243b..96f8c63eab24 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -379,6 +379,8 @@ function createGlobalSync() { }), ) } + if (event.properties.info.parentID) break + setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index bc62c70232f2..eb09b154b9c1 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -501,7 +501,7 @@ export default function Layout(props: ParentProps) { const [dirStore] = globalSync.child(dir) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) result.push(...dirSessions) } @@ -510,7 +510,7 @@ export default function Layout(props: ParentProps) { const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) }) @@ -1203,7 +1203,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) @@ -1349,7 +1349,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(directory) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1358,7 +1358,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1445,7 +1445,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) From c4e4f2a0586df665988cd4afcecb810df4995627 Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Sat, 17 Jan 2026 19:45:31 +0800 Subject: [PATCH 02/14] fix(desktop): Added a Windows-only guard that makes window.getComputedStyle fall back to document.documentElement (#9054) --- packages/desktop/src/index.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 7a46ba8cde01..8398f457766b 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -26,6 +26,18 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ) } +const isWindows = ostype() === "windows" +if (isWindows) { + const originalGetComputedStyle = window.getComputedStyle + window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { + if (!(elt instanceof Element)) { + // WebView2 can call into Floating UI with non-elements; fall back to a safe element. + return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) + } + return originalGetComputedStyle(elt, pseudoElt ?? undefined) + }) as typeof window.getComputedStyle +} + let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ From 7030f49a7485e7b8f2f553351019b778dca64af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ace=C3=B1a?= <40375118+j0nl1@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:47:19 +0100 Subject: [PATCH 03/14] fix: mdns discover hostname (#9039) --- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/server/mdns.ts | 4 +++- packages/opencode/src/server/server.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 2c207ecc2f28..d799b7cc25e3 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,7 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `opencode.local:${server.port}`) } // Open localhost in browser diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 8bddb9105037..953269de444a 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,15 +7,17 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number, name = "opencode") { + export function publish(port: number) { if (currentPort === port) return if (bonjour) unpublish() try { + const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", + host: "opencode.local", port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f0c64b49f81d..28dec7f4043b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -562,7 +562,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, `opencode-${server.port!}`) + MDNS.publish(server.port!) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } From d37724649182ef26fd158cd4d36c9e6f97a6ea5c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 17 Jan 2026 11:47:55 +0000 Subject: [PATCH 04/14] chore: generate --- packages/opencode/src/cli/cmd/web.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index d799b7cc25e3..5fa2bb42640f 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,11 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `opencode.local:${server.port}`) + UI.println( + UI.Style.TEXT_INFO_BOLD + " mDNS: ", + UI.Style.TEXT_NORMAL, + `opencode.local:${server.port}`, + ) } // Open localhost in browser From 07dc8d8ce48300e42c4cd5026fac609be1b2aaca Mon Sep 17 00:00:00 2001 From: Slone <50995948+Slone123c@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:48:38 +0800 Subject: [PATCH 05/14] fix: escape CSS selector keys to handle special characters (#9030) --- packages/ui/src/components/list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 5be7f95aeec5..631b3e33a299 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -69,7 +69,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${key}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) element?.scrollIntoView({ block: "center" }) }) }) @@ -81,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) element?.scrollIntoView({ block: "center" }) }) From a58d1be8226c749f015047c15c47950735cd9370 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 17 Jan 2026 12:04:18 +0000 Subject: [PATCH 06/14] ignore: update download stats 2026-01-17 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index e09c57e8f416..9a665612b144 100644 --- a/STATS.md +++ b/STATS.md @@ -202,3 +202,4 @@ | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | +| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | From a813fcb41cb43f463c2fd14dfe9d499c4d28c151 Mon Sep 17 00:00:00 2001 From: Colby Gilbert Date: Sat, 17 Jan 2026 11:04:43 -0800 Subject: [PATCH 07/14] docs: add firmware provider to providers docs (#8993) --- packages/web/src/content/docs/providers.mdx | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e1d684de00ae..6022d174a7db 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -558,6 +558,33 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- +### Firmware + +1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. + +2. Run the `/connect` command and search for **Firmware**. + + ```txt + /connect + ``` + +3. Enter your Firmware API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +--- + ### Fireworks AI 1. Head over to the [Fireworks AI console](https://app.fireworks.ai/), create an account, and click **Create API Key**. From eb968a6651e2af4b806ca8b466a82b11b04c56a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernat=20Peric=C3=A0s?= Date: Sat, 17 Jan 2026 20:07:03 +0100 Subject: [PATCH 08/14] docs(config): explain that `autoupdate` doesn't work when installed with a package manager (#9092) --- packages/web/src/content/docs/config.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 30edbbd21462..eeb41b943c9b 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -425,6 +425,7 @@ OpenCode will automatically download any new updates when it starts up. You can ``` If you don't want updates but want to be notified when a new version is available, set `autoupdate` to `"notify"`. +Notice that this only works if it was not installed using a package manager such as Homebrew. --- From 5a199b04cbbc8a582d8130d4d8e37e052094cc50 Mon Sep 17 00:00:00 2001 From: Rahul Mishra Date: Sun, 18 Jan 2026 00:38:11 +0530 Subject: [PATCH 09/14] fix: don't try to open command palette if a dialog is already open (#9116) --- packages/app/src/context/command.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index a93ffc024545..d8dc13e23442 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -122,6 +123,7 @@ export function formatKeybind(config: string): string { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { + const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -165,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended()) return + if (suspended() || dialog.active) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { From 58f7da6e9f8fb08a9f42a0d2cc34a18b2475ddea Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 13:09:27 -0600 Subject: [PATCH 10/14] docs: document the plural forms --- packages/web/src/content/docs/agents.mdx | 12 +++++----- packages/web/src/content/docs/commands.mdx | 24 +++++++++---------- packages/web/src/content/docs/config.mdx | 10 +++++--- .../web/src/content/docs/custom-tools.mdx | 16 ++++++------- packages/web/src/content/docs/modes.mdx | 12 +++++----- packages/web/src/content/docs/permissions.mdx | 2 +- packages/web/src/content/docs/plugins.mdx | 24 +++++++++---------- packages/web/src/content/docs/skills.mdx | 10 ++++---- 8 files changed, 57 insertions(+), 53 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index b85bd2142fa7..22bed7f16a44 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -157,10 +157,10 @@ Configure agents in your `opencode.json` config file: You can also define agents using markdown files. Place them in: -- Global: `~/.config/opencode/agent/` -- Per-project: `.opencode/agent/` +- Global: `~/.config/opencode/agents/` +- Per-project: `.opencode/agents/` -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Reviews code for quality and best practices mode: subagent @@ -419,7 +419,7 @@ You can override these permissions per agent. You can also set permissions in Markdown agents. -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent @@ -637,7 +637,7 @@ Do you have an agent you'd like to share? [Submit a PR](https://github.com/anoma ### Documentation agent -```markdown title="~/.config/opencode/agent/docs-writer.md" +```markdown title="~/.config/opencode/agents/docs-writer.md" --- description: Writes and maintains project documentation mode: subagent @@ -659,7 +659,7 @@ Focus on: ### Security auditor -```markdown title="~/.config/opencode/agent/security-auditor.md" +```markdown title="~/.config/opencode/agents/security-auditor.md" --- description: Performs security audits and identifies vulnerabilities mode: subagent diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 92ca08bd2e99..1d7e4f1c21a2 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -15,11 +15,11 @@ Custom commands are in addition to the built-in commands like `/init`, `/undo`, ## Create command files -Create markdown files in the `command/` directory to define custom commands. +Create markdown files in the `commands/` directory to define custom commands. -Create `.opencode/command/test.md`: +Create `.opencode/commands/test.md`: -```md title=".opencode/command/test.md" +```md title=".opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -42,7 +42,7 @@ Use the command by typing `/` followed by the command name. ## Configure -You can add custom commands through the OpenCode config or by creating markdown files in the `command/` directory. +You can add custom commands through the OpenCode config or by creating markdown files in the `commands/` directory. --- @@ -79,10 +79,10 @@ Now you can run this command in the TUI: You can also define commands using markdown files. Place them in: -- Global: `~/.config/opencode/command/` -- Per-project: `.opencode/command/` +- Global: `~/.config/opencode/commands/` +- Per-project: `.opencode/commands/` -```markdown title="~/.config/opencode/command/test.md" +```markdown title="~/.config/opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -112,7 +112,7 @@ The prompts for the custom commands support several special placeholders and syn Pass arguments to commands using the `$ARGUMENTS` placeholder. -```md title=".opencode/command/component.md" +```md title=".opencode/commands/component.md" --- description: Create a new component --- @@ -138,7 +138,7 @@ You can also access individual arguments using positional parameters: For example: -```md title=".opencode/command/create-file.md" +```md title=".opencode/commands/create-file.md" --- description: Create a new file with content --- @@ -167,7 +167,7 @@ Use _!`command`_ to inject [bash command](/docs/tui#bash-commands) output into y For example, to create a custom command that analyzes test coverage: -```md title=".opencode/command/analyze-coverage.md" +```md title=".opencode/commands/analyze-coverage.md" --- description: Analyze test coverage --- @@ -180,7 +180,7 @@ Based on these results, suggest improvements to increase coverage. Or to review recent changes: -```md title=".opencode/command/review-changes.md" +```md title=".opencode/commands/review-changes.md" --- description: Review recent changes --- @@ -199,7 +199,7 @@ Commands run in your project's root directory and their output becomes part of t Include files in your command using `@` followed by the filename. -```md title=".opencode/command/review-component.md" +```md title=".opencode/commands/review-component.md" --- description: Review component --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index eeb41b943c9b..1474cb91558c 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -51,6 +51,10 @@ Config sources are loaded in this order (later sources override earlier ones): This means project configs can override global defaults, and global configs can override remote organizational defaults. +:::note +The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. +::: + --- ### Remote @@ -330,7 +334,7 @@ You can configure specialized agents for specific tasks through the `agent` opti } ``` -You can also define agents using markdown files in `~/.config/opencode/agent/` or `.opencode/agent/`. [Learn more here](/docs/agents). +You can also define agents using markdown files in `~/.config/opencode/agents/` or `.opencode/agents/`. [Learn more here](/docs/agents). --- @@ -394,7 +398,7 @@ You can configure custom commands for repetitive tasks through the `command` opt } ``` -You can also define commands using markdown files in `~/.config/opencode/command/` or `.opencode/command/`. [Learn more here](/docs/commands). +You can also define commands using markdown files in `~/.config/opencode/commands/` or `.opencode/commands/`. [Learn more here](/docs/commands). --- @@ -530,7 +534,7 @@ You can configure MCP servers you want to use through the `mcp` option. [Plugins](/docs/plugins) extend OpenCode with custom tools, hooks, and integrations. -Place plugin files in `.opencode/plugin/` or `~/.config/opencode/plugin/`. You can also load plugins from npm through the `plugin` option. +Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option. ```json title="opencode.json" { diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index 2701be650868..e089a035b4b4 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -17,8 +17,8 @@ Tools are defined as **TypeScript** or **JavaScript** files. However, the tool d They can be defined: -- Locally by placing them in the `.opencode/tool/` directory of your project. -- Or globally, by placing them in `~/.config/opencode/tool/`. +- Locally by placing them in the `.opencode/tools/` directory of your project. +- Or globally, by placing them in `~/.config/opencode/tools/`. --- @@ -26,7 +26,7 @@ They can be defined: The easiest way to create tools is using the `tool()` helper which provides type-safety and validation. -```ts title=".opencode/tool/database.ts" {1} +```ts title=".opencode/tools/database.ts" {1} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -49,7 +49,7 @@ The **filename** becomes the **tool name**. The above creates a `database` tool. You can also export multiple tools from a single file. Each export becomes **a separate tool** with the name **`_`**: -```ts title=".opencode/tool/math.ts" +```ts title=".opencode/tools/math.ts" import { tool } from "@opencode-ai/plugin" export const add = tool({ @@ -112,7 +112,7 @@ export default { Tools receive context about the current session: -```ts title=".opencode/tool/project.ts" {8} +```ts title=".opencode/tools/project.ts" {8} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -136,7 +136,7 @@ You can write your tools in any language you want. Here's an example that adds t First, create the tool as a Python script: -```python title=".opencode/tool/add.py" +```python title=".opencode/tools/add.py" import sys a = int(sys.argv[1]) @@ -146,7 +146,7 @@ print(a + b) Then create the tool definition that invokes it: -```ts title=".opencode/tool/python-add.ts" {10} +```ts title=".opencode/tools/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -156,7 +156,7 @@ export default tool({ b: tool.schema.number().describe("Second number"), }, async execute(args) { - const result = await Bun.$`python3 .opencode/tool/add.py ${args.a} ${args.b}`.text() + const result = await Bun.$`python3 .opencode/tools/add.py ${args.a} ${args.b}`.text() return result.trim() }, }) diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index a31a8223b072..57c1c54a956d 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -87,10 +87,10 @@ Configure modes in your `opencode.json` config file: You can also define modes using markdown files. Place them in: -- Global: `~/.config/opencode/mode/` -- Project: `.opencode/mode/` +- Global: `~/.config/opencode/modes/` +- Project: `.opencode/modes/` -```markdown title="~/.config/opencode/mode/review.md" +```markdown title="~/.config/opencode/modes/review.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.1 @@ -268,9 +268,9 @@ You can create your own custom modes by adding them to the configuration. Here a ### Using markdown files -Create mode files in `.opencode/mode/` for project-specific modes or `~/.config/opencode/mode/` for global modes: +Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: -```markdown title=".opencode/mode/debug.md" +```markdown title=".opencode/modes/debug.md" --- temperature: 0.1 tools: @@ -294,7 +294,7 @@ Focus on: Do not make any changes to files. Only investigate and report. ``` -```markdown title="~/.config/opencode/mode/refactor.md" +```markdown title="~/.config/opencode/modes/refactor.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.2 diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index b4f0691ced75..4df3841e34a8 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -174,7 +174,7 @@ Refer to the [Granular Rules (Object Syntax)](#granular-rules-object-syntax) sec You can also configure agent permissions in Markdown: -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index bf26744f6c4e..66a1b3cad95d 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -19,8 +19,8 @@ There are two ways to load plugins. Place JavaScript or TypeScript files in the plugin directory. -- `.opencode/plugin/` - Project-level plugins -- `~/.config/opencode/plugin/` - Global plugins +- `.opencode/plugins/` - Project-level plugins +- `~/.config/opencode/plugins/` - Global plugins Files in these directories are automatically loaded at startup. @@ -57,8 +57,8 @@ Plugins are loaded from all sources and all hooks run in sequence. The load orde 1. Global config (`~/.config/opencode/opencode.json`) 2. Project config (`opencode.json`) -3. Global plugin directory (`~/.config/opencode/plugin/`) -4. Project plugin directory (`.opencode/plugin/`) +3. Global plugin directory (`~/.config/opencode/plugins/`) +4. Project plugin directory (`.opencode/plugins/`) Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately. @@ -85,7 +85,7 @@ Local plugins and custom tools can use external npm packages. Add a `package.jso OpenCode runs `bun install` at startup to install these. Your plugins and tools can then import them. -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" import { escape } from "shescape" export const MyPlugin = async (ctx) => { @@ -103,7 +103,7 @@ export const MyPlugin = async (ctx) => { ### Basic structure -```js title=".opencode/plugin/example.js" +```js title=".opencode/plugins/example.js" export const MyPlugin = async ({ project, client, $, directory, worktree }) => { console.log("Plugin initialized!") @@ -215,7 +215,7 @@ Here are some examples of plugins you can use to extend opencode. Send notifications when certain events occur: -```js title=".opencode/plugin/notification.js" +```js title=".opencode/plugins/notification.js" export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { return { event: async ({ event }) => { @@ -240,7 +240,7 @@ If you’re using the OpenCode desktop app, it can send system notifications aut Prevent opencode from reading `.env` files: -```javascript title=".opencode/plugin/env-protection.js" +```javascript title=".opencode/plugins/env-protection.js" export const EnvProtection = async ({ project, client, $, directory, worktree }) => { return { "tool.execute.before": async (input, output) => { @@ -258,7 +258,7 @@ export const EnvProtection = async ({ project, client, $, directory, worktree }) Plugins can also add custom tools to opencode: -```ts title=".opencode/plugin/custom-tools.ts" +```ts title=".opencode/plugins/custom-tools.ts" import { type Plugin, tool } from "@opencode-ai/plugin" export const CustomToolsPlugin: Plugin = async (ctx) => { @@ -292,7 +292,7 @@ Your custom tools will be available to opencode alongside built-in tools. Use `client.app.log()` instead of `console.log` for structured logging: -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" export const MyPlugin = async ({ client }) => { await client.app.log({ service: "my-plugin", @@ -311,7 +311,7 @@ Levels: `debug`, `info`, `warn`, `error`. See [SDK documentation](https://openco Customize the context included when a session is compacted: -```ts title=".opencode/plugin/compaction.ts" +```ts title=".opencode/plugins/compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CompactionPlugin: Plugin = async (ctx) => { @@ -335,7 +335,7 @@ The `experimental.session.compacting` hook fires before the LLM generates a cont You can also replace the compaction prompt entirely by setting `output.prompt`: -```ts title=".opencode/plugin/custom-compaction.ts" +```ts title=".opencode/plugins/custom-compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CustomCompactionPlugin: Plugin = async (ctx) => { diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 54c2c9d06ef4..553931eec49c 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -13,8 +13,8 @@ Skills are loaded on-demand via the native `skill` tool—agents see available s Create one folder per skill name and put a `SKILL.md` inside it. OpenCode searches these locations: -- Project config: `.opencode/skill//SKILL.md` -- Global config: `~/.config/opencode/skill//SKILL.md` +- Project config: `.opencode/skills//SKILL.md` +- Global config: `~/.config/opencode/skills//SKILL.md` - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` @@ -23,9 +23,9 @@ OpenCode searches these locations: ## Understand discovery For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree. -It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. +It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. -Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. --- @@ -71,7 +71,7 @@ Keep it specific enough for the agent to choose correctly. ## Use an example -Create `.opencode/skill/git-release/SKILL.md` like this: +Create `.opencode/skills/git-release/SKILL.md` like this: ```markdown --- From 3aff88c23d139f47af7f4db7bbc14e08a30f3b6e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:36:54 -0600 Subject: [PATCH 11/14] docs: add use_github_token to example (#9120) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node --- github/README.md | 2 ++ packages/web/src/content/docs/github.mdx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/github/README.md b/github/README.md index 8238bdc42aa0..17b24ffb1d6e 100644 --- a/github/README.md +++ b/github/README.md @@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true ``` 3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys. diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 6e8b9de4d79b..a31fe1e7be82 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -180,8 +180,10 @@ jobs: - uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true prompt: | Review this pull request: - Check for code quality issues From f3513bacffc83355369ccf5b7e8c264dc764d901 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 14:41:42 -0600 Subject: [PATCH 12/14] tui: fix model state persistence when model store is not ready --- packages/opencode/src/cli/cmd/tui/context/local.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf8..d058ce54fb36 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + const state = { + pending: false, + } function save() { + if (!modelStore.ready) { + state.pending = true + return + } + state.pending = false Bun.write( file, JSON.stringify({ @@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch(() => {}) .finally(() => { setModelStore("ready", true) + if (state.pending) save() }) const args = useArgs() From 89635f351888623de706553ff2adbc50db5548b5 Mon Sep 17 00:00:00 2001 From: ryanwyler Date: Sun, 11 Jan 2026 18:00:28 -0700 Subject: [PATCH 13/14] feat: add collapse compaction mode Adds a new 'collapse' compaction method that preserves recent context while summarizing older messages, with intelligent merging of historical summaries. - Selective compression: only compresses oldest 65% of tokens - Historical summary merging: includes previous summaries for context continuity - Breakpoint insertion: places summary at correct position in timeline - TUI toggle: switch between standard/collapse via command palette New file compaction-collapse.ts contains all collapse logic for easy rebasing. Config options: - compaction.method: 'standard' | 'collapse' - compaction.trigger: overflow threshold (default 0.85) - compaction.extractRatio: fraction to extract (default 0.65) - compaction.recentRatio: recent context reference (default 0.15) - compaction.summaryMaxTokens: target summary size (default 10000) - compaction.previousSummaries: history to merge (default 3) --- .../src/cli/cmd/tui/routes/session/index.tsx | 13 + .../cli/cmd/tui/routes/session/sidebar.tsx | 6 + packages/opencode/src/config/config.ts | 36 + packages/opencode/src/id/id.ts | 63 +- .../src/session/compaction-extension.ts | 629 ++++++++++++++++++ packages/opencode/src/session/compaction.ts | 20 +- packages/opencode/src/session/message-v2.ts | 25 +- packages/sdk/js/src/v2/gen/types.gen.ts | 24 + 8 files changed, 805 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/session/compaction-extension.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 1d64a2ff1569..dcd45d51cc70 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -147,6 +147,10 @@ export function Session() { const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) + const [compactionMethod, setCompactionMethod] = kv.signal<"standard" | "collapse">( + "compaction_method", + sync.data.config.compaction?.method ?? "standard", + ) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -392,6 +396,15 @@ export function Session() { dialog.clear() }, }, + { + title: compactionMethod() === "collapse" ? "Use standard compaction" : "Use collapse compaction", + value: "session.toggle.compaction_method", + category: "Session", + onSelect: (dialog) => { + setCompactionMethod((prev) => (prev === "standard" ? "collapse" : "standard")) + dialog.clear() + }, + }, { title: "Unshare session", value: "session.unshare", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723a..ae9bf23795ea 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -93,6 +93,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { Context + + compact{" "} + {sync.data.config.compaction?.auto === false + ? "disabled" + : kv.get("compaction_method", sync.data.config.compaction?.method ?? "standard")} + {context()?.tokens ?? 0} tokens {context()?.percentage ?? 0}% used {cost()} spent diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1574c644d322..735cfe115034 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1022,6 +1022,42 @@ export namespace Config { .object({ auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + method: z + .enum(["standard", "collapse"]) + .optional() + .describe( + "Compaction method: 'standard' summarizes entire conversation, 'collapse' extracts oldest messages and creates summary at breakpoint (default: standard)", + ), + trigger: z + .number() + .min(0) + .max(1) + .optional() + .describe("Trigger compaction at this fraction of total context (default: 0.85 = 85%)"), + extractRatio: z + .number() + .min(0) + .max(1) + .optional() + .describe("For collapse mode: fraction of oldest tokens to extract and summarize (default: 0.65)"), + recentRatio: z + .number() + .min(0) + .max(1) + .optional() + .describe("For collapse mode: fraction of newest tokens to use as reference context (default: 0.15)"), + summaryMaxTokens: z + .number() + .min(1000) + .max(50000) + .optional() + .describe("For collapse mode: target token count for the summary output (default: 10000)"), + previousSummaries: z + .number() + .min(0) + .max(10) + .optional() + .describe("For collapse mode: number of previous summaries to include for context merging (default: 3)"), }) .optional(), experimental: z diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index db2920b0a458..05098c9a49ff 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -18,6 +18,7 @@ export namespace Identifier { } const LENGTH = 26 + const TIME_BYTES = 6 // State for monotonic ID generation let lastTimestamp = 0 @@ -65,12 +66,12 @@ export namespace Identifier { now = descending ? ~now : now - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + const timeBytes = Buffer.alloc(TIME_BYTES) + for (let i = 0; i < TIME_BYTES; i++) { + timeBytes[i] = Number((now >> BigInt((TIME_BYTES - 1 - i) * 8)) & BigInt(0xff)) } - return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) + return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - TIME_BYTES * 2) } /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ @@ -80,4 +81,58 @@ export namespace Identifier { const encoded = BigInt("0x" + hex) return Number(encoded / BigInt(0x1000)) } + + /** + * Insert an ID that sorts after afterId, and optionally before beforeId. + * + * If beforeId is provided and there's a gap, the new ID will sort between them. + * Otherwise, the new ID will sort immediately after afterId. + * + * @param afterId - The ID that the new ID must sort AFTER + * @param beforeId - Optional ID that the new ID should sort BEFORE (if gap exists) + * @param prefix - The prefix for the new ID (e.g., "message", "part") + */ + export function insert(afterId: string, beforeId: string | undefined, prefix: keyof typeof prefixes): string { + const underscoreIndex = afterId.indexOf("_") + if (underscoreIndex === -1) { + throw new Error(`Invalid afterId: ${afterId}`) + } + + const afterHex = afterId.slice(underscoreIndex + 1, underscoreIndex + 1 + TIME_BYTES * 2) + const afterValue = BigInt("0x" + afterHex) + + let newValue: bigint + + if (beforeId) { + const beforeUnderscoreIndex = beforeId.indexOf("_") + if (beforeUnderscoreIndex !== -1) { + const beforeHex = beforeId.slice(beforeUnderscoreIndex + 1, beforeUnderscoreIndex + 1 + TIME_BYTES * 2) + if (/^[0-9a-f]+$/i.test(beforeHex)) { + const beforeValue = BigInt("0x" + beforeHex) + const gap = beforeValue - afterValue + if (gap > BigInt(1)) { + // Insert in the middle of the gap + newValue = afterValue + gap / BigInt(2) + } else { + // Gap too small, create after afterId + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + } else { + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + } else { + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + } else { + // No beforeId, create after afterId + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + + const timeBytes = Buffer.alloc(TIME_BYTES) + for (let i = 0; i < TIME_BYTES; i++) { + timeBytes[i] = Number((newValue >> BigInt((TIME_BYTES - 1 - i) * 8)) & BigInt(0xff)) + } + + return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - TIME_BYTES * 2) + } } diff --git a/packages/opencode/src/session/compaction-extension.ts b/packages/opencode/src/session/compaction-extension.ts new file mode 100644 index 000000000000..4fd5c6e264fe --- /dev/null +++ b/packages/opencode/src/session/compaction-extension.ts @@ -0,0 +1,629 @@ +import { Session } from "." +import { Identifier } from "../id/id" +import { Instance } from "../project/instance" +import { Provider } from "../provider/provider" +import { MessageV2 } from "./message-v2" +import { SessionPrompt } from "./prompt" +import { Token } from "../util/token" +import { Log } from "../util/log" +import { SessionProcessor } from "./processor" +import { Agent } from "@/agent/agent" +import { Plugin } from "@/plugin" +import { Config } from "@/config/config" +import { Global } from "@/global" +import path from "path" + +/** + * Compaction Extension Module + * + * This module implements extended compaction modes beyond the standard compaction. + * Currently includes "collapse" mode - future modes can be added here. + * + * Collapse mode features: + * - Selective compression: Only compresses OLD messages, keeps recent work intact + * - Historical summary merging: Merges previous summaries into new ones (no info loss) + * - Breakpoint insertion: Places summary at correct position in message timeline + * + * This file is designed to be self-contained for easy rebasing when upstream changes. + */ + +export namespace CompactionExtension { + const log = Log.create({ service: "session.compaction.extension" }) + + // Default configuration values + export const DEFAULTS = { + method: "standard" as const, + trigger: 0.85, // Trigger at 85% of usable context to leave headroom + extractRatio: 0.65, + recentRatio: 0.15, + summaryMaxTokens: 10000, // Target token count for collapse summary + previousSummaries: 3, // Number of previous summaries to include in collapse + } + + // Build collapse prompt instructions (tokenTarget is optional for estimation) + function collapseInstructions(tokenTarget?: number): string { + const targetClause = tokenTarget ? ` (target: approximately ${tokenTarget} tokens)` : "" + return `You are creating a comprehensive context restoration document. This document will serve as the foundation for continued work - it must preserve critical knowledge that would otherwise be lost. + +Create a detailed summary${targetClause} with these sections: +1. Current Task State - what is being worked on, next steps, blockers +2. Resolved Code & Lessons Learned - working code verbatim, failed approaches, insights +3. User Directives - explicit preferences, style rules, things to always/never do +4. Custom Utilities & Commands - scripts, aliases, debugging commands +5. Design Decisions & Derived Requirements - architecture decisions, API contracts, patterns +6. Technical Facts - file paths, function names, config values, environment details + +Critical rules: +- PRESERVE working code verbatim in fenced blocks +- INCLUDE failed approaches with explanations +- Be specific with paths, line numbers, function names +- Capture the "why" behind decisions +- User directives are sacred - never omit them` + } + + /** + * Get the compaction method. + * Priority: TUI toggle (kv.json) > config file > default + */ + export async function getMethod(): Promise<"standard" | "collapse"> { + const config = await Config.get() + const configMethod = config.compaction?.method + + // Check TUI toggle override + try { + const file = Bun.file(path.join(Global.Path.state, "kv.json")) + if (await file.exists()) { + const kv = await file.json() + const toggle = kv["compaction_method"] + if (toggle === "standard" || toggle === "collapse") { + return toggle + } + } + } catch { + // Ignore KV read errors + } + + return configMethod ?? DEFAULTS.method + } + + /** + * Check if context is overflowing based on collapse trigger threshold. + * Uses configurable trigger ratio instead of fixed context-output calculation. + */ + export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + const config = await Config.get() + if (config.compaction?.auto === false) return false + const context = input.model.limit.context + if (context === 0) return false + + const count = input.tokens.input + input.tokens.cache.read + input.tokens.cache.write + input.tokens.output + const trigger = config.compaction?.trigger ?? DEFAULTS.trigger + const threshold = context * trigger + const isOver = count > threshold + + log.debug("overflow check", { + tokens: input.tokens, + count, + context, + trigger, + threshold, + isOver, + }) + + return isOver + } + + /** + * Collapse compaction: Extract oldest messages, distill with AI, insert summary at breakpoint. + * Messages before the breakpoint are filtered out by filterCompacted(). + */ + export async function process(input: { + parentID: string + messages: MessageV2.WithParts[] + sessionID: string + abort: AbortSignal + auto: boolean + }): Promise<"continue" | "stop"> { + const config = await Config.get() + const extractRatio = config.compaction?.extractRatio ?? DEFAULTS.extractRatio + const recentRatio = config.compaction?.recentRatio ?? DEFAULTS.recentRatio + const summaryMaxTokens = config.compaction?.summaryMaxTokens ?? DEFAULTS.summaryMaxTokens + const previousSummariesLimit = config.compaction?.previousSummaries ?? DEFAULTS.previousSummaries + + // Get the user message to determine which model we'll use + const originalUserMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User + const agent = await Agent.get("compaction") + const model = agent.model + ? await Provider.getModel(agent.model.providerID, agent.model.modelID) + : await Provider.getModel(originalUserMessage.model.providerID, originalUserMessage.model.modelID) + + // Calculate token counts and role counts + const messageTokens: number[] = [] + let totalTokens = 0 + let userCount = 0 + let assistantCount = 0 + for (const msg of input.messages) { + const estimate = estimateMessageTokens(msg) + messageTokens.push(estimate) + totalTokens += estimate + if (msg.info.role === "user") userCount++ + else if (msg.info.role === "assistant") assistantCount++ + } + + // Check if first message is a breakpoint (existing compaction) or new conversation + const firstMessage = input.messages[0] + const isBreakpoint = + firstMessage?.info.role === "assistant" && (firstMessage.info as MessageV2.Assistant).mode === "compaction" + + log.info("collapse context", { + sessionID: input.sessionID, + messages: input.messages.length, + tokens: totalTokens, + user: userCount, + assistant: assistantCount, + firstMessageId: firstMessage?.info.id, + chainType: isBreakpoint ? "breakpoint" : "new", + }) + + // Calculate extraction targets + const extractTarget = Math.floor(totalTokens * extractRatio) + const recentTarget = Math.floor(totalTokens * recentRatio) + + // Helper: if message at index has a parentID, return the parent's index + function findChainStart(index: number): number | undefined { + if (index <= 0 || index >= input.messages.length) return undefined + const msg = input.messages[index] + if (msg.info.role !== "assistant") return undefined + const parentID = (msg.info as MessageV2.Assistant).parentID + if (!parentID) return undefined + const parentIndex = input.messages.findIndex((m) => m.info.id === parentID) + return parentIndex >= 0 && parentIndex < index ? parentIndex : undefined + } + + // Find split points + let extractedTokens = 0 + let extractSplitIndex = 0 + for (let i = 0; i < input.messages.length; i++) { + if (extractedTokens >= extractTarget) break + extractedTokens += messageTokens[i] + extractSplitIndex = i + 1 + } + + // Ensure extract split is not in the middle of a chain + const extractChainStart = findChainStart(extractSplitIndex) + if (extractChainStart !== undefined) { + for (let i = extractChainStart; i < extractSplitIndex; i++) { + extractedTokens -= messageTokens[i] + } + extractSplitIndex = extractChainStart + } + + let recentTokens = 0 + let recentSplitIndex = input.messages.length + for (let i = input.messages.length - 1; i >= 0; i--) { + if (recentTokens >= recentTarget) break + recentTokens += messageTokens[i] + recentSplitIndex = i + } + + // Ensure recent split is not in the middle of a chain + const recentChainStart = findChainStart(recentSplitIndex) + if (recentChainStart !== undefined) { + for (let i = recentChainStart; i < recentSplitIndex; i++) { + recentTokens += messageTokens[i] + } + recentSplitIndex = recentChainStart + } + + // Ensure recent split doesn't overlap with extract + if (recentSplitIndex <= extractSplitIndex) { + recentSplitIndex = extractSplitIndex + } + + const extractedMessages = input.messages.slice(0, extractSplitIndex) + const middleMessages = input.messages.slice(extractSplitIndex, recentSplitIndex) + const recentReferenceMessages = input.messages.slice(recentSplitIndex) + + // Calculate middle section tokens + let middleTokens = 0 + for (let i = extractSplitIndex; i < recentSplitIndex; i++) { + middleTokens += messageTokens[i] + } + + log.info("collapse split", { + sessionID: input.sessionID, + total: { messages: input.messages.length, tokens: totalTokens }, + extract: { messages: extractedMessages.length, tokens: extractedTokens }, + middle: { messages: middleMessages.length, tokens: middleTokens }, + recent: { messages: recentReferenceMessages.length, tokens: recentTokens }, + }) + + if (extractedMessages.length === 0) { + log.info("collapse skipped", { sessionID: input.sessionID, reason: "no messages to extract" }) + return "continue" + } + + // Convert extracted messages to markdown for distillation + const markdownContent = messagesToMarkdown(extractedMessages) + const recentContext = messagesToMarkdown(recentReferenceMessages) + + // Build base prompt (without previous summaries) to calculate token budget + const markdownTokens = Token.estimate(markdownContent) + const recentTokensEstimate = Token.estimate(recentContext) + const templateTokens = Token.estimate(collapseInstructions()) + const basePromptTokens = markdownTokens + recentTokensEstimate + templateTokens + const contextLimit = model.limit.context + const outputReserve = SessionPrompt.OUTPUT_TOKEN_MAX + const previousSummaryBudget = Math.max(0, contextLimit - outputReserve - basePromptTokens) + + // Fetch previous summaries that fit within budget + const previousSummaries = await getPreviousSummaries(input.sessionID, previousSummariesLimit, previousSummaryBudget) + + // Get the last extracted message to determine breakpoint position + const lastExtractedMessage = extractedMessages[extractedMessages.length - 1] + let afterId = lastExtractedMessage.info.id + let beforeId: string | undefined + let breakpointTimestamp = lastExtractedMessage.info.time.created + 1 + + // Check if any message after the split has a parentID (is part of a chain) + // If so, the compaction must sort BEFORE that parent to keep the chain together + const messagesAfterSplit = input.messages.slice(extractSplitIndex) + for (const msg of messagesAfterSplit) { + if (msg.info.role === "assistant") { + const parentID = (msg.info as MessageV2.Assistant).parentID + if (parentID) { + // Find the message that sorts just before the parent + // Use direct string comparison (not localeCompare) for consistent case-sensitive ordering + const sortedMessages = [...input.messages].sort((a, b) => + a.info.id < b.info.id ? -1 : a.info.id > b.info.id ? 1 : 0, + ) + const parentIndex = sortedMessages.findIndex((m) => m.info.id === parentID) + + if (parentIndex > 0) { + afterId = sortedMessages[parentIndex - 1].info.id + beforeId = parentID + + const parent = input.messages.find((m) => m.info.id === parentID) + if (parent) { + breakpointTimestamp = parent.info.time.created - 1 + } + + log.debug("collapse breakpoint adjusted for chain", { + sessionID: input.sessionID, + chainMessageId: msg.info.id, + parentID, + afterId, + beforeId, + }) + } + break + } + } + } + + // Create compaction user message - sorts after afterId, and before beforeId if possible + const compactionUserId = Identifier.insert(afterId, beforeId, "message") + const compactionUserTimestamp = breakpointTimestamp + + log.debug("collapse insert", { + sessionID: input.sessionID, + afterInTime: afterId, + beforeInTime: beforeId ?? "(none)", + breakpointId: compactionUserId, + breakpointTimestamp: compactionUserTimestamp, + }) + + const compactionUserMsg = await Session.updateMessage({ + id: compactionUserId, + role: "user", + model: originalUserMessage.model, + sessionID: input.sessionID, + agent: originalUserMessage.agent, + time: { + created: compactionUserTimestamp, + }, + }) + await Session.updatePart({ + id: Identifier.insert(compactionUserId, undefined, "part"), + messageID: compactionUserMsg.id, + sessionID: input.sessionID, + type: "compaction", + auto: input.auto, + }) + + // Create assistant summary message - sorts after compaction user, before beforeId if possible + const compactionAssistantId = Identifier.insert(compactionUserId, beforeId, "message") + const compactionAssistantTimestamp = compactionUserTimestamp + 1 + + const msg = (await Session.updateMessage({ + id: compactionAssistantId, + role: "assistant", + parentID: compactionUserMsg.id, + sessionID: input.sessionID, + mode: "compaction", + agent: "compaction", + summary: true, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: model.id, + providerID: model.providerID, + time: { + created: compactionAssistantTimestamp, + }, + })) as MessageV2.Assistant + + const processor = SessionProcessor.create({ + assistantMessage: msg, + sessionID: input.sessionID, + model, + abort: input.abort, + }) + + // Allow plugins to inject context + const compacting = await Plugin.trigger( + "experimental.session.compacting", + { sessionID: input.sessionID }, + { context: [], prompt: undefined }, + ) + + // Build prompt sections - only include what we have + const sections: string[] = [] + + // Instructions + sections.push(collapseInstructions(summaryMaxTokens)) + + // Previous summaries + if (previousSummaries.length > 0) { + sections.push(` +IMPORTANT: Merge all information from these previous summaries into your new summary. Do not lose any historical context. + +${previousSummaries.map((summary, i) => `--- Summary ${i + 1} ---\n${summary}`).join("\n\n")} +`) + } + + // Extracted content + sections.push(` +The following conversation content needs to be distilled into the summary: + +${markdownContent} +`) + + // Recent context + sections.push(` +The following is recent context for reference (shows current state): + +${recentContext} +`) + + // Additional plugin context + if (compacting.context.length > 0) { + sections.push(` +${compacting.context.join("\n\n")} +`) + } + + sections.push("Generate the context restoration document now.") + + const collapsePrompt = sections.join("\n\n") + + const result = await processor.process({ + user: originalUserMessage, + agent, + abort: input.abort, + sessionID: input.sessionID, + tools: {}, + system: [], + messages: [ + { + role: "user", + content: [{ type: "text", text: collapsePrompt }], + }, + ], + model, + }) + + // NOTE: We intentionally do NOT add a "Continue if you have next steps" message + // for collapse mode. The collapse summary is just context restoration - the loop + // should exit after the summary is generated so the user can continue naturally. + + if (processor.message.error) return "stop" + + // Update token count on the chronologically last assistant message + // so isOverflow() sees the correct post-collapse state. + const allMessages = await Session.messages({ sessionID: input.sessionID }) + const lastAssistant = allMessages + .filter( + (m): m is MessageV2.WithParts & { info: MessageV2.Assistant } => + m.info.role === "assistant" && m.info.id !== msg.id, + ) + .sort((a, b) => b.info.time.created - a.info.time.created)[0] + + if (lastAssistant) { + const collapseSummaryTokens = processor.message.tokens.output + + const currentTotal = + lastAssistant.info.tokens.input + + lastAssistant.info.tokens.cache.read + + lastAssistant.info.tokens.cache.write + + lastAssistant.info.tokens.output + + const newTotal = Math.max(0, currentTotal - extractedTokens + collapseSummaryTokens) + + lastAssistant.info.tokens = { + input: 0, + output: lastAssistant.info.tokens.output, + reasoning: lastAssistant.info.tokens.reasoning, + cache: { + read: Math.max(0, newTotal - lastAssistant.info.tokens.output), + write: 0, + }, + } + await Session.updateMessage(lastAssistant.info) + + log.debug("tokens adjusted", { + sessionID: input.sessionID, + extracted: extractedTokens, + summary: collapseSummaryTokens, + estimated: newTotal, + }) + } + + // Count messages in the compacted chain (after compaction) + const remainingMessages = input.messages.length - extractedMessages.length + 2 // +2 for compaction user/assistant + const remainingUser = userCount - extractedMessages.filter((m) => m.info.role === "user").length + 1 + const remainingAssistant = assistantCount - extractedMessages.filter((m) => m.info.role === "assistant").length + 1 + + log.info("collapsed", { + sessionID: input.sessionID, + extracted: extractedMessages.length, + remaining: remainingMessages, + user: remainingUser, + assistant: remainingAssistant, + summaryTokens: processor.message.tokens.output, + }) + + // Delete the original trigger message (created by create()) to prevent + // the loop from picking it up again as a pending compaction task. + // The trigger is the message at input.parentID - we've created a new + // compaction user message at the breakpoint position. + if (input.parentID !== compactionUserMsg.id) { + log.debug("cleanup trigger", { sessionID: input.sessionID, id: input.parentID }) + // Delete parts first + const triggerMsg = input.messages.find((m) => m.info.id === input.parentID) + if (triggerMsg) { + for (const part of triggerMsg.parts) { + await Session.removePart({ + sessionID: input.sessionID, + messageID: input.parentID, + partID: part.id, + }) + } + } + await Session.removeMessage({ + sessionID: input.sessionID, + messageID: input.parentID, + }) + } + + // For auto-compaction: return "continue" so the loop processes the user's + // original message that triggered the overflow. The trigger message is deleted, + // so the loop will find the real user message and respond to it. + // For manual compaction: return "stop" - user explicitly requested compaction only. + if (input.auto) { + return "continue" + } + return "stop" + } + + /** + * Estimate tokens for a message (respects compaction state) + */ + function estimateMessageTokens(msg: MessageV2.WithParts): number { + let tokens = 0 + for (const part of msg.parts) { + if (part.type === "text") { + tokens += Token.estimate(part.text) + } else if (part.type === "tool" && part.state.status === "completed") { + // Skip compacted tool outputs + if (part.state.time.compacted) continue + tokens += Token.estimate(JSON.stringify(part.state.input)) + tokens += Token.estimate(part.state.output) + } + } + return tokens + } + + /** + * Convert messages to markdown format for distillation + */ + function messagesToMarkdown(messages: MessageV2.WithParts[]): string { + const lines: string[] = [] + + for (const msg of messages) { + const role = msg.info.role === "user" ? "User" : "Assistant" + lines.push(`### ${role}`) + lines.push("") + + for (const part of msg.parts) { + if (part.type === "text" && part.text) { + // Skip synthetic parts like "Continue if you have next steps" + if (part.synthetic) continue + lines.push(part.text) + lines.push("") + } else if (part.type === "tool" && part.state.status === "completed") { + // Skip compacted tool outputs + if (part.state.time.compacted) continue + lines.push(`**Tool: ${part.tool}**`) + lines.push("```json") + lines.push(JSON.stringify(part.state.input, null, 2)) + lines.push("```") + if (part.state.output) { + lines.push("Output:") + lines.push("```") + lines.push(part.state.output.slice(0, 1000)) + if (part.state.output.length > 1000) lines.push("... (truncated)") + lines.push("```") + } + lines.push("") + } + } + } + + return lines.join("\n") + } + + /** + * Extract summary text from a compaction summary message's parts + */ + function extractSummaryText(msg: MessageV2.WithParts): string { + return msg.parts + .filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic) + .map((p) => p.text) + .join("\n") + } + + /** + * Fetch previous compaction summaries from the session (unfiltered). + * Respects token budget to avoid overflowing context window. + */ + async function getPreviousSummaries(sessionID: string, limit: number, tokenBudget: number): Promise { + const allMessages = await Session.messages({ sessionID }) + + const summaryMessages = allMessages + .filter( + (m): m is MessageV2.WithParts & { info: MessageV2.Assistant } => + m.info.role === "assistant" && + (m.info as MessageV2.Assistant).summary === true && + (m.info as MessageV2.Assistant).finish !== undefined, + ) + .sort((a, b) => a.info.time.created - b.info.time.created) // oldest first + .slice(-limit) // take the N most recent + + // Include summaries only if they fit within token budget + // Start from most recent (end of array) since those are most relevant + const result: string[] = [] + let tokensUsed = 0 + + for (let i = summaryMessages.length - 1; i >= 0; i--) { + const text = extractSummaryText(summaryMessages[i]) + if (!text.trim()) continue + + const estimate = Token.estimate(text) + if (tokensUsed + estimate > tokenBudget) break + + result.unshift(text) // prepend to maintain chronological order + tokensUsed += estimate + } + + return result + } +} diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ae69221288f8..f85eb2fe1ac5 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,6 +14,7 @@ import { fn } from "@/util/fn" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" import { Config } from "@/config/config" +import { CompactionExtension } from "./compaction-extension" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -28,6 +29,13 @@ export namespace SessionCompaction { } export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + // Use collapse overflow check if method is collapse (uses configurable trigger) + const method = await CompactionExtension.getMethod() + if (method === "collapse") { + return CompactionExtension.isOverflow(input) + } + + // Standard overflow check const config = await Config.get() if (config.compaction?.auto === false) return false const context = input.model.limit.context @@ -95,7 +103,17 @@ export namespace SessionCompaction { sessionID: string abort: AbortSignal auto: boolean - }) { + }): Promise<"continue" | "stop"> { + // Route to collapse compaction if configured + const method = await CompactionExtension.getMethod() + log.info("compacting", { method }) + if (method === "collapse") { + const result = await CompactionExtension.process(input) + Bus.publish(Event.Compacted, { sessionID: input.sessionID }) + return result + } + + // Standard compaction const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User const agent = await Agent.get("compaction") const model = agent.model diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d326976f1aef..8284c9f7bfea 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -11,8 +11,10 @@ import { ProviderTransform } from "@/provider/transform" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import { type SystemError } from "bun" +import { Log } from "../util/log" export namespace MessageV2 { + const log = Log.create({ service: "message-v2" }) export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const AuthError = NamedError.create( @@ -604,17 +606,28 @@ export namespace MessageV2 { export async function filterCompacted(stream: AsyncIterable) { const result = [] as MessageV2.WithParts[] const completed = new Set() + for await (const msg of stream) { + const hasCompactionPart = msg.parts.some((part) => part.type === "compaction") + const isAssistantSummary = + msg.info.role === "assistant" && (msg.info as Assistant).summary && (msg.info as Assistant).finish + result.push(msg) - if ( - msg.info.role === "user" && - completed.has(msg.info.id) && - msg.parts.some((part) => part.type === "compaction") - ) + + // Check if this is a compaction breakpoint + if (msg.info.role === "user" && completed.has(msg.info.id) && hasCompactionPart) { + log.debug("breakpoint", { id: msg.info.id }) break - if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish) completed.add(msg.info.parentID) + } + + // If assistant with summary=true and finish, add parentID to completed set + if (isAssistantSummary) { + completed.add((msg.info as Assistant).parentID) + } } + result.reverse() + log.debug("filtered", { count: result.length }) return result } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e47c4f5f7f17..24d9b2ad6469 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1746,6 +1746,30 @@ export type Config = { * Enable pruning of old tool outputs (default: true) */ prune?: boolean + /** + * Compaction method: 'standard' summarizes entire conversation, 'collapse' extracts oldest messages and creates summary at breakpoint (default: standard) + */ + method?: "standard" | "collapse" + /** + * Trigger compaction at this fraction of total context (default: 0.85 = 85%) + */ + trigger?: number + /** + * For collapse mode: fraction of oldest tokens to extract and summarize (default: 0.65) + */ + extractRatio?: number + /** + * For collapse mode: fraction of newest tokens to use as reference context (default: 0.15) + */ + recentRatio?: number + /** + * For collapse mode: target token count for the summary output (default: 10000) + */ + summaryMaxTokens?: number + /** + * For collapse mode: number of previous summaries to include for context merging (default: 3) + */ + previousSummaries?: number } experimental?: { hook?: { From 7a421ce0250ef12a8ffe850d3deccc8fa518703b Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 17 Jan 2026 13:07:17 -0700 Subject: [PATCH 14/14] Fix collapse compaction infinite loop by skipping trigger creation Adds insertTriggers config option to allow compaction methods to control whether they create trigger messages. Standard compaction needs triggers (default: true), but collapse compaction creates its own breakpoint messages and should skip triggers (default: false). This fixes the infinite loop bug where collapse compaction would: 1. Insert breakpoint at timestamp T 2. Create trigger at same timestamp T 3. filterCompacted() would stop at breakpoint 4. Exclude messages after breakpoint with updated token counts 5. Loop would see old token count and trigger again With this fix, collapse compaction directly calls process() without creating triggers, preventing timestamp collisions and ensuring the loop sees the correct post-compaction token counts. Resolves: Infinite compaction loop in ses_43534dcd4ffedAJydNtU3YLQ9S --- packages/opencode/src/config/config.ts | 6 +++++ packages/opencode/src/session/prompt.ts | 33 +++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 735cfe115034..ccb5b9b6ea2a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1058,6 +1058,12 @@ export namespace Config { .max(10) .optional() .describe("For collapse mode: number of previous summaries to include for context merging (default: 3)"), + insertTriggers: z + .boolean() + .optional() + .describe( + "Whether to insert compaction trigger messages in the stream. Standard compaction needs triggers (default: true), collapse compaction does not (default: false)", + ), }) .optional(), experimental: z diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8327698fd5fc..11f6b9c1b76b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -11,6 +11,7 @@ import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai" import { SessionCompaction } from "./compaction" +import { Config } from "../config/config" import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" @@ -498,13 +499,31 @@ export namespace SessionPrompt { lastFinished.summary !== true && (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) ) { - await SessionCompaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - }) - continue + const config = await Config.get() + const method = config.compaction?.method ?? "standard" + const insertTriggers = config.compaction?.insertTriggers ?? method === "standard" + + if (insertTriggers) { + // Standard compaction: create trigger message, loop will process it + await SessionCompaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + }) + continue + } else { + // Collapse compaction: directly call process without trigger + const result = await SessionCompaction.process({ + messages: msgs, + parentID: lastUser.id, + abort, + sessionID, + auto: true, + }) + if (result === "stop") break + continue + } } // normal processing