Compare commits

...

22 Commits

Author SHA1 Message Date
opencode-agent[bot] 0a8f14f5e7 Fix beta integration 2026-08-16 09:12:14 +00:00
opencode-agent[bot] 08dd3f51ed fix(app): space no git status (#42868)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-16 08:14:58 +00:00
Dax Raad 0e99cb987a fix(desktop): use built CLI during beta build 2026-08-16 03:20:15 -04:00
opencode-agent[bot] 7731d1235d fix(cli): clarify debug config output (#42860)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-16 02:39:47 -04:00
opencode-agent[bot] 174c0a742c chore: generate 2026-08-16 06:39:30 +00:00
Dax b080f216a5 feat(cli): add native CPU profiling (#42862) 2026-08-16 02:38:23 -04:00
Simon Klee 0d7fe6e074 fix(cli): omit undefined compile executable path 2026-08-16 08:03:11 +02:00
opencode-agent[bot] 01b7b53eeb chore: generate 2026-08-16 05:32:10 +00:00
Dax Raad 613c570a3b feat(cli): add heap snapshot signal 2026-08-16 01:30:44 -04:00
Dax Raad a48d44955e fix(cli): suppress compiled runtime warnings 2026-08-16 01:03:25 -04:00
Dax Raad 8251934007 fix(core): batch initial streamed delta 2026-08-15 19:26:16 -04:00
Dax Raad 42e345e1bc fix(cli): resolve Bun canary compile assets 2026-08-15 18:56:03 -04:00
Dax 6a7d6c5adc refactor(core): use numeric event timestamps (#42828) 2026-08-15 18:48:58 -04:00
Dax Raad 9e22c40fff feat: align V2 beta release channels 2026-08-15 18:40:23 -04:00
opencode-agent[bot] 467722c2f9 fix(app): use tree directory picker everywhere (#42820)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-16 08:19:44 +10:00
Luke Parker d9dfceddf8 fix(app): release virtualized timeline elements (#42825) 2026-08-16 08:19:06 +10:00
Dax 4fee4d7d86 fix(core): batch streamed session deltas 2026-08-15 18:16:01 -04:00
Luke Parker 61d7f942b5 fix(app): show new session header immediately (#42822) 2026-08-16 08:04:28 +10:00
Dax Raad d8e1753330 chore(cli): build binaries with Bun 1.4 2026-08-15 17:51:09 -04:00
opencode-agent[bot] d94d520f45 fix(tui): make running subagents clickable (#42797)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 19:54:40 +00:00
Dax Raad fcc1e9c42f docs(core): add MCP setup guidance 2026-08-15 15:13:28 -04:00
opencode-agent[bot] 64b9ba339e docs(console): prohibit abusive multi-account use (#42813)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 15:08:52 -04:00
61 changed files with 753 additions and 952 deletions
+10 -3
View File
@@ -1,6 +1,10 @@
name: "Setup Bun" name: "Setup Bun"
description: "Setup Bun with caching and install dependencies" description: "Setup Bun with caching and install dependencies"
inputs: inputs:
bun-version:
description: "Bun version to install instead of the root packageManager version"
required: false
default: ""
install-flags: install-flags:
description: "Additional flags to pass to 'bun install'" description: "Additional flags to pass to 'bun install'"
required: false required: false
@@ -20,19 +24,22 @@ runs:
shell: bash shell: bash
run: | run: |
if [ "$RUNNER_ARCH" = "X64" ]; then if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]") V="${{ inputs.bun-version }}"
if [ -z "$V" ]; then V=$(node -p "require('./package.json').packageManager.split('@')[1]"); fi
TAG=$([ "$V" = "canary" ] && echo "canary" || echo "bun-v${V}")
case "$RUNNER_OS" in case "$RUNNER_OS" in
macOS) OS=darwin ;; macOS) OS=darwin ;;
Linux) OS=linux ;; Linux) OS=linux ;;
Windows) OS=windows ;; Windows) OS=windows ;;
esac esac
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT" echo "url=https://github.com/oven-sh/bun/releases/download/${TAG}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi fi
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-version: ${{ !steps.bun-url.outputs.url && inputs.bun-version || '' }}
bun-version-file: ${{ !steps.bun-url.outputs.url && !inputs.bun-version && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }} bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory - name: Get cache directory
-37
View File
@@ -1,37 +0,0 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
jobs:
sync:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Git Committer
id: setup-git-committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch
env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts
+23 -5
View File
@@ -7,6 +7,7 @@ on:
- ci - ci
- dev - dev
- beta - beta
- v2
- fix/npm-native-binary-install - fix/npm-native-binary-install
- snapshot-* - snapshot-*
workflow_dispatch: workflow_dispatch:
@@ -32,7 +33,7 @@ permissions:
packages: write packages: write
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
jobs: jobs:
version: version:
@@ -45,6 +46,13 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2' || github.ref_name == 'beta'
working-directory: packages/updates
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Setup git committer - name: Setup git committer
id: committer id: committer
uses: ./.github/actions/setup-git-committer uses: ./.github/actions/setup-git-committer
@@ -74,13 +82,15 @@ jobs:
build-cli: build-cli:
needs: version needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with: with:
fetch-tags: true fetch-tags: true
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
with:
bun-version: canary # Bun 1.4 until its stable release is published
- name: Setup git committer - name: Setup git committer
id: committer id: committer
@@ -102,6 +112,7 @@ jobs:
id: build id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env: env:
BUN_COMPILE_RELEASE: canary
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }} GH_REPO: ${{ needs.version.outputs.repo }}
@@ -185,7 +196,7 @@ jobs:
build-node-cli: build-node-cli:
needs: version needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -335,6 +346,7 @@ jobs:
build-electron: build-electron:
needs: needs:
- version - version
- sign-cli-macos
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false continue-on-error: false
env: env:
@@ -373,6 +385,12 @@ jobs:
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 - uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
if: runner.os == 'macOS' if: runner.os == 'macOS'
with: with:
@@ -431,6 +449,7 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }} OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
- name: Build - name: Build
run: bun run build run: bun run build
@@ -447,6 +466,7 @@ jobs:
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }} OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Package - name: Package
if: needs.version.outputs.release if: needs.version.outputs.release
@@ -569,13 +589,11 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with: with:
name: opencode-preview-cli name: opencode-preview-cli
path: packages/cli/dist path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with: with:
pattern: opencode-node-cli-* pattern: opencode-node-cli-*
path: packages/cli/dist/node path: packages/cli/dist/node
+1 -1
View File
@@ -9,7 +9,7 @@
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions. - Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`. - Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client. - The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it. - Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories ## V2 TUI Stories
+3 -3
View File
@@ -15,13 +15,13 @@ Usage: install.sh [options]
Options: Options:
-h, --help Display this help message -h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 0.0.0-next-17236) -v, --version <version> Install a specific version (e.g., 0.0.0-beta-17236)
-b, --binary <path> Install from a local binary instead of downloading -b, --binary <path> Install from a local binary instead of downloading
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples: Examples:
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-next-17236 curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-beta-17236
./install --binary /path/to/opencode2 ./install --binary /path/to/opencode2
EOF EOF
} }
@@ -166,7 +166,7 @@ else
fi fi
if [ -z "$requested_version" ]; then if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/next || true) metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/beta || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
if [ -z "$specific_version" ]; then if [ -z "$specific_version" ]; then
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14", "packageManager": "bun@1.3.14",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"", "dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -1,60 +0,0 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
@@ -72,7 +72,12 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]') const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject) await expectAppVisible(addProject)
await addProject.click() await addProject.click()
await page.locator("[data-directory-path]").click() const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await page.locator('[data-action="home-new-session"]').click() await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
@@ -1,205 +0,0 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types"
interface DialogSelectDirectoryProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
}
const RECENT_PROJECT_LIMIT = 5
type Row = {
absolute: string
search: string
group: "recent" | "folders"
}
function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = displayPickerPath(absolute, "", "")
const tilde = displayPickerPath(full, "~", home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search, group }
}
function uniqueRows(rows: Row[]) {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.absolute)) return false
seen.add(row.absolute)
return true
})
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = createDirectorySearch({
sdk,
home,
base: start,
})
const recentProjects = createMemo(() => {
const projects = serverCtx.projects.list()
const byProject = new Map<string, number>()
for (const project of projects) {
let at = 0
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
const sessions = sync.child(directory, { bootstrap: false })[0].session
for (const session of sessions) {
if (session.time.archived) continue
const updated = session.time.updated ?? session.time.created
if (updated > at) at = updated
}
}
byProject.set(project.worktree, at)
}
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
return {
...row,
search: `${row.search}\n${name}`,
}
})
})
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
}
function resolve(absolute: string) {
props.onSelect(props.multiple ? [absolute] : absolute)
dialog.close()
}
return (
<Dialog title={props.title ?? language.t("command.project.open")}>
<List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
groupBy={(item) => item.group}
sortGroupsBy={(a, b) => {
if (a.category === b.category) return 0
return a.category === "recent" ? -1 : 1
}}
groupHeader={(group) =>
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanPickerInput(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return
e.preventDefault()
e.stopPropagation()
const value = displayPickerPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
if (!path) return
resolve(path.absolute)
}}
>
{(item) => {
const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}}
</List>
</Dialog>
)
}
@@ -1,9 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/servers"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js" import { lazy } from "solid-js"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy" import { directoryPickerKind } from "./directory-picker-policy"
const DialogSelectDirectoryV2 = lazy(() => const DialogSelectDirectoryV2 = lazy(() =>
@@ -19,7 +17,6 @@ type DirectoryPickerInput = {
export function useDirectoryPicker() { export function useDirectoryPicker() {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const dialog = useDialog() const dialog = useDialog()
return (input: DirectoryPickerInput) => { return (input: DirectoryPickerInput) => {
@@ -36,10 +33,6 @@ export function useDirectoryPicker() {
const cancel = () => { const cancel = () => {
if (!selected) input.onSelect(null) if (!selected) input.onSelect(null)
} }
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel) dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
} }
} }
@@ -60,7 +60,11 @@ export function NewSessionView(props: {
<Show <Show
when={props.workspace.bar.visible()} when={props.workspace.bar.visible()}
fallback={ fallback={
<PromptGitStatus branch={props.workspace.bar.branch()} noGit={!props.workspace.project.git()} /> <PromptGitStatus
branch={props.workspace.bar.branch()}
noGit={!props.workspace.project.git()}
class="ms-1"
/>
} }
> >
<PromptWorkspaceSelector <PromptWorkspaceSelector
@@ -94,7 +94,7 @@ export function createTimelineController(input: {
fallback: language.t("command.session.new"), fallback: language.t("command.session.new"),
}) })
}) })
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID())) const showHeader = createMemo(() => !!input.session.identity.sessionID())
const projection = createTimelineProjection({ const projection = createTimelineProjection({
messages: input.session.history.messages, messages: input.session.history.messages,
userMessages: input.userMessages, userMessages: input.userMessages,
@@ -1203,6 +1203,8 @@ function MessageTimelineView(
onCleanup(() => { onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame) if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
// Solid runs cleanup before it disconnects the row, so defer TanStack's null-ref cleanup.
queueMicrotask(() => virtualizer.measureElement(null))
}) })
return ( return (
+2 -1
View File
@@ -5,7 +5,8 @@ const fs = require("fs")
const path = require("path") const path = require("path")
const os = require("os") const os = require("os")
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] const forwardedSignals =
process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGHUP"] : ["SIGINT", "SIGTERM", "SIGHUP", "SIGUSR1"]
function run(target) { function run(target) {
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" }) const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
+1 -1
View File
@@ -41,8 +41,8 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3", "uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0" "ws": "8.21.0"
}, },
"devDependencies": { "devDependencies": {
+71 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun" import { $ } from "bun"
import { rm } from "fs/promises" import { mkdir, rm } from "fs/promises"
import path from "path" import path from "path"
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
@@ -27,6 +27,7 @@ const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?
const skipInstall = process.argv.includes("--skip-install") const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui") const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin() const solidPlugin = createSolidTransformPlugin()
const releaseAssets = new Map<string, Promise<Map<string, string>>>()
const allTargets: { const allTargets: {
os: string os: string
@@ -99,6 +100,7 @@ for (const item of targets) {
} }
const target = targetName(item) const target = targetName(item)
const name = target.replace(binary, "cli") const name = target.replace(binary, "cli")
const executablePath = await compileExecutable(item)
console.log(`building ${name}`) console.log(`building ${name}`)
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/index.ts"], entrypoints: ["./src/index.ts"],
@@ -115,8 +117,9 @@ for (const item of targets) {
autoloadTsconfig: true, autoloadTsconfig: true,
autoloadPackageJson: true, autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget, target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary), outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"], execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
windows: {}, windows: {},
}, },
define: { define: {
@@ -154,6 +157,72 @@ for (const item of targets) {
await verifyArtifact(path.join(outdir, name)) await verifyArtifact(path.join(outdir, name))
} }
async function compileExecutable(item: (typeof allTargets)[number]) {
const release = process.env.BUN_COMPILE_RELEASE
if (!release) return
const platform = item.os === "win32" ? "windows" : item.os
const name = [
"bun",
platform,
item.arch === "arm64" ? "aarch64" : item.arch,
item.abi,
item.avx2 === false ? "baseline" : undefined,
]
.filter(Boolean)
.join("-")
const cache = path.join(outdir, ".bun", release)
const executable = path.join(cache, name, item.os === "win32" ? "bun.exe" : "bun")
if (await Bun.file(executable).exists()) return executable
await mkdir(cache, { recursive: true })
const archive = path.join(cache, `${name}.zip`)
const assets = await compileReleaseAssets(release)
const url = assets.get(`${name}.zip`)
if (!url) throw new Error(`Bun release ${release} does not include ${name}.zip`)
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
const response = await fetch(url, {
headers: { Accept: "application/octet-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
})
if (!response.ok) throw new Error(`Failed to download ${name} from Bun release ${release}: ${response.status}`)
await Bun.write(archive, response)
await $`unzip -oq ${archive} -d ${cache}`
await rm(archive)
return executable
}
function compileReleaseAssets(release: string) {
const existing = releaseAssets.get(release)
if (existing) return existing
const pending = fetch(`https://api.github.com/repos/oven-sh/bun/releases/tags/${release}?cache=${Date.now()}`)
.then(async (response) => {
if (!response.ok) throw new Error(`Failed to resolve Bun release ${release}: ${response.status}`)
const data: unknown = await response.json()
if (typeof data !== "object" || data === null || !("assets" in data) || !Array.isArray(data.assets)) {
throw new Error(`Bun release ${release} returned invalid metadata`)
}
return new Map(
data.assets
.filter(
(asset): asset is { name: string; url: string } =>
typeof asset === "object" &&
asset !== null &&
"name" in asset &&
typeof asset.name === "string" &&
"url" in asset &&
typeof asset.url === "string",
)
.map((asset) => [asset.name, asset.url]),
)
})
.catch((error) => {
releaseAssets.delete(release)
throw error
})
releaseAssets.set(release, pending)
return pending
}
function targetName(item: (typeof allTargets)[number]) { function targetName(item: (typeof allTargets)[number]) {
return [ return [
binary, binary,
+5 -1
View File
@@ -14,10 +14,14 @@ async function published(name: string, version: string) {
async function publish(dir: string, name: string, version: string) { async function publish(dir: string, name: string, version: string) {
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
if (await published(name, version)) return console.log(`already published ${name}@${version}`) const exists = await published(name, version)
if (exists) console.log(`already published ${name}@${version}`)
if (!exists) {
await $`bun pm pack`.cwd(dir) await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
} }
if (Script.channel === "beta") await $`npm dist-tag add ${`${name}@${version}`} next`
}
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) { async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
const binaries: Record<string, string> = {} const binaries: Record<string, string> = {}
+6 -3
View File
@@ -1,5 +1,6 @@
import { Argument, Flag } from "effect/unstable/cli" import { Argument, Command, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec" import { Spec } from "../framework/spec"
import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined declare const OPENCODE_CLI_NAME: string | undefined
@@ -26,7 +27,7 @@ const PermissionParams = {
), ),
} }
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface", description: "OpenCode 2.0 preview command line interface",
params: { params: {
...ServerParams, ...ServerParams,
@@ -70,7 +71,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Debugging and troubleshooting tools", description: "Debugging and troubleshooting tools",
commands: [ commands: [
Spec.make("agents", { description: "List all agents" }), Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "Show resolved configuration" }), Spec.make("config", { description: "List configuration sources" }),
], ],
}), }),
Spec.make("console", { Spec.make("console", {
@@ -277,3 +278,5 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}), }),
], ],
}) })
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
+12
View File
@@ -0,0 +1,12 @@
export * as GlobalFlags from "./global-flags"
import { Flag, GlobalFlag } from "effect/unstable/cli"
export const CpuProfile = GlobalFlag.setting("cpu-profile")({
flag: Flag.string("cpu-profile").pipe(
Flag.withDescription("Write a CPU profile to this path when the process stops"),
Flag.optional,
),
})
export const all = [CpuProfile] as const
+45
View File
@@ -0,0 +1,45 @@
export * as CpuProfile from "./cpu-profile"
import { Effect, FileSystem } from "effect"
import { Session } from "node:inspector"
import path from "node:path"
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
const session = new Session()
session.connect()
yield* command(session, "Profiler.enable")
yield* command(session, "Profiler.start")
yield* Effect.logInfo("CPU profile started", { path: target })
return session
}),
() => effect,
(session) =>
Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
session.post("Profiler.stop", (error, result) => {
session.disconnect()
if (error) return reject(error)
Bun.write(target, JSON.stringify(result.profile)).then(() => resolve(), reject)
})
}),
).pipe(
Effect.andThen(Effect.logInfo("CPU profile written", { path: target })),
Effect.catchCause((cause) => Effect.logError("Failed to write CPU profile", { path: target, cause })),
),
)
}
function command(session: Session, method: "Profiler.enable" | "Profiler.start") {
return Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
session.post(method, (error) => (error ? reject(error) : resolve()))
}),
)
}
+20 -2
View File
@@ -1,10 +1,13 @@
import { Effect, FileSystem, Scope } from "effect" import { Effect, FileSystem, Option, Scope } from "effect"
import { Command } from "effect/unstable/cli" import { Command } from "effect/unstable/cli"
import { Spec } from "./spec" import { Spec } from "./spec"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater" import { Updater } from "../services/updater"
import { Config } from "../config" import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm" import { Npm } from "@opencode-ai/util/npm"
import { GlobalFlags } from "../commands/global-flags"
import { CpuProfile } from "../cpu-profile"
import path from "node:path"
export type Input<Value> = export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands> Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -86,7 +89,22 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
? node.spec.pipe( ? node.spec.pipe(
Command.withHandler((input) => Command.withHandler((input) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input)) const module = yield* Effect.promise(handler.load)
const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
if (!cpuProfile) return yield* module.default(input)
const target = path.resolve(cpuProfile)
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = target
return yield* (
node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)
).pipe(
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}),
),
)
}), }),
), ),
) )
+37
View File
@@ -0,0 +1,37 @@
import { Global } from "@opencode-ai/util/global"
import { Effect, Queue } from "effect"
import path from "node:path"
export const listen = Effect.gen(function* () {
const global = yield* Global.Service
if (process.platform === "win32") return
const signals = yield* Queue.dropping<void>(1)
yield* Effect.acquireRelease(
Effect.sync(() => {
const handler = () => Queue.offerUnsafe(signals, undefined)
process.on("SIGUSR1", handler)
return handler
}),
(handler) => Effect.sync(() => process.off("SIGUSR1", handler)),
)
yield* Queue.take(signals).pipe(
Effect.andThen(
Effect.suspend(() => {
const file = path.join(
global.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
)
return Effect.gen(function* () {
yield* Effect.logInfo("writing heap snapshot", { path: file })
const { writeHeapSnapshot } = yield* Effect.tryPromise(() => import("node:v8"))
yield* Effect.try(() => writeHeapSnapshot(file))
yield* Effect.logInfo("heap snapshot written", { path: file })
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to write heap snapshot", { path: file, cause })))
}),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
})
export * as Heap from "./heap"
+6 -2
View File
@@ -12,6 +12,7 @@ import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config" import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm" import { Npm } from "@opencode-ai/util/npm"
import { Heap } from "./heap"
const Handlers = Runtime.handlers(Commands, { const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"), $: () => import("./commands/handlers/default"),
@@ -54,13 +55,16 @@ const Handlers = Runtime.handlers(Commands, {
serve: () => import("./commands/handlers/serve"), serve: () => import("./commands/handlers/serve"),
}) })
Effect.logInfo("cli starting", { Effect.gen(function* () {
yield* Heap.listen
yield* Effect.logInfo("cli starting", {
version: OPENCODE_VERSION, version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL, channel: OPENCODE_CHANNEL,
local: OPENCODE_LOCAL, local: OPENCODE_LOCAL,
args: process.argv.slice(2), args: process.argv.slice(2),
})
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
}).pipe( }).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer), Effect.provide(Config.layer),
Effect.provide(Updater.layer), Effect.provide(Updater.layer),
+1 -1
View File
@@ -84,7 +84,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: { database: {
path: path:
process.env.OPENCODE_DB ?? process.env.OPENCODE_DB ??
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) || (["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db" ? "opencode.db"
+8 -3
View File
@@ -25,12 +25,12 @@ const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = OPENCODE_CHANNEL) { export function filename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return "service.json" if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json` return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
} }
export function defaultPort(channel = OPENCODE_CHANNEL) { export function defaultPort(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return 0xc0de if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000) return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
} }
@@ -104,7 +104,12 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return { return {
file, file,
version: input.checkVersion ? OPENCODE_VERSION : undefined, version: input.checkVersion ? OPENCODE_VERSION : undefined,
command: [...selfCommand(), "serve", "--service"], command: [
...selfCommand(),
"serve",
"--service",
...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
],
} }
}) })
+2 -1
View File
@@ -35,7 +35,8 @@ describe("updater", () => {
test("accepts strict release version variants", () => { test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade") expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade") expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade") expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none") expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none") expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
}) })
+2 -1
View File
@@ -10,9 +10,10 @@ describe("debug config command", () => {
expect(debug.exitCode).toBe(0) expect(debug.exitCode).toBe(0)
expect(debug.stdout).toContain("config") expect(debug.stdout).toContain("config")
expect(debug.stdout).toContain("Show resolved configuration") expect(debug.stdout).toContain("List configuration sources")
expect(config.exitCode).toBe(0) expect(config.exitCode).toBe(0)
expect(config.stdout).toContain("opencode debug config [flags]") expect(config.stdout).toContain("opencode debug config [flags]")
expect(config.stdout).toContain("List configuration sources")
}) })
test("prints config entries from the invoking directory without reordering permissions", async () => { test("prints config entries from the invoking directory without reordering permissions", async () => {
+27
View File
@@ -11,12 +11,37 @@ import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => { test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de) expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("dev")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("beta")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de) expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df) expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a")) expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b")) expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
}) })
test("managed service forwards the CPU profile path to the server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
const profile = path.join(root, "server.cpuprofile")
try {
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = profile
try {
const options = await Effect.runPromise(
ServiceConfig.options().pipe(
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
} finally {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => { test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try { try {
@@ -37,6 +62,8 @@ test("local channel stores service config with the local service filename", asyn
test("service filenames share release channels and identify preview channels", () => { test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json") expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("dev")).toBe("service.json")
expect(ServiceConfig.filename("beta")).toBe("service.json")
expect(ServiceConfig.filename("next")).toBe("service.json") expect(ServiceConfig.filename("next")).toBe("service.json")
expect(ServiceConfig.filename("local")).toBe("service-local.json") expect(ServiceConfig.filename("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json") expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
+40 -41
View File
@@ -20,7 +20,6 @@ import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import type { Schema } from "effect" import type { Schema } from "effect"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
import type { Shell } from "@opencode-ai/schema/shell" import type { Shell } from "@opencode-ai/schema/shell"
import type { DateTime } from "effect"
import type { Provider } from "@opencode-ai/schema/provider" import type { Provider } from "@opencode-ai/schema/provider"
import type { Integration } from "@opencode-ai/schema/integration" import type { Integration } from "@opencode-ai/schema/integration"
import type { Form } from "@opencode-ai/schema/form" import type { Form } from "@opencode-ai/schema/form"
@@ -316,7 +315,7 @@ export type Endpoint5_31Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.created" readonly type: "session.created"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -336,7 +335,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.agent.selected" readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -349,7 +348,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.model.selected" readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -362,7 +361,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.moved" readonly type: "session.moved"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -376,7 +375,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.renamed" readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -385,7 +384,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.deleted" readonly type: "session.deleted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -394,7 +393,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.forked" readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -410,7 +409,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivered" readonly type: "session.inbox.delivered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -419,7 +418,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.enqueued" readonly type: "session.inbox.enqueued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -432,7 +431,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.cancelled" readonly type: "session.inbox.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -441,7 +440,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivery.changed" readonly type: "session.inbox.delivery.changed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -454,7 +453,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.started" readonly type: "session.execution.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -463,7 +462,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.succeeded" readonly type: "session.execution.succeeded"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -472,7 +471,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.failed" readonly type: "session.execution.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -484,7 +483,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.interrupted" readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -493,7 +492,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.instructions.updated" readonly type: "session.instructions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -506,7 +505,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.synthetic" readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -520,7 +519,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.skill.activated" readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -534,7 +533,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.shell.started" readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -543,7 +542,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.shell.ended" readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -561,7 +560,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.started" readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -576,7 +575,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.ended" readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -598,7 +597,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.failed" readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -622,7 +621,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.started" readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -635,7 +634,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.ended" readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -650,7 +649,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.started" readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -664,7 +663,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.ended" readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -679,7 +678,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.started" readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -693,7 +692,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.ended" readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -707,7 +706,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.called" readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -723,7 +722,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.success" readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -759,7 +758,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.failed" readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -798,7 +797,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.retry.scheduled" readonly type: "session.retry.scheduled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -813,7 +812,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.started" readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -827,7 +826,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.ended" readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -841,7 +840,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.failed" readonly type: "session.compaction.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -855,7 +854,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.staged" readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -864,7 +863,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.cleared" readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -873,7 +872,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.committed" readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -882,7 +881,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.usage.recorded" readonly type: "session.usage.recorded"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -21,7 +21,7 @@ export default function TermsOfService() {
<section data-component="brand-content"> <section data-component="brand-content">
<article data-component="terms-of-service"> <article data-component="terms-of-service">
<h1>Terms of Use</h1> <h1>Terms of Use</h1>
<p class="effective-date">Effective date: Mar 6, 2026</p> <p class="effective-date">Effective date: Aug 15, 2026</p>
<p> <p>
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
@@ -154,6 +154,11 @@ export default function TermsOfService() {
is dangerous, harmful, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or is dangerous, harmful, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or
otherwise objectionable; otherwise objectionable;
</li> </li>
<li>
creates, maintains, or uses accounts in bulk, or creates, maintains, or uses multiple accounts to
circumvent usage limits, access restrictions, billing obligations, promotions, suspensions, or any
other restriction or policy applicable to the Services;
</li>
<li>automatically or programmatically extracts data or Output (defined below);</li> <li>automatically or programmatically extracts data or Output (defined below);</li>
<li>Represent that the Output was human-generated when it was not;</li> <li>Represent that the Output was human-generated when it was not;</li>
<li> <li>
+10 -10
View File
@@ -1,6 +1,6 @@
export * as Bus from "./bus.js" export * as Bus from "./bus.js"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, lte, sql } from "drizzle-orm" import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
@@ -47,7 +47,7 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
export type SerializedEvent = { export type SerializedEvent = {
readonly id: Event.ID readonly id: Event.ID
readonly type: string readonly type: string
readonly created?: DateTime.Utc readonly created?: number
readonly seq: number readonly seq: number
readonly aggregateID: string readonly aggregateID: string
readonly data: Record<string, unknown> readonly data: Record<string, unknown>
@@ -74,7 +74,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Event.Payload => {
} }
return { return {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0), created: event.created ?? 0,
type: definition.type, type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version), durable: envelope(event.aggregateID, event.seq, definition.durable.version),
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
@@ -283,7 +283,7 @@ export function configured(options?: Options) {
if ( if (
stored?.id === event.id && stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) && stored.type === versionedType(definition.type, durable.version) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) && stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded) isDeepStrictEqual(stored.data, encoded)
) { ) {
if (input.ownerID && row?.ownerID == null) { if (input.ownerID && row?.ownerID == null) {
@@ -358,7 +358,7 @@ export function configured(options?: Options) {
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)), created: event.created ?? 0,
type: versionedType(definition.type, durable.version), type: versionedType(definition.type, durable.version),
data: encoded, data: encoded,
}, },
@@ -455,7 +455,7 @@ export function configured(options?: Options) {
definition, definition,
{ {
id: options?.id ?? Event.ID.create(), id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now, created: yield* Clock.currentTimeMillis,
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(location ? { location } : {}), ...(location ? { location } : {}),
@@ -491,7 +491,7 @@ export function configured(options?: Options) {
commit: options?.commit, commit: options?.commit,
event: { event: {
id: options?.id ?? Event.ID.create(), id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now, created: yield* Clock.currentTimeMillis,
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(location ? { location } : {}), ...(location ? { location } : {}),
@@ -571,7 +571,7 @@ export function configured(options?: Options) {
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
created: DateTime.toEpochMillis(event.created), created: event.created,
type: versionedType(item.definition.type, item.definition.durable.version), type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded, data: encoded,
}) })
@@ -619,7 +619,7 @@ export function configured(options?: Options) {
Effect.gen(function* () { Effect.gen(function* () {
const payload = { const payload = {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0), created: event.created ?? 0,
type: definition.type, type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload } as Event.Payload
@@ -733,7 +733,7 @@ export function configured(options?: Options) {
return [ return [
decodeSerializedEvent({ decodeSerializedEvent({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -63,6 +63,33 @@ examples, but do not fetch it to determine the V2 configuration shape.
See the [full configuration guide](https://opencode.ai/v2/docs/config) for See the [full configuration guide](https://opencode.ai/v2/docs/config) for
every field, examples, config locations, and links to dedicated feature guides. every field, examples, config locations, and links to dedicated feature guides.
## [MCP servers](https://opencode.ai/v2/docs/mcp-servers)
Configure MCP servers under `mcp.servers`. Prefer the CLI because it preserves
unrelated configuration. Use `--global` when the user asks to set up a service
for themselves without limiting it to the current project; omit it when they
explicitly want project-local configuration.
```sh
opencode2 mcp add <name> --global --url <remote-url>
opencode2 mcp list
```
Remote servers use OAuth by default. If `mcp list` reports that a server needs
authentication, run the OAuth flow and then verify the connection:
```sh
opencode2 mcp auth <name>
opencode2 mcp list
```
The auth command prints an authorization URL, waits for the browser redirect,
and stores credentials outside the OpenCode configuration. Do not ask for or
store an API key when the server supports OAuth. Use header-based credentials
only when OAuth is unavailable or the user explicitly requires them, and use an
environment substitution such as `{env:MCP_API_KEY}` instead of writing a
secret into configuration.
## [V1 to V2 migration](https://opencode.ai/v2/docs/migrate-v1) ## [V1 to V2 migration](https://opencode.ai/v2/docs/migrate-v1)
For any request to migrate OpenCode configuration, agents, commands, skills, For any request to migrate OpenCode configuration, agents, commands, skills,
+3 -3
View File
@@ -161,7 +161,7 @@ export const admit = Effect.fn("SessionInbox.admit")(function* (
const base = { const base = {
id: request.id, id: request.id,
sessionID: request.sessionID, sessionID: request.sessionID,
timeCreated: event.created, timeCreated: DateTime.makeUnsafe(event.created),
} }
return Effect.succeed(Info.make({ ...base, ...request.item })) return Effect.succeed(Info.make({ ...base, ...request.item }))
}), }),
@@ -196,7 +196,7 @@ export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(functio
readonly id: SessionMessage.ID readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly item: Item readonly item: Item
readonly timeCreated: DateTime.Utc readonly timeCreated: number
}, },
) { ) {
const message = yield* db const message = yield* db
@@ -222,7 +222,7 @@ export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(functio
: encodeMove(request.item.payload), : encodeMove(request.item.payload),
delivery: request.item.delivery, delivery: request.item.delivery,
enqueued_seq: request.enqueuedSeq, enqueued_seq: request.enqueuedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated), time_created: request.timeCreated,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning({ id: SessionInboxTable.id }) .returning({ id: SessionInboxTable.id })
+22 -21
View File
@@ -26,6 +26,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type DraftTool = WritableDraft<SessionMessage.AssistantTool> type DraftTool = WritableDraft<SessionMessage.AssistantTool>
type DraftText = WritableDraft<SessionMessage.AssistantText> type DraftText = WritableDraft<SessionMessage.AssistantText>
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning> type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const created = DateTime.makeUnsafe(event.created)
const latestTool = (assistant: DraftAssistant | undefined, id?: string) => const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast( assistant?.content.findLast(
@@ -70,7 +71,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata, metadata: event.metadata,
agent: event.data.agent, agent: event.data.agent,
previous, previous,
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -85,7 +86,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata, metadata: event.metadata,
model: event.data.model, model: event.data.model,
previous, previous,
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -101,7 +102,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
projectID: event.data.projectID, projectID: event.data.projectID,
subpath: event.data.subpath, subpath: event.data.subpath,
previous: yield* adapter.getLocation(), previous: yield* adapter.getLocation(),
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -126,7 +127,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
text: event.data.text, text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`, description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata, metadata: event.metadata,
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -138,7 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.data.metadata, metadata: event.data.metadata,
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
type: "synthetic", type: "synthetic",
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -151,7 +152,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
name: event.data.name, name: event.data.name,
text: event.data.text, text: event.data.text,
metadata: event.metadata, metadata: event.metadata,
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -164,7 +165,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
shellID: event.data.shell.id, shellID: event.data.shell.id,
command: event.data.shell.command, command: event.data.shell.command,
status: event.data.shell.status, status: event.data.shell.status,
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -177,7 +178,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.status = event.data.shell.status draft.status = event.data.shell.status
draft.exit = event.data.shell.exit draft.exit = event.data.shell.exit
draft.output = event.data.output draft.output = event.data.output
draft.time.completed = event.created draft.time.completed = created
}), }),
) )
} }
@@ -205,7 +206,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
yield* adapter.updateAssistant( yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => { produce(currentAssistant, (draft) => {
draft.retry = undefined draft.retry = undefined
draft.time.completed = event.created draft.time.completed = created
}), }),
) )
} }
@@ -216,7 +217,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
agent: event.data.agent, agent: event.data.agent,
model: event.data.model, model: event.data.model,
metadata: event.metadata, metadata: event.metadata,
time: { created: event.created }, time: { created },
content: [], content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}), }),
@@ -225,7 +226,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}, },
"session.step.ended": (event) => { "session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created draft.time.completed = created
draft.finish = event.data.finish draft.finish = event.data.finish
draft.cost = event.data.cost draft.cost = event.data.cost
draft.tokens = event.data.tokens draft.tokens = event.data.tokens
@@ -239,7 +240,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}, },
"session.step.failed": (event) => { "session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created draft.time.completed = created
draft.finish = "error" draft.finish = "error"
draft.error = castDraft(event.data.error) draft.error = castDraft(event.data.error)
draft.retry = undefined draft.retry = undefined
@@ -277,7 +278,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "tool", type: "tool",
id: event.data.id, id: event.data.id,
name: event.data.name, name: event.data.name,
time: { created: event.created }, time: { created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }), state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
}), }),
), ),
@@ -296,7 +297,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) { if (match) {
match.executed = event.data.executed match.executed = event.data.executed
match.providerState = event.data.state match.providerState = event.data.state
match.time.ran = event.created match.time.ran = created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateRunning.make({ SessionMessage.ToolStateRunning.make({
status: "running", status: "running",
@@ -315,7 +316,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
match.executed = event.data.executed || match.executed === true match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState match.providerResultState = event.data.resultState
match.time.completed = event.created match.time.completed = created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateCompleted.make({ SessionMessage.ToolStateCompleted.make({
status: "completed", status: "completed",
@@ -333,7 +334,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match && (match.state.status === "streaming" || match.state.status === "running")) { if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState match.providerResultState = event.data.resultState
match.time.completed = event.created match.time.completed = created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateError.make({ SessionMessage.ToolStateError.make({
status: "error", status: "error",
@@ -354,7 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "reasoning", type: "reasoning",
text: "", text: "",
state: event.data.state, state: event.data.state,
time: { created: event.created }, time: { created },
}), }),
), ),
) )
@@ -365,7 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestReasoning(draft) const match = latestReasoning(draft)
if (match) { if (match) {
match.text = event.data.text match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created } match.time = { created: match.time?.created ?? created, completed: created }
if (event.data.state !== undefined) match.state = event.data.state if (event.data.state !== undefined) match.state = event.data.state
} }
}) })
@@ -389,7 +390,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason, reason: event.data.reason,
summary: "", summary: "",
recent: event.data.recent ?? "", recent: event.data.recent ?? "",
time: { created: event.created }, time: { created },
}), }),
), ),
"session.compaction.ended": (event) => { "session.compaction.ended": (event) => {
@@ -414,7 +415,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -429,7 +430,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: current?.metadata ?? event.metadata, metadata: current?.metadata ?? event.metadata,
reason: event.data.reason, reason: event.data.reason,
error: event.data.error, error: event.data.error,
time: current?.time ?? { created: event.created }, time: current?.time ?? { created },
}) })
if (current?.status === "running") return yield* adapter.updateCompaction(failed) if (current?.status === "running") return yield* adapter.updateCompaction(failed)
yield* adapter.appendMessage(failed) yield* adapter.appendMessage(failed)
+14 -14
View File
@@ -162,8 +162,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
tokens_reasoning: 0, tokens_reasoning: 0,
tokens_cache_read: 0, tokens_cache_read: 0,
tokens_cache_write: 0, tokens_cache_write: 0,
time_created: DateTime.toEpochMillis(event.created), time_created: event.created,
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning({ sessionID: SessionTable.id }) .returning({ sessionID: SessionTable.id })
@@ -411,8 +411,8 @@ const layer = Layer.effectDiscard(
agent: event.data.agent, agent: event.data.agent,
model: event.data.model, model: event.data.model,
version: event.data.version, version: event.data.version,
time_created: DateTime.toEpochMillis(event.created), time_created: event.created,
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning({ sessionID: SessionTable.id }) .returning({ sessionID: SessionTable.id })
@@ -431,7 +431,7 @@ const layer = Layer.effectDiscard(
path: event.data.subpath, path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}), ...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null, workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
@@ -487,7 +487,7 @@ const layer = Layer.effectDiscard(
yield* run(db, event) yield* run(db, event)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) }) .set({ agent: event.data.agent, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -498,7 +498,7 @@ const layer = Layer.effectDiscard(
yield* run(db, event) yield* run(db, event)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) }) .set({ model: event.data.model, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -507,7 +507,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.Renamed, (event) => yield* bus.project(SessionEvent.Renamed, (event) =>
db db
.update(SessionTable) .update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.created) }) .set({ title: event.data.title, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie), .pipe(Effect.orDie),
@@ -535,7 +535,7 @@ const layer = Layer.effectDiscard(
files: input.payload.files, files: input.payload.files,
agents: input.payload.agents, agents: input.payload.agents,
skills: input.payload.skills, skills: input.payload.skills,
time: { created: event.created }, time: { created: DateTime.makeUnsafe(event.created) },
} }
: { : {
id: input.id, id: input.id,
@@ -543,7 +543,7 @@ const layer = Layer.effectDiscard(
text: input.payload.text, text: input.payload.text,
description: input.payload.description, description: input.payload.description,
metadata: input.payload.metadata, metadata: input.payload.metadata,
time: { created: event.created }, time: { created: DateTime.makeUnsafe(event.created) },
}, },
) )
}), }),
@@ -561,7 +561,7 @@ const layer = Layer.effectDiscard(
}) })
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) }) .set({ time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -640,7 +640,7 @@ const layer = Layer.effectDiscard(
.update(SessionTable) .update(SessionTable)
.set({ .set({
revert: { ...revert, files: revert.files ? [...revert.files] : undefined }, revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
@@ -650,7 +650,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) => yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) =>
db db
.update(SessionTable) .update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .set({ revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie, Effect.asVoid), .pipe(Effect.orDie, Effect.asVoid),
@@ -685,7 +685,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .set({ revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai" import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect" import { Clock, Effect } from "effect"
import { Bus } from "../../bus.js" import { Bus } from "../../bus.js"
import { Model } from "../../model.js" import { Model } from "../../model.js"
import { SessionEvent } from "../event.js" import { SessionEvent } from "../event.js"
@@ -81,6 +81,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* and consumers fold by id/ordinal rather than global position. * and consumers fold by id/ordinal rather than global position.
*/ */
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => { export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const deltaBatchInterval = 100
const tools = new Map< const tools = new Map<
string, string,
{ {
@@ -123,32 +124,56 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const fragments = ( const fragments = (
name: string, name: string,
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>, ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
delta?: (id: string, value: string, ordinal: number) => Effect.Effect<void>,
single = false, single = false,
) => { ) => {
const chunks = new Map< type Fragment = {
string, readonly ordinal: number
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> } readonly values: string[]
>() pending: string
publishedAt?: number
state?: Record<string, unknown>
}
const chunks = new Map<string, Fragment>()
let nextOrdinal = 0 let nextOrdinal = 0
const start = (id: string, state?: Record<string, unknown>) => const start = (id: string, state?: Record<string, unknown>) =>
Effect.suspend(() => { Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
const ordinal = nextOrdinal++ const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [], state }) chunks.set(id, { ordinal, values: [], pending: "", state })
return Effect.succeed(ordinal) return Effect.succeed(ordinal)
}) })
const append = (id: string, value: string, state?: Record<string, unknown>) => const publishDelta = Effect.fnUntraced(function* (id: string, force = false) {
Effect.suspend(() => { if (!delta) return undefined
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
if (!current.pending) return undefined
const now = yield* Clock.currentTimeMillis
if (!force && current.publishedAt === undefined) {
current.publishedAt = now
return undefined
}
if (!force && current.publishedAt !== undefined && now - current.publishedAt < deltaBatchInterval)
return undefined
yield* delta(id, current.pending, current.ordinal)
current.pending = ""
current.publishedAt = now
return undefined
})
const append = Effect.fnUntraced(function* (id: string, value: string, state?: Record<string, unknown>) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value) current.values.push(value)
if (delta) current.pending += value
if (state !== undefined) current.state = { ...current.state, ...state } if (state !== undefined) current.state = { ...current.state, ...state }
return Effect.succeed(current.ordinal) yield* publishDelta(id)
return current.ordinal
}) })
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) { const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* publishDelta(id, true)
yield* ended( yield* ended(
id, id,
value ?? current.values.join(""), value ?? current.values.join(""),
@@ -156,9 +181,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state === undefined ? current.state : { ...current.state, ...state }, state === undefined ? current.state : { ...current.state, ...state },
) )
chunks.delete(id) chunks.delete(id)
return undefined
}) })
const flush = Effect.fnUntraced(function* () { const flush = Effect.fnUntraced(function* () {
for (const id of chunks.keys()) yield* end(id) for (const id of Array.from(chunks.keys())) yield* end(id)
}) })
return { start, append, end, flush, has: (id: string) => chunks.has(id) } return { start, append, end, flush, has: (id: string) => chunks.has(id) }
} }
@@ -175,6 +201,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state, state,
}) })
}), }),
(_textID, value, ordinal) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
delta: value,
})
}),
true, true,
) )
const reasoning = fragments( const reasoning = fragments(
@@ -189,6 +224,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state, state,
}) })
}), }),
(_reasoningID, value, ordinal) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
delta: value,
})
}),
true, true,
) )
const toolInput = fragments("tool input", (id, value) => const toolInput = fragments("tool input", (id, value) =>
@@ -351,13 +395,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}) })
return return
case "text-delta": case "text-delta":
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata)) yield* text.append(event.id, event.text, providerState(event.providerMetadata))
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaTextOrdinal,
delta: event.text,
})
return return
case "text-end": case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata)) yield* text.end(event.id, providerState(event.providerMetadata))
@@ -373,17 +411,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}) })
return return
case "reasoning-delta": case "reasoning-delta":
const deltaReasoningOrdinal = yield* reasoning.append( yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
event.id,
event.text,
providerState(event.providerMetadata),
)
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaReasoningOrdinal,
delta: event.text,
})
return return
case "reasoning-end": case "reasoning-end":
yield* reasoning.end(event.id, providerState(event.providerMetadata)) yield* reasoning.end(event.id, providerState(event.providerMetadata))
@@ -399,12 +427,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)) return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`)) if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
yield* toolInput.append(event.id, event.text) yield* toolInput.append(event.id, event.text)
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
delta: event.text,
})
return return
} }
case "tool-input-end": case "tool-input-end":
+1 -3
View File
@@ -181,9 +181,7 @@ export const Plugin = {
) )
const background = input.background === true const background = input.background === true
yield* context.progress({ yield* context.progress({ sessionID: child.id, status: "running" })
metadata: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () { const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input. // The child session owns its agent/model (set at create); prompt only admits input.
+25 -24
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
@@ -422,7 +422,7 @@ describe("Bus", () => {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const aggregateID = Event.ID.create() const aggregateID = Event.ID.create()
yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" }) const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
const rows = yield* db const rows = yield* db
.select() .select()
.from(EventTable) .from(EventTable)
@@ -433,6 +433,7 @@ describe("Bus", () => {
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]?.type).toBe(Bus.versionedType(SyncMessage.type, 1)) expect(rows[0]?.type).toBe(Bus.versionedType(SyncMessage.type, 1))
expect(rows[0]?.aggregate_id).toBe(aggregateID) expect(rows[0]?.aggregate_id).toBe(aggregateID)
expect(rows[0]?.created).toBe(event.created)
}), }),
) )
@@ -706,7 +707,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -726,7 +727,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -763,7 +764,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID: envelopeAggregateID, aggregateID: envelopeAggregateID,
@@ -797,7 +798,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -806,7 +807,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 5, seq: 5,
aggregateID, aggregateID,
@@ -831,14 +832,14 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2), type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2),
seq: 0, seq: 0,
aggregateID, aggregateID,
data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } }, data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
}) })
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0)) expect(received[0]?.created).toBe(0)
}), }),
) )
@@ -867,7 +868,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: "unknown.event.1", type: "unknown.event.1",
seq: 0, seq: 0,
aggregateID: Event.ID.create(), aggregateID: Event.ID.create(),
@@ -895,7 +896,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -915,7 +916,7 @@ describe("Bus", () => {
const id = Event.ID.create() const id = Event.ID.create()
const replayed = { const replayed = {
id, id,
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -972,7 +973,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1001,7 +1002,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1012,7 +1013,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 2, seq: 2,
aggregateID, aggregateID,
@@ -1045,7 +1046,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1058,7 +1059,7 @@ describe("Bus", () => {
.replay( .replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1080,7 +1081,7 @@ describe("Bus", () => {
yield* bus.listen((event) => Effect.sync(() => received.push(event))) yield* bus.listen((event) => Effect.sync(() => received.push(event)))
const replayed = { const replayed = {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1101,7 +1102,7 @@ describe("Bus", () => {
const aggregateID = Session.ID.create() const aggregateID = Session.ID.create()
const replayed = { const replayed = {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1126,7 +1127,7 @@ describe("Bus", () => {
const id = Event.ID.create() const id = Event.ID.create()
yield* bus.replay({ yield* bus.replay({
id, id,
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1136,7 +1137,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id, id,
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1159,7 +1160,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1170,7 +1171,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1232,7 +1233,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
+1 -1
View File
@@ -1002,7 +1002,7 @@ test("reconciles only changed MCP server config", async () => {
const publishUpdate = () => const publishUpdate = () =>
PubSub.publish(updates, { PubSub.publish(updates, {
id: ID.create(), id: ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Event.Updated.type, type: Event.Updated.type,
data: {}, data: {},
} satisfies Payload<typeof Event.Updated>) } satisfies Payload<typeof Event.Updated>)
+1 -1
View File
@@ -599,7 +599,7 @@ describe("Session.create", () => {
.all() .all()
.pipe(Effect.orDie)).map((event) => ({ .pipe(Effect.orDie)).map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
+1 -1
View File
@@ -760,7 +760,7 @@ describe("Session.prompt", () => {
yield* Effect.forEach( yield* Effect.forEach(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -13,6 +13,8 @@ import { Provider } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema" import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot" import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { it } from "./lib/effect"
import { TestClock } from "effect/testing"
const sessionID = Session.ID.make("ses_tool_event_test") const sessionID = Session.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -201,6 +203,82 @@ test("reasoning state from start, empty delta, and end is merged", async () => {
}) })
}) })
it.effect("batches text deltas and flushes pending text before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "one" }),
LLMEvent.textDelta({ id: "text", text: " two" }),
LLMEvent.textDelta({ id: "text", text: " three" }),
],
publisher.publish,
{ discard: true },
)
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
yield* TestClock.adjust("99 millis")
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
yield* TestClock.adjust("1 millis")
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " four" }))
expect(published.filter((event) => event.type === "session.text.delta").map((event) => event.data)).toMatchObject([
{ delta: "one two three four" },
])
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " five" }))
yield* publisher.publish(LLMEvent.textEnd({ id: "text" }))
expect(published.slice(-2).map((event) => event.type)).toEqual(["session.text.delta", "session.text.ended.1"])
expect(published.at(-2)?.data).toMatchObject({ delta: " five" })
}),
)
it.effect("batches reasoning deltas and flushes pending reasoning before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.reasoningStart({ id: "reasoning" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: "one" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: " two" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: " three" }),
LLMEvent.reasoningEnd({ id: "reasoning" }),
],
publisher.publish,
{ discard: true },
)
expect(
published.filter((event) => event.type === "session.reasoning.delta").map((event) => event.data),
).toMatchObject([{ delta: "one two three" }])
expect(published.slice(-2).map((event) => event.type)).toEqual([
"session.reasoning.delta",
"session.reasoning.ended.1",
])
}),
)
test("tool input deltas are accumulated without being published", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
[
LLMEvent.toolInputStart({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file.txt"}' }),
LLMEvent.toolInputEnd({ id: "call", name: "read" }),
],
publisher.publish,
{ discard: true },
),
)
expect(published.some((event) => event.type === "session.tool.input.delta")).toBe(false)
expect(published.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
text: '{"path":"file.txt"}',
})
})
test("provider-executed tool metadata is flattened using the route key", async () => { test("provider-executed tool metadata is flattened using the route key", async () => {
const { published, publisher } = capture("openai") const { published, publisher } = capture("openai")
await Effect.runPromise( await Effect.runPromise(
+29 -10
View File
@@ -75,7 +75,7 @@ import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import { ID } from "@opencode-ai/core/model" import { ID } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing" import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq } from "drizzle-orm" import { asc, desc, eq } from "drizzle-orm"
@@ -672,7 +672,7 @@ const replaySessionProjection = (id: Session.ID) =>
yield* Effect.forEach( yield* Effect.forEach(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -686,7 +686,7 @@ const replaySessionProjection = (id: Session.ID) =>
type FragmentKind = "text" | "reasoning" | "tool input" type FragmentKind = "text" | "reasoning" | "tool input"
type FragmentFixture = { type FragmentFixture = {
readonly delta: Event.Definition readonly delta?: Event.Definition
readonly completeEvents: LLMEvent[] readonly completeEvents: LLMEvent[]
readonly partialEvents: LLMEvent[] readonly partialEvents: LLMEvent[]
readonly expectedAssistant: unknown readonly expectedAssistant: unknown
@@ -748,7 +748,6 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
] ]
const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } } const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } }
return { return {
delta: SessionEvent.Tool.Input.Delta,
partialEvents, partialEvents,
completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })], completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
expectedAssistant: { type: "assistant", content: [expectedContent] }, expectedAssistant: { type: "assistant", content: [expectedContent] },
@@ -767,20 +766,37 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
yield* admit(session, prompt) yield* admit(session, prompt)
const bus = yield* Bus.Service const bus = yield* Bus.Service
const live = yield* bus.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) const live = fixture.delta
? yield* bus.subscribe(fixture.delta).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
: undefined
yield* Effect.yieldNow yield* Effect.yieldNow
yield* TestLLM.push(fixture.completeEvents) yield* TestLLM.push(fixture.completeEvents)
yield* session.resume(sessionID) yield* session.resume(sessionID)
const { db } = yield* Database.Service const { db } = yield* Database.Service
const deltas = yield* db const deltas = fixture.delta
? yield* db
.select({ type: EventTable.type }) .select({ type: EventTable.type })
.from(EventTable) .from(EventTable)
.where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1))) .where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1)))
.all() .all()
.pipe(Effect.orDie) .pipe(Effect.orDie)
expect(Array.from(yield* Fiber.join(live))).toHaveLength(32) : []
if (live) {
const streamed = Array.from(yield* Fiber.join(live))
expect(streamed).toHaveLength(1)
expect(
streamed
.map((event) => {
if (!event.data || typeof event.data !== "object" || !("delta" in event.data))
throw new Error("Expected delta event")
if (typeof event.data.delta !== "string") throw new Error("Expected string delta")
return event.data.delta
})
.join(""),
).toBe(chunks.join(""))
}
expect(deltas).toHaveLength(0) expect(deltas).toHaveLength(0)
expect(yield* session.context(sessionID)).toMatchObject(expectedContext) expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
@@ -1463,7 +1479,7 @@ describe("SessionRunnerLLM", () => {
yield* Effect.forEach( yield* Effect.forEach(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -5219,8 +5235,11 @@ describe("SessionRunnerLLM", () => {
) )
for (const kind of fragmentKinds) { for (const kind of fragmentKinds) {
it.effect(`broadcasts provider ${kind} deltas without storing projection rewrites`, () => it.effect(
verifyEphemeralDeltas(kind), kind === "tool input"
? "does not broadcast provider tool input deltas"
: `batches provider ${kind} deltas without storing projection rewrites`,
() => verifyEphemeralDeltas(kind),
) )
it.effect(`durably closes partial ${kind} when the provider stream fails`, () => verifyPartialFlushOnFailure(kind)) it.effect(`durably closes partial ${kind} when the provider stream fails`, () => verifyPartialFlushOnFailure(kind))
+1 -1
View File
@@ -298,7 +298,7 @@ describe("SubagentTool", () => {
}) })
const child = yield* sessions.get(outputSessionID(settled.metadata)) const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" }) expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" }) expect(progress[0]).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({ expect(child).toMatchObject({
parentID: parent.id, parentID: parent.id,
location: parent.location, location: parent.location,
+3 -2
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun" import { $ } from "bun"
import { downloadCliToResources, resolveChannel } from "./utils" import { copyBuiltCliToResources, downloadCliToResources, resolveChannel } from "./utils"
const channel = resolveChannel() const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources() if (channel === "dev") await downloadCliToResources()
if (channel === "beta") await downloadCliToResources("next") if (channel === "beta" && Bun.env.OPENCODE_CLI_DIST) await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
if (channel === "beta" && !Bun.env.OPENCODE_CLI_DIST) await downloadCliToResources("beta")
+13 -3
View File
@@ -3,7 +3,7 @@ import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
const CLI_VERSION = "0.0.0-next-16365" const CLI_VERSION = "dev"
export type Channel = "dev" | "beta" | "prod" export type Channel = "dev" | "beta" | "prod"
@@ -74,18 +74,28 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
try { try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile( await copyCliToResources(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest, dest,
) )
} finally { } finally {
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
await prepareCli(dest)
console.log(`Copied ${cli.package}@${version} to ${dest}`) console.log(`Copied ${cli.package}@${version} to ${dest}`)
} }
export async function copyBuiltCliToResources(root: string, dest = windowsify("resources/opencode-cli")) {
const cli = getCurrentCli()
const directory = cli.package.replace("@opencode-ai/", "")
await copyCliToResources(join(root, directory, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), dest)
}
async function copyCliToResources(source: string, dest: string) {
await copyFile(source, dest)
await prepareCli(dest)
}
async function prepareCli(dest: string) { async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755) if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
@@ -27,7 +27,7 @@ test("installs and verifies the bundled CLI version", async () => {
await controller.installOpencode("Debian") await controller.installOpencode("Debian")
expect(installs).toEqual([["Debian", "0.0.0-next-16365"]]) expect(installs).toEqual([["Debian", "0.0.0-dev-16365"]])
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true) expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
}) })
@@ -37,12 +37,12 @@ test("rejects a WSL CLI version that differs from the bundled version", async ()
testControllerOptions({ testControllerOptions({
installCli: async () => undefined, installCli: async () => undefined,
resolveCli: async () => "/home/me/.opencode/bin/opencode2", resolveCli: async () => "/home/me/.opencode/bin/opencode2",
readCliVersion: async () => "0.0.0-next-older", readCliVersion: async () => "0.0.0-dev-older",
}), }),
) )
await expect(controller.installOpencode("Debian")).rejects.toThrow( await expect(controller.installOpencode("Debian")).rejects.toThrow(
"OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365", "OpenCode update finished but Debian still reports 0.0.0-dev-older; expected 0.0.0-dev-16365",
) )
}) })
@@ -147,7 +147,7 @@ async function waitFor(check: () => boolean) {
function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions { function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions {
return { return {
cli: { version: "0.0.0-next-16365" }, cli: { version: "0.0.0-dev-16365" },
spawnSidecar: async () => ({ spawnSidecar: async () => ({
stop: async () => undefined, stop: async () => undefined,
onExit: () => undefined, onExit: () => undefined,
@@ -159,7 +159,7 @@ function testControllerOptions(overrides: Partial<ControllerOptions> = {}): Cont
writeServers: (servers: WslServerConfig[]) => { writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers persistedServers = servers
}, },
readCliVersion: async () => "0.0.0-next-16365", readCliVersion: async () => "0.0.0-dev-16365",
resolveCli: async () => "/home/me/.opencode/bin/opencode2", resolveCli: async () => "/home/me/.opencode/bin/opencode2",
...overrides, ...overrides,
} }
+25 -1
View File
@@ -1,5 +1,25 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { isOpenCodeEvent } from "../src/groups/event.js" import { isOpenCodeEvent, type OpenCodeEvent, type OpenCodeEventEncoded } from "../src/groups/event.js"
type JsonShape<Value> = Value extends string | number | boolean | null
? Value
: Value extends undefined
? never
: Value extends ReadonlyArray<infer Item>
? ReadonlyArray<JsonShape<Item>>
: Value extends object
? {
readonly [Key in keyof Value as undefined extends Value[Key] ? never : Key]: JsonShape<Value[Key]>
} & {
readonly [Key in keyof Value as undefined extends Value[Key] ? Key : never]?: JsonShape<
Exclude<Value[Key], undefined>
>
}
: Value
// JSON.stringify omits undefined object properties, so normalize them before
// requiring every runtime event shape to fit its encoded wire contract.
const wireReady: [JsonShape<OpenCodeEvent>] extends [JsonShape<OpenCodeEventEncoded>] ? true : false = true
test("classifies public events by type", () => { test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true) expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
@@ -7,3 +27,7 @@ test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false) expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
}) })
test("keeps public event runtime values within the encoded contract", () => {
expect(wireReady).toBe(true)
})
+4 -4
View File
@@ -4,7 +4,7 @@ import { Schema, SchemaTransformation } from "effect"
import { optional } from "./schema.js" import { optional } from "./schema.js"
import { ascending } from "./identifier.js" import { ascending } from "./identifier.js"
import { Location } from "./location.js" import { Location } from "./location.js"
import { DateTimeUtcFromMillis, statics } from "./schema.js" import { statics } from "./schema.js"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"), Schema.brand("Event.ID"),
@@ -60,7 +60,7 @@ export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
type PayloadBase<D extends Definition> = { type PayloadBase<D extends Definition> = {
readonly id: ID readonly id: ID
readonly type: D["type"] readonly type: D["type"]
readonly created: typeof DateTimeUtcFromMillis.Type readonly created: number
readonly data: Data<D> readonly data: Data<D>
readonly location?: Location.Ref readonly location?: Location.Ref
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
@@ -100,7 +100,7 @@ export function durable<
}) })
return Schema.Struct({ return Schema.Struct({
id: ID, id: ID,
created: DateTimeUtcFromMillis, created: Schema.Finite,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
durable, durable,
@@ -125,7 +125,7 @@ export function ephemeral<
const data = Schema.Struct(input.schema) const data = Schema.Struct(input.schema)
return Schema.Struct({ return Schema.Struct({
id: ID, id: ID,
created: DateTimeUtcFromMillis, created: Schema.Finite,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
location: optional(Location.Ref), location: optional(Location.Ref),
+1 -1
View File
@@ -34,7 +34,7 @@ const IS_PREVIEW = CHANNEL !== "latest"
const VERSION = await (async () => { const VERSION = await (async () => {
if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION
if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${previewBuildNumber()}` if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${previewBuildNumber()}`
const version = await fetch("https://registry.npmjs.org/opencode-ai/latest") const version = await fetch("https://registry.npmjs.org/@opencode-ai%2fcli/latest")
.then((res) => { .then((res) => {
if (!res.ok) throw new Error(res.statusText) if (!res.ok) throw new Error(res.statusText)
return res.json() return res.json()
+2 -4
View File
@@ -2,7 +2,7 @@ export * as EventFeed from "./event-feed"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event" import { isOpenCodeEvent, type OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect"
export const SubscriberCapacity = 4_096 export const SubscriberCapacity = 4_096
@@ -26,10 +26,8 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/server/EventFeed") {} export class Service extends Context.Service<Service, Interface>()("@opencode/server/EventFeed") {}
const encode = Schema.encodeUnknownSync(OpenCodeEvent)
export function frame(event: OpenCodeEvent) { export function frame(event: OpenCodeEvent) {
return `data: ${JSON.stringify(encode(event))}\n\n` return `data: ${JSON.stringify(event)}\n\n`
} }
export const make = Effect.fn("EventFeed.make")(function* ( export const make = Effect.fn("EventFeed.make")(function* (
+4 -7
View File
@@ -2,8 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Agent } from "@opencode-ai/core/agent" import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } from "../../core/test/lib/effect" import { it } from "../../core/test/lib/effect"
import { EventFeed } from "../src/event-feed" import { EventFeed } from "../src/event-feed"
@@ -11,14 +10,14 @@ const Internal = Bus.ephemeral({ type: "test.internal", schema: { value: Schema.
const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({ const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({
id: Event.ID.make(`evt_${id}`), id: Event.ID.make(`evt_${id}`),
created: DateTime.makeUnsafe(Date.now()), created: Date.now(),
type: Agent.Event.Updated.type, type: Agent.Event.Updated.type,
data: {}, data: {},
}) })
const internal = (value: string): Event.Payload<typeof Internal> => ({ const internal = (value: string): Event.Payload<typeof Internal> => ({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(Date.now()), created: Date.now(),
type: Internal.type, type: Internal.type,
data: { value }, data: { value },
}) })
@@ -40,9 +39,7 @@ function makeSource() {
describe("EventFeed", () => { describe("EventFeed", () => {
test("preserves the public SSE frame encoding", () => { test("preserves the public SSE frame encoding", () => {
const payload = event("wire") const payload = event("wire")
expect(EventFeed.frame(payload)).toBe( expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
`data: ${JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(payload))}\n\n`,
)
}) })
it.effect("encodes once and delivers the same frame to every subscriber", () => it.effect("encodes once and delivers the same frame to every subscriber", () =>
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { channelsForRef, validGitHubClaims } from "./index" import { channelsForRef, resolveChannel, validGitHubClaims } from "./index"
const claims = { const claims = {
repository: "anomalyco/opencode", repository: "anomalyco/opencode",
@@ -19,6 +19,10 @@ describe("GitHub publish authorization", () => {
expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"]) expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"])
}) })
test("maps V2 development to the dev channel", () => {
expect(channelsForRef("refs/heads/v2")).toEqual(["dev"])
})
test("rejects another repository or workflow", () => { test("rejects another repository or workflow", () => {
expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false) expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false)
expect( expect(
@@ -33,3 +37,8 @@ describe("GitHub publish authorization", () => {
).toBe(false) ).toBe(false)
}) })
}) })
test("routes the retired next channel to beta", () => {
expect(resolveChannel("next")).toBe("beta")
expect(resolveChannel("dev")).toBe("dev")
})
+8 -4
View File
@@ -42,7 +42,7 @@ export default {
const segments = url.pathname.split("/").filter(Boolean) const segments = url.pathname.split("/").filter(Boolean)
if (segments.length === 2 && segments[0] === "api" && validIdentifier(segments[1])) { if (segments.length === 2 && segments[0] === "api" && validIdentifier(segments[1])) {
return channel(env.DB, segments[1]) return channel(env.DB, resolveChannel(segments[1]))
} }
if ( if (
segments.length === 3 && segments.length === 3 &&
@@ -50,7 +50,7 @@ export default {
validIdentifier(segments[1]) && validIdentifier(segments[1]) &&
validIdentifier(segments[2]) validIdentifier(segments[2])
) { ) {
return artifactName(env.DB, segments[1], segments[2]) return artifactName(env.DB, resolveChannel(segments[1]), segments[2])
} }
if ( if (
segments.length === 4 && segments.length === 4 &&
@@ -59,7 +59,7 @@ export default {
validIdentifier(segments[2]) && validIdentifier(segments[2]) &&
validIdentifier(segments[3]) validIdentifier(segments[3])
) { ) {
return artifactDistribution(env.DB, segments[1], segments[2], segments[3]) return artifactDistribution(env.DB, resolveChannel(segments[1]), segments[2], segments[3])
} }
return new Response("Not found", { status: 404 }) return new Response("Not found", { status: 404 })
}, },
@@ -344,7 +344,7 @@ export function validGitHubClaims(claims: JWTPayload): claims is GitHubClaims {
export function channelsForRef(ref: string) { export function channelsForRef(ref: string) {
if (ref === "refs/heads/dev") return ["dev", "latest"] if (ref === "refs/heads/dev") return ["dev", "latest"]
if (ref === "refs/heads/v2") return ["next"] if (ref === "refs/heads/v2") return ["dev"]
if (ref === "refs/heads/beta") return ["beta"] if (ref === "refs/heads/beta") return ["beta"]
if (ref === "refs/heads/ci") return ["ci"] if (ref === "refs/heads/ci") return ["ci"]
if (ref === "refs/heads/fix/npm-native-binary-install") return ["fix/npm-native-binary-install"] if (ref === "refs/heads/fix/npm-native-binary-install") return ["fix/npm-native-binary-install"]
@@ -352,6 +352,10 @@ export function channelsForRef(ref: string) {
return snapshot ? [snapshot] : [] return snapshot ? [snapshot] : []
} }
export function resolveChannel(channel: string) {
return channel === "next" ? "beta" : channel
}
function validMutation(request: Request) { function validMutation(request: Request) {
const origin = request.headers.get("Origin") const origin = request.headers.get("Origin")
if (origin && origin !== new URL(request.url).origin) return json({ error: "Invalid origin" }, 403) if (origin && origin !== new URL(request.url).origin) return json({ error: "Invalid origin" }, 403)
+2 -2
View File
@@ -15,7 +15,7 @@ network. Its types and methods are generated from the same contract as the
## Install ## Install
```sh ```sh
bun add @opencode-ai/client@next bun add @opencode-ai/client@beta
``` ```
## Create a client ## Create a client
@@ -119,7 +119,7 @@ OpenCode provides a first-class Effect client through the
and decodes responses into OpenCode schema values. and decodes responses into OpenCode schema values.
```sh ```sh
bun add @opencode-ai/client@next effect bun add @opencode-ai/client@beta effect
``` ```
### Create a client ### Create a client
+3 -3
View File
@@ -106,7 +106,7 @@ visible from the plugin file, for example:
```sh ```sh
cd .opencode cd .opencode
bun add @opencode-ai/plugin@next bun add @opencode-ai/plugin@beta
``` ```
Match the plugin package version to the OpenCode release you target. Match the plugin package version to the OpenCode release you target.
@@ -400,7 +400,7 @@ manifest is:
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "next" "@opencode-ai/plugin": "beta"
} }
} }
``` ```
@@ -430,7 +430,7 @@ OpenCode provides a first-class Effect API for plugins through the
plugin package and export an `effect` function instead of `setup`: plugin package and export an `effect` function instead of `setup`:
```sh ```sh
bun add @opencode-ai/plugin@next effect bun add @opencode-ai/plugin@beta effect
``` ```
```ts title=".opencode/plugins/reviewer-effect.ts" ```ts title=".opencode/plugins/reviewer-effect.ts"
+4 -4
View File
@@ -18,19 +18,19 @@ description: "Get started with OpenCode."
<CodeGroup> <CodeGroup>
```bash npm ```bash npm
npm install -g @opencode-ai/cli@next npm install -g @opencode-ai/cli@beta
``` ```
```bash bun ```bash bun
bun install -g --trust @opencode-ai/cli@next bun install -g --trust @opencode-ai/cli@beta
``` ```
```bash pnpm ```bash pnpm
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@beta
``` ```
```bash yarn ```bash yarn
yarn global add @opencode-ai/cli@next yarn global add @opencode-ai/cli@beta
``` ```
```bash curl ```bash curl
+2 -2
View File
@@ -35,10 +35,10 @@ beta compatibility bug rather than an expected migration requirement.
## Install the beta ## Install the beta
Install the beta from the `next` distribution tag: Install the beta from the `beta` distribution tag:
```bash ```bash
npm install -g @opencode-ai/cli@next npm install -g @opencode-ai/cli@beta
``` ```
Start it in your project with: Start it in your project with:
@@ -119,7 +119,7 @@ Its private service configuration is stored separately at:
The database normally lives at: The database normally lives at:
```text ```text
~/.local/share/opencode/opencode-next.db ~/.local/share/opencode/opencode.db
``` ```
`OPENCODE_DB` can override the database location. `OPENCODE_DB` can override the database location.
-360
View File
@@ -1,360 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs/promises"
const model = "opencode/gpt-5.3-codex"
interface PR {
number: number
title: string
author: { login: string }
labels: Array<{ name: string }>
}
interface FailedPR {
number: number
title: string
reason: string
}
async function commentOnPR(prNumber: number, reason: string) {
const body = `⚠️ **Blocking Beta Release**
This PR cannot be merged into the beta branch due to: **${reason}**
Please resolve this issue to include this PR in the next beta release.`
try {
await $`gh pr comment ${prNumber} --body ${body}`
console.log(` Posted comment on PR #${prNumber}`)
} catch (err) {
console.log(` Failed to post comment on PR #${prNumber}: ${err}`)
}
}
async function conflicts() {
const out = await $`git diff --name-only --diff-filter=U`.text().catch(() => "")
return out
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
}
async function cleanup() {
try {
await $`git merge --abort`
} catch {}
try {
await $`git checkout -- .`
} catch {}
try {
await $`git clean -fd`
} catch {}
}
function lines(prs: PR[]) {
return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)"
}
function group(title: string) {
if (process.env.GITHUB_ACTIONS !== "true") {
console.log(title)
return { [Symbol.dispose]() {} }
}
console.log(`::group::${title}`)
return {
[Symbol.dispose]() {
console.log("::endgroup::")
},
}
}
async function typecheck() {
console.log(" Running typecheck...")
try {
await $`bun typecheck`
return true
} catch (err) {
console.log(`Typecheck failed: ${err}`)
return false
}
}
async function build() {
console.log(" Running final build smoke check...")
try {
await $`./script/build.ts --single`.cwd("packages/opencode")
return true
} catch (err) {
console.log(`Build failed: ${err}`)
return false
}
}
async function validate() {
if (!(await typecheck())) return false
if (!(await build())) return false
return true
}
async function commitSmokeChanges() {
const out = await $`git status --porcelain`.text()
if (!out.trim()) {
console.log("Smoke check passed")
return true
}
try {
await $`git add -A`
await $`git commit -m "Fix beta integration"`
} catch (err) {
console.log(`Failed to commit smoke fixes: ${err}`)
return false
}
if (!(await validate())) return false
const left = await $`git status --porcelain`.text()
if (!left.trim()) {
console.log("Smoke check passed")
return true
}
console.log(`Smoke check left uncommitted changes:\n${left}`)
return false
}
async function install() {
console.log(" Regenerating bun.lock...")
try {
await fs.rm("bun.lock", { force: true })
await $`bun install`
await $`git add bun.lock`
return true
} catch (err) {
console.log(`Install failed: ${err}`)
return false
}
}
async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) {
console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`)
const done = lines(prs.filter((x) => applied.includes(x.number)))
const next = lines(prs.slice(idx + 1))
const prompt = [
`Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`,
`PR #${pr.number}: ${pr.title}`,
`Start with these conflicted files: ${files.join(", ")}.`,
`Merged PRs on HEAD:\n${done}`,
`Pending PRs after this one (context only):\n${next}`,
"IMPORTANT: The conflict resolution must be consistent with already-merged PRs.",
"Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.",
"Prefer already-merged PRs over the base branch when resolving stacked conflicts.",
"If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.",
"If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.",
"If a PR already changed an import, keep that change.",
"After resolving the conflicts, run `bun typecheck` at the repo root.",
"If typecheck fails, you may also update any files reported by typecheck.",
"Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.",
"Fix any merge-caused typecheck errors before finishing.",
"Keep the merge in progress, do not abort the merge, and do not create a commit.",
"When done, leave the working tree with no unmerged files and a passing typecheck.",
].join("\n")
try {
await $`opencode run -m ${model} ${prompt}`
} catch (err) {
console.log(` opencode failed: ${err}`)
return false
}
const left = await conflicts()
if (left.length > 0) {
console.log(` Conflicts remain: ${left.join(", ")}`)
return false
}
if (files.includes("bun.lock") && !(await install())) return false
if (!(await typecheck())) return false
console.log(" Conflicts resolved with opencode")
return true
}
async function smoke(prs: PR[], applied: number[]) {
console.log("\nRunning final smoke check...")
if (await validate()) return commitSmokeChanges()
console.log("\nTrying to fix final smoke check with opencode...")
const done = lines(prs.filter((x) => applied.includes(x.number)))
const prompt = [
"The beta merge batch is complete, but the deterministic final smoke check failed.",
`Merged PRs on HEAD:\n${done}`,
"Run `bun typecheck` at the repo root.",
"Run `./script/build.ts --single` in `packages/opencode`.",
"Fix any merge-caused issues until both commands pass.",
"Do not create a commit.",
].join("\n")
try {
await $`opencode run -m ${model} ${prompt}`
} catch (err) {
console.log(`Smoke fix failed: ${err}`)
return false
}
if (!(await validate())) return false
return commitSmokeChanges()
}
async function main() {
console.log("Fetching open PRs with beta label...")
const stdout =
await $`gh pr list --state open --draft=false --label beta --json number,title,author,labels --limit 100`.text()
const prs: PR[] = JSON.parse(stdout).sort((a: PR, b: PR) => a.number - b.number)
console.log(`Found ${prs.length} open PRs with beta label`)
if (prs.length === 0) {
console.log("No team PRs to merge")
return
}
console.log("Fetching latest dev branch...")
await $`git fetch origin dev`
console.log("Checking out beta branch...")
await $`git checkout -B beta origin/dev`
const applied: number[] = []
const failed: FailedPR[] = []
for (const [idx, pr] of prs.entries()) {
console.log()
using _ = group(`Processing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
console.log(" Fetching PR head...")
try {
await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}`
} catch (err) {
console.log(` Failed to fetch: ${err}`)
failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" })
await commentOnPR(pr.number, "Fetch failed")
continue
}
console.log(" Merging...")
try {
await $`git merge --no-commit --no-ff pr/${pr.number}`
} catch {
const files = await conflicts()
if (files.length > 0) {
console.log(" Failed to merge (conflicts)")
if (!(await fix(pr, files, prs, applied, idx))) {
await cleanup()
failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
await commentOnPR(pr.number, "Merge conflicts with dev branch")
continue
}
} else {
console.log(" Failed to merge")
await cleanup()
failed.push({ number: pr.number, title: pr.title, reason: "Merge failed" })
await commentOnPR(pr.number, "Merge failed")
continue
}
}
try {
await $`git rev-parse -q --verify MERGE_HEAD`.text()
} catch {
console.log(" No changes, skipping")
continue
}
try {
await $`git add -A`
} catch {
console.log(" Failed to stage changes")
failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" })
await commentOnPR(pr.number, "Failed to stage changes")
continue
}
const commitMsg = `Apply PR #${pr.number}: ${pr.title}`
try {
await $`git commit -m ${commitMsg}`
} catch (err) {
console.log(` Failed to commit: ${err}`)
failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" })
await commentOnPR(pr.number, "Failed to commit changes")
continue
}
console.log(" Applied successfully")
applied.push(pr.number)
}
console.log("\n--- Summary ---")
console.log(`Applied: ${applied.length} PRs`)
applied.forEach((num) => console.log(` - PR #${num}`))
if (failed.length > 0) {
console.log(`Failed: ${failed.length} PRs`)
failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`))
throw new Error(`${failed.length} PR(s) failed to merge`)
}
console.log("\nChecking if beta branch has changes...")
await $`git fetch origin beta`
const localTree = (await $`git rev-parse beta^{tree}`.text()).trim()
const remoteTrees = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
const matchIdx = remoteTrees.indexOf(localTree)
if (matchIdx !== -1) {
if (matchIdx !== 0) {
console.log(`Beta branch contains this sync, but additional commits exist after it. Leaving beta branch as is.`)
} else {
console.log("Beta branch has identical contents, no push needed")
}
return
}
if (!(await smoke(prs, applied))) throw new Error("Final smoke check failed")
await $`git fetch origin beta`
const validatedTree = (await $`git rev-parse beta^{tree}`.text()).trim()
const remoteTreesAfterSmoke = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
const matchIdxAfterSmoke = remoteTreesAfterSmoke.indexOf(validatedTree)
if (matchIdxAfterSmoke !== -1) {
if (matchIdxAfterSmoke !== 0) {
console.log(
`Beta branch contains this validated sync, but additional commits exist after it. Leaving beta branch as is.`,
)
} else {
console.log("Validated beta branch now matches remote contents, no push needed")
}
return
}
console.log("Force pushing validated beta branch...")
await $`git push origin beta --force --no-verify`
console.log("Successfully synced beta branch")
}
main().catch((err) => {
console.error("Error:", err)
process.exit(1)
})
+15 -1
View File
@@ -35,7 +35,6 @@ if (Script.release && !Script.preview) {
await prepareReleaseFiles() await prepareReleaseFiles()
if (Script.channel !== "beta") {
console.log("\n=== schema ===\n") console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts` await $`bun ./packages/schema/script/publish.ts`
@@ -68,6 +67,21 @@ if (Script.channel !== "beta") {
console.log("\n=== ui ===\n") console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts` await $`bun ./packages/ui/script/publish.ts`
if (Script.channel === "beta") {
const packages = [
"@opencode-ai/schema",
"@opencode-ai/codemode",
"@opencode-ai/theme",
"@opencode-ai/ai",
"@opencode-ai/util",
"@opencode-ai/protocol",
"@opencode-ai/client",
"@opencode-ai/plugin",
"@opencode-ai/core",
"@opencode-ai/ui",
]
await Promise.all(packages.map((name) => $`npm dist-tag add ${`${name}@${Script.version}`} next`))
} }
if (Script.release) { if (Script.release) {