Compare commits

..

10 Commits

Author SHA1 Message Date
LukeParkerDev
a722045ee8 Merge branch 'dev' into refactor/oauth-node-http 2026-03-28 11:41:48 +10:00
Adam
f736116967 fix(app): more startup efficiency (#19454) 2026-03-28 01:26:57 +00:00
Luke Parker
82fc493520 feat(ci): use Azure Artifact Signing for Windows releases (#15201) 2026-03-28 11:20:19 +10:00
Kit Langton
2145d97f18 refactor(session): effectify SessionCompaction service (#19459) 2026-03-28 01:09:14 +00:00
Sebastian
f3997d8082 Single target plugin entrypoints (#19467) 2026-03-27 23:44:46 +00:00
opencode-agent[bot]
02b19bc3d7 chore: generate 2026-03-27 21:38:08 +00:00
Kit Langton
5cd54ec345 refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457) 2026-03-27 17:37:07 -04:00
opencode-agent[bot]
c8909908f5 chore: update nix node_modules hashes 2026-03-27 21:11:06 +00:00
Dax
9f5713e802 Merge branch 'dev' into refactor/oauth-node-http 2026-03-20 10:02:12 -04:00
Dax Raad
816a5f793f refactor: replace Bun.serve with Node http.createServer in OAuth handlers
Use Node's createServer for MCP OAuth callback and Codex plugin OAuth
servers instead of Bun.serve, making them work under Node.js.
2026-03-19 21:47:38 -04:00
58 changed files with 1921 additions and 866 deletions

View File

@@ -98,15 +98,129 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
path: |
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@v1
with:
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: false
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Verify Windows CLI signatures
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -152,6 +266,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -190,6 +312,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -246,11 +369,34 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -292,6 +438,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -326,6 +480,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -358,6 +513,22 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @()
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
foreach ($file in $files | Select-Object -Unique) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
@@ -373,6 +544,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -411,6 +583,16 @@ jobs:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:

View File

@@ -1,54 +0,0 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error

View File

@@ -1,7 +1,14 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
@@ -813,7 +820,7 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
])
}
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
@@ -846,7 +853,9 @@ const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, m
}
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin

View File

@@ -1,5 +0,0 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4XhUHjgqinKxOeT8K5hGAjpFA2vzOp8QpEg0uYCZwvg=",
"aarch64-linux": "sha256-X2YTNOpJocIkWkkfS8RnuDW+tvj4riHs7CXM+cS9iv0=",
"aarch64-darwin": "sha256-pN0rY+cpdW+6gNWeegVprdmhc2H72OZ9WxKDIs1fvJM=",
"x86_64-darwin": "sha256-l8+Yz/6UfSPJrdgfcqy/L2SvxN2i9Apv2R0B61rpEmw="
"x86_64-linux": "sha256-aqmdiQeFREbUfRi3YX+ot4+CjykDuJpxYQH54W3hxME=",
"aarch64-linux": "sha256-ykJp6rFFwXkfJpMRJheTw+r495Wpmx5nj2LKxgSSVDw=",
"aarch64-darwin": "sha256-xHGM1rLld8sqkY+lhvec7fWkPPajIE403viIcpsFnk4=",
"x86_64-darwin": "sha256-QkGtT76P9Kf2+Ny0rI4CwMrIFzRIXiZwi8KS2o+jECU="
}
}

View File

@@ -159,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const dialog = await openSettings(page)
const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect(input).toHaveAttribute("placeholder", "System Mono")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
@@ -167,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const initialUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(initialFontFamily).toContain("IBM Plex Mono")
expect(initialFontFamily).toContain("ui-monospace")
const next = "Test Mono"
@@ -185,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
})
.toMatchObject({
appearance: {
font: next,
mono: next,
},
})
@@ -206,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "Inter")
await expect(input).toHaveAttribute("placeholder", "System Sans")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
@@ -214,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const initialCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(initialFontFamily).toContain("Inter")
expect(initialFontFamily).toContain("ui-sans-serif")
const next = "Test Sans"
@@ -232,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
})
.toMatchObject({
appearance: {
uiFont: next,
sans: next,
},
})
@@ -267,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
})
.toMatchObject({
appearance: {
font: "Reset Mono",
mono: "Reset Mono",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect(input).toHaveAttribute("placeholder", "System Mono")
await expect
.poll(async () => {
@@ -285,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
})
.toMatchObject({
appearance: {
font: "",
mono: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(fontFamily).toContain("IBM Plex Mono")
expect(fontFamily).toContain("ui-monospace")
expect(fontFamily).not.toContain("Reset Mono")
})
@@ -316,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
})
.toMatchObject({
appearance: {
uiFont: "Reset Sans",
sans: "Reset Sans",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "Inter")
await expect(input).toHaveAttribute("placeholder", "System Sans")
await expect
.poll(async () => {
@@ -334,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
})
.toMatchObject({
appearance: {
uiFont: "",
sans: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(fontFamily).toContain("Inter")
expect(fontFamily).toContain("ui-sans-serif")
expect(fontFamily).not.toContain("Reset Sans")
})
@@ -373,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
return raw ? JSON.parse(raw) : null
}, settingsKey)
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
await code.click()
await code.clear()
@@ -395,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
})
.toMatchObject({
appearance: {
font: mono,
uiFont: sans,
mono,
sans,
},
})
@@ -415,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(updatedMono).not.toBe(initialMono)
expect(updatedSans).toContain(sans)
expect(updatedSans).not.toBe(initialSans)
expect(updatedSettings?.appearance?.font).toBe(mono)
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
expect(updatedSettings?.appearance?.mono).toBe(mono)
expect(updatedSettings?.appearance?.sans).toBe(sans)
await closeDialog(page, dialog)
await page.reload()
@@ -432,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
})
.toMatchObject({
appearance: {
font: mono,
uiFont: sans,
mono,
sans,
},
})
@@ -468,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(rehydratedMono).not.toBe(initialMono)
expect(rehydratedSans).toContain(sans)
expect(rehydratedSans).not.toBe(initialSans)
expect(rehydratedSettings?.appearance?.font).toBe(mono)
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {

View File

@@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => (
<SessionProviders>
<Session />
@@ -278,7 +283,11 @@ export function AppInterface(props: {
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>

View File

@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
heartbeat = undefined
}
void (async () => {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
const start = () => {
if (started) return run
started = true
run = (async () => {
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(flush)
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop()
abort.abort()
flush()
})
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
return {
url: currentServer.http.url,
client: sdk,
event: emitter,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))

View File

@@ -72,10 +72,16 @@ function createGlobalSync() {
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const cacheProjects = () => {
setProjectCache(
@@ -348,6 +354,20 @@ function createGlobalSync() {
}
onMount(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
}
void bootstrap()
})

View File

@@ -43,8 +43,10 @@ function waitForPaint() {
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
setTimeout(() => {
clearTimeout(timer)
finish()
}, 0)
})
})
}
@@ -87,12 +89,6 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
@@ -108,6 +104,12 @@ export async function bootstrapGlobal(input: {
]
const slow = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.project.list().then((x) => {
@@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: {
if (loading) input.setStore("status", "partial")
const fast = [
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
]
const slow = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
seededPath
? Promise.resolve()
@@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: {
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
@@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: {
)
}),
),
]
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
retry(() =>

View File

@@ -544,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => {
Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
sessionFrame = requestAnimationFrame(() => {
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
return {

View File

@@ -94,7 +94,11 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
})

View File

@@ -32,8 +32,8 @@ export interface Settings {
}
appearance: {
fontSize: number
font: string
uiFont: string
mono: string
sans: string
}
keybinds: Record<string, string>
permissions: {
@@ -43,20 +43,18 @@ export interface Settings {
sounds: SoundSettings
}
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
const monoKey = "ibm-plex-mono"
const monoBase = monoFallback
const sansBase = sansFallback
function input(font: string | undefined, key?: string) {
if (!font || font === key || !font.trim()) return ""
return font
function input(font: string | undefined) {
return font ?? ""
}
function family(font: string) {
@@ -64,14 +62,14 @@ function family(font: string) {
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
}
function stack(font: string | undefined, base: string, key?: string) {
const value = input(font, key).trim()
function stack(font: string | undefined, base: string) {
const value = font?.trim() ?? ""
if (!value) return base
return `${family(value)}, ${base}`
}
export function monoInput(font: string | undefined) {
return input(font, monoKey)
return input(font)
}
export function sansInput(font: string | undefined) {
@@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) {
}
export function monoFontFamily(font: string | undefined) {
return stack(font, monoBase, monoKey)
return stack(font, monoBase)
}
export function sansFontFamily(font: string | undefined) {
@@ -100,8 +98,8 @@ const defaultSettings: Settings = {
},
appearance: {
fontSize: 14,
font: "",
uiFont: "",
mono: "",
sans: "",
},
keybinds: {},
permissions: {
@@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
return {
@@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
setFont(value: string) {
setStore("appearance", "font", value.trim() ? value : "")
setStore("appearance", "mono", value.trim() ? value : "")
},
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
setUIFont(value: string) {
setStore("appearance", "uiFont", value.trim() ? value : "")
setStore("appearance", "sans", value.trim() ? value : "")
},
},
keybinds: {

View File

@@ -544,6 +544,8 @@ export default function Page() {
let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
@@ -718,7 +720,6 @@ export default function Page() {
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
untrack(() => {
void sync.session.sync(id)
})
@@ -730,13 +731,47 @@ export default function Page() {
if (params.id !== id) return
untrack(() => {
if (stale) void sync.session.sync(id, { force: true })
void sync.session.todo(id, todos ? { force: true } : undefined)
})
}, 0)
})
}),
)
createEffect(
on(
() => {
const id = params.id
return [
sdk.directory,
id,
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk.directory !== dir || params.id !== id) return
untrack(() => {
void sync.session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
},
{ defer: true },
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -1658,6 +1693,8 @@ export default function Page() {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)

View File

@@ -1,5 +1,25 @@
import { execFile } from "node:child_process"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import type { Configuration } from "electron-builder"
const execFileAsync = promisify(execFile)
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const signScript = path.join(rootDir, "script", "sign-windows.ps1")
async function signWindows(configuration: { path: string }) {
if (process.platform !== "win32") return
if (process.env.GITHUB_ACTIONS !== "true") return
await execFileAsync(
"pwsh",
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", signScript, configuration.path],
{ cwd: rootDir },
)
}
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -44,6 +64,9 @@ const getBase = (): Configuration => ({
},
win: {
icon: `resources/icons/icon.ico`,
signtoolOptions: {
sign: signWindows,
},
target: ["nsis"],
},
nsis: {

View File

@@ -13,11 +13,12 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "resources/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))

View File

@@ -63,6 +63,9 @@ export async function copyBinaryToSidecarFolder(source: string) {
await $`mkdir -p ${dir}`
const dest = windowsify(`${dir}/opencode-cli`)
await $`cp ${source} ${dest}`
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${source} to ${dest}`)

View File

@@ -10,10 +10,11 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "src-tauri/target/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))

View File

@@ -48,6 +48,9 @@ export async function copyBinaryToSidecarFolder(source: string, target = RUST_TA
await $`mkdir -p src-tauri/sidecars`
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
await $`cp ${source} ${dest}`
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
console.log(`Copied ${source} to ${dest}`)
}

View File

@@ -12,6 +12,10 @@
"icons/beta/icon.ico"
],
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/beta/icon.ico"
}

View File

@@ -45,6 +45,10 @@
"entitlements": "./entitlements.plist"
},
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/dev/icon.ico",
"headerImage": "assets/nsis-header.bmp",

View File

@@ -12,6 +12,10 @@
"icons/prod/icon.ico"
],
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/prod/icon.ico"
}

View File

@@ -212,8 +212,82 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
Still open and likely worth migrating:
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
- [x] `Session``session/index.ts`
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
Other services not yet migrated:
- [ ] `SessionSummary``session/summary.ts`
- [ ] `SessionTodo``session/todo.ts`
- [ ] `SessionRevert``session/revert.ts`
- [ ] `Instruction``session/instruction.ts`
- [ ] `ShareNext``share/share-next.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Storage``storage/storage.ts`
- [ ] `Workspace``control-plane/workspace.ts`
## Tool interface → Effect
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
1. Migrate each tool to return Effects
2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
- [ ] `task.ts` — MEDIUM: task state management
- [ ] `glob.ts` — LOW: simple async generator
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
- [ ] `skill.ts` — LOW: skill tool adapter
- [ ] `plan.ts` — LOW: plan file operations
## Effect service adoption in already-migrated code
Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
- [ ] `file/index.ts` — 11 calls (the File service itself)
- [ ] `config/config.ts` — 7 calls
- [ ] `auth/index.ts` — 3 calls
- [ ] `skill/index.ts` — 3 calls
- [ ] `file/time.ts` — 1 call
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
- [ ] `format/index.ts` — 1 call
## Filesystem consolidation
`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
Current raw fs users that will convert during tool migration:
- `tool/read.ts` — fs.createReadStream, readline
- `tool/apply_patch.ts` — fs/promises
- `tool/bash.ts` — fs/promises
- `file/ripgrep.ts` — fs/promises
- `storage/storage.ts` — fs/promises
- `patch/index.ts` — fs, fs/promises
## Primitives & utilities
- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code

View File

@@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
- Author package entrypoint is `@opencode-ai/plugin/tui`.
- Internal plugins load inside the CLI app the same way external TUI plugins do.
- Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
## TUI config
@@ -27,6 +29,7 @@ Example:
- `plugin` entries can be either a string spec or `[spec, options]`.
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
- Relative path specs are resolved relative to the config file that declared them.
- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
@@ -46,7 +49,7 @@ Minimal module shape:
```tsx
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => {
api.command.register(() => [
@@ -69,16 +72,20 @@ const tui: TuiPlugin = async (api, options, meta) => {
])
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id: "acme.demo",
tui,
}
export default plugin
```
- Loader only reads the module default export object. Named exports are ignored.
- TUI shape is `default export { id?, tui }`.
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
- A single module cannot export both `server` and `tui`.
- `tui` signature is `(api, options, meta) => Promise<void>`.
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
- File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used.
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
@@ -137,6 +144,7 @@ npm plugins can declare a version compatibility range in `package.json` using th
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
- Tuple targets in `oc-plugin` provide default options written into config.
- A package can target `server`, `tui`, or both.
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
- There is no uninstall, list, or update CLI command for external plugins.
- Local file plugins are configured directly in `tui.json`.

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
@@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,5 +1,5 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo } from "solid-js"
const id = "internal:sidebar-context"
@@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-files"
@@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Global } from "@/global"
@@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-lsp"
@@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
const id = "internal:sidebar-mcp"
@@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
import { TodoItem } from "../../component/todo-item"
@@ -40,7 +40,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,9 +1,9 @@
import { Keybind } from "@/util/keybind"
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { createEffect, createMemo, createSignal } from "solid-js"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
const id = "internal:plugin-manager"
const key = Keybind.parse("space").at(0)
@@ -53,11 +53,17 @@ function Install(props: { api: TuiPluginApi }) {
<props.api.ui.DialogPrompt
title="Install plugin"
placeholder="npm package name"
busy={busy()}
busyText="Installing plugin..."
description={() => (
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text>
<text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
{global() ? "global" : "local"}
</text>
<Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
</Show>
</box>
)}
onConfirm={(raw) => {
@@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => {
])
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -20,10 +20,10 @@ import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
@@ -231,9 +231,7 @@ async function loadExternalPlugin(
const mod = await import(entry)
.then((raw) => {
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
return mod
return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
})
.catch((error) => {
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
@@ -566,16 +564,13 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
}
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
// TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
const plugin = load.module.tui
if (!plugin) return []
const options = load.item ? Config.pluginOptions(load.item) : undefined
return [
{
id: load.id,
load,
meta,
plugin,
plugin: load.module.tui,
options,
enabled: true,
},

View File

@@ -1,14 +1,17 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount, type JSX } from "solid-js"
import { Show, createEffect, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Spinner } from "../component/spinner"
export type DialogPromptProps = {
title: string
description?: () => JSX.Element
placeholder?: string
value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
@@ -19,6 +22,12 @@ export function DialogPrompt(props: DialogPromptProps) {
let textarea: TextareaRenderable
useKeyboard((evt) => {
if (props.busy) {
if (evt.name === "escape") return
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "return") {
props.onConfirm?.(textarea.plainText)
}
@@ -28,11 +37,21 @@ export function DialogPrompt(props: DialogPromptProps) {
dialog.setSize("medium")
setTimeout(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) return
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
createEffect(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) {
textarea.blur()
return
}
textarea.focus()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) {
{props.description}
<textarea
onSubmit={() => {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
/>
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</box>
</box>
)

View File

@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
@@ -344,8 +343,6 @@ export namespace File {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
Effect.succeed({
@@ -515,54 +512,57 @@ export namespace File {
})
const read = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
if (exists) {
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text" as const,
content: Buffer.from(bytes).toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64" as const,
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
}
return { type: "text" as const, content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
const exists = yield* appFs.existsSafe(full)
if (!exists) return { type: "text" as const, content: "" }
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
if (encode) {
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
return {
type: "text" as const,
content: Buffer.from(bytes).toString("base64"),
mimeType,
encoding: "base64" as const,
}
}
const content = yield* appFs.readFileString(full).pipe(
Effect.map((s) => s.trim()),
Effect.catch(() => Effect.succeed("")),
)
if (Instance.project.vcs === "git") {
return yield* Effect.promise(async (): Promise<File.Content> => {
if (Instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text()
@@ -579,51 +579,60 @@ export namespace File {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text", content, patch, diff: formatPatch(patch) }
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
}
return { type: "text", content }
})
}
}
return { type: "text" as const, content }
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (Instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(Instance.project.worktree, ".gitignore")
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
if (gitignoreText) ig.add(gitignoreText)
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
if (ignoreText) ig.add(ignoreText)
ignored = ig.ignores.bind(ig)
}
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (Instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(Instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(Instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
})
}
const nodes: File.Node[] = []
for (const entry of entries) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(Instance.directory, absolute)
const type = entry.type === "directory" ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
@@ -667,9 +676,7 @@ export namespace File {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
const { runPromise } = makeRuntime(Service, layer)
export function init() {
return runPromise((svc) => svc.init())

View File

@@ -1,7 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
import type { PlatformError } from "effect/PlatformError"
@@ -15,20 +14,13 @@ export namespace AppFileSystem {
export type Error = PlatformError | FileSystemError
export interface DirEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
export interface Interface extends FileSystem.FileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean>
readonly isFile: (path: string) => Effect.Effect<boolean>
readonly existsSafe: (path: string) => Effect.Effect<boolean>
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
@@ -43,10 +35,6 @@ export namespace AppFileSystem {
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
})
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
@@ -57,19 +45,6 @@ export namespace AppFileSystem {
return info?.type === "File"
})
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
return yield* Effect.tryPromise({
try: async () => {
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
return entries.map((e): DirEntry => ({
name: e.name,
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
}))
},
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
})
})
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return JSON.parse(text)
@@ -160,10 +135,8 @@ export namespace AppFileSystem {
return Service.of({
...fs,
existsSafe,
isDir,
isFile,
readDirectoryEntries,
readJson,
writeJson,
ensureDir,

View File

@@ -1,4 +1,6 @@
import { Effect, Layer, ServiceMap } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import path from "path"
@@ -6,7 +8,6 @@ import { mergeDeep } from "remeda"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
@@ -36,6 +37,7 @@ export namespace Format {
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) {
@@ -98,38 +100,45 @@ export namespace Format {
return checks.filter((x) => x.enabled).map((x) => x.item)
}
async function formatFile(filepath: string) {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
function formatFile(filepath: string) {
return Effect.gen(function* () {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", filepath)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
log.info("running", { command: item.command })
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
const code = yield* spawner
.spawn(
ChildProcess.make(cmd[0]!, cmd.slice(1), {
cwd: Instance.directory,
env: item.environment,
extendEnv: true,
}),
)
.pipe(
Effect.flatMap((handle) => handle.exitCode),
Effect.scoped,
Effect.catch(() =>
Effect.sync(() => {
log.error("failed to format file", {
error: "spawn failed",
command: item.command,
...item.environment,
file: filepath,
})
return ChildProcessSpawner.ExitCode(1)
}),
),
)
if (code !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file: filepath,
})
}
}
})
}
log.info("init")
@@ -162,14 +171,17 @@ export namespace Format {
const file = Effect.fn("Format.file")(function* (filepath: string) {
const { formatFile } = yield* InstanceState.get(state)
yield* Effect.promise(() => formatFile(filepath))
yield* formatFile(filepath)
})
return Service.of({ init, status, file })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -1,4 +1,5 @@
import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -52,7 +53,7 @@ interface PendingAuth {
}
export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// find the right entry in pendingAuths (which is keyed by oauthState).
@@ -60,6 +61,80 @@ export namespace McpOAuthCallback {
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function cleanupStateIndex(oauthState: string) {
for (const [name, state] of mcpNameToState) {
if (state === oauthState) {
mcpNameToState.delete(name)
break
}
}
}
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(): Promise<void> {
if (server) return
@@ -69,88 +144,14 @@ export namespace McpOAuthCallback {
return
}
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
for (const [name, s] of mcpNameToState) {
if (s === state) {
mcpNameToState.delete(name)
break
}
}
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
// Clean up reverse index
for (const [name, s] of mcpNameToState) {
if (s === state) {
mcpNameToState.delete(name)
break
}
}
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
})
server!.on("error", reject)
})
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
@@ -196,7 +197,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> {
if (server) {
server.stop()
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}

View File

@@ -6,6 +6,7 @@ import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
const log = Log.create({ service: "plugin.codex" })
@@ -241,7 +242,7 @@ interface PendingOAuth {
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof Bun.serve> | undefined
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
@@ -249,77 +250,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
oauthServer = Bun.serve({
port: OAUTH_PORT,
fetch(req) {
const url = new URL(req.url)
oauthServer = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const current = pendingOAuth
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response("Login cancelled", { status: 200 })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
return new Response("Not found", { status: 404 })
},
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(200)
res.end("Login cancelled")
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
})
log.info("codex oauth server started", { port: OAUTH_PORT })
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.stop()
oauthServer.close(() => {
log.info("codex oauth server stopped")
})
oauthServer = undefined
log.info("codex oauth server stopped")
}
}

View File

@@ -17,12 +17,15 @@ import { errorMessage } from "@/util/error"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
type PluginSource,
} from "./shared"
export namespace Plugin {
@@ -35,6 +38,8 @@ export namespace Plugin {
type Loaded = {
item: Config.PluginSpec
spec: string
target: string
source: PluginSource
mod: Record<string, unknown>
}
@@ -112,7 +117,8 @@ export namespace Plugin {
const resolved = await resolvePlugin(spec)
if (!resolved) return
if (pluginSource(spec) === "npm") {
const source = pluginSource(spec)
if (source === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false)
.catch((err) => {
@@ -156,14 +162,17 @@ export namespace Plugin {
return {
item,
spec,
target,
source,
mod,
}
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
if (plugin?.server) {
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
return
}

View File

@@ -21,6 +21,7 @@ export function parsePluginSpecifier(spec: string) {
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
type PluginMode = "strict" | "detect"
export function pluginSource(spec: string): PluginSource {
return spec.startsWith("file://") ? "file" : "npm"
@@ -123,6 +124,40 @@ export function readPluginId(id: unknown, spec: string) {
return value
}
export function readV1Plugin(
mod: Record<string, unknown>,
spec: string,
kind: PluginKind,
mode: PluginMode = "strict",
) {
const value = mod.default
if (!isRecord(value)) {
if (mode === "detect") return
throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`)
}
if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") {
throw new TypeError(`Plugin ${spec} has invalid server export`)
}
if (tui !== undefined && typeof tui !== "function") {
throw new TypeError(`Plugin ${spec} has invalid tui export`)
}
if (server !== undefined && tui !== undefined) {
throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`)
}
if (kind === "server" && server === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with server()`)
}
if (kind === "tui" && tui === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
}
return value
}
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
if (source === "file") {
if (id) return id
@@ -135,15 +170,3 @@ export async function resolvePluginId(source: PluginSource, spec: string, target
}
return pkg.json.name.trim()
}
export function getDefaultPlugin(mod: Record<string, unknown>) {
// A single default object keeps v1 detection explicit and avoids scanning exports.
const value = mod.default
if (!isRecord(value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") return
if (tui !== undefined && typeof tui !== "function") return
if (server === undefined && tui === undefined) return
return value
}

View File

@@ -16,6 +16,8 @@ import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
@@ -30,153 +32,194 @@ export namespace SessionCompaction {
}
const COMPACTION_BUFFER = 20_000
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
const config = await Config.get()
if (config.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count =
input.tokens.total ||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
const reserved =
config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
const usable = input.model.limit.input
? input.model.limit.input - reserved
: context - ProviderTransform.maxOutputTokens(input.model)
return count >= usable
}
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: SessionID }) {
const config = await Config.get()
if (config.compaction?.prune === false) return
log.info("pruning")
const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
if (NotFoundError.isInstance(err)) return undefined
throw err
})
if (!msgs) return
let total = 0
let pruned = 0
const toPrune = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
await Session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
export interface Interface {
readonly isOverflow: (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) => Effect.Effect<boolean>
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
readonly process: (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) => Effect.Effect<"continue" | "stop">
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
}
export async function process(input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) {
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionCompaction") {}
let messages = input.messages
let replay: MessageV2.WithParts | undefined
if (input.overflow) {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
replay = msg
messages = input.messages.slice(0, i)
break
export const layer: Layer.Layer<
Service,
never,
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) {
const cfg = yield* config.get()
if (cfg.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count =
input.tokens.total ||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
const reserved =
cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
const usable = input.model.limit.input
? input.model.limit.input - reserved
: context - ProviderTransform.maxOutputTokens(input.model)
return count >= usable
})
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
if (cfg.compaction?.prune === false) return
log.info("pruning")
const msgs = yield* session
.messages({ sessionID: input.sessionID })
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
if (!msgs) return
let total = 0
let pruned = 0
const toPrune: MessageV2.ToolPart[] = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
}
}
const agent = await Agent.get("compaction")
const model = agent.model
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
const msg = (await Session.updateMessage({
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
})) as MessageV2.Assistant
const processor = SessionProcessor.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
abort: input.abort,
})
// Allow plugins to inject context or replace compaction prompt
const compacting = await Plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
yield* session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
})
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) {
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
let messages = input.messages
let replay: MessageV2.WithParts | undefined
if (input.overflow) {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
replay = msg
messages = input.messages.slice(0, i)
break
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
}
}
const agent = yield* agents.get("compaction")
const model = yield* Effect.promise(() =>
agent.model
? Provider.getModel(agent.model.providerID, agent.model.modelID)
: Provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
)
const msg = (yield* session.updateMessage({
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
})) as MessageV2.Assistant
const processor = SessionProcessor.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
abort: input.abort,
})
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work.
@@ -204,133 +247,179 @@ When constructing the summary, try to stick to this template:
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
---`
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages)
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const result = await processor.process({
user: userMessage,
agent,
abort: input.abort,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...(await MessageV2.toModelMessages(msgs, model, { stripMedia: true })),
{
role: "user",
content: [
{
type: "text",
text: promptText,
},
],
},
],
model,
})
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media",
}).toObject()
processor.message.finish = "error"
await Session.updateMessage(processor.message)
return "stop"
}
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info as MessageV2.User
const replayMsg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: original.agent,
model: original.model,
format: original.format,
tools: original.tools,
system: original.system,
variant: original.variant,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
await Session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
const result = yield* Effect.promise(() =>
processor.process({
user: userMessage,
agent,
abort: input.abort,
sessionID: input.sessionID,
})
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
},
],
model,
}),
)
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media",
}).toObject()
processor.message.finish = "error"
yield* session.updateMessage(processor.message)
return "stop"
}
} else {
const continueMsg = await Session.updateMessage({
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info as MessageV2.User
const replayMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: original.agent,
model: original.model,
format: original.format,
tools: original.tools,
system: original.system,
variant: original.variant,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
yield* session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
sessionID: input.sessionID,
})
}
}
if (!replay) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
}
if (processor.message.error) return "stop"
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
return result
})
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
await Session.updatePart({
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
overflow: input.overflow,
})
}
}
if (processor.message.error) return "stop"
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
return "continue"
})
return Service.of({
isOverflow,
prune,
process: processCompaction,
create,
})
}),
)
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
return runPromise((svc) => svc.isOverflow(input))
}
export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export async function process(input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) {
return runPromise((svc) => svc.process(input))
}
export const create = fn(
z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
}),
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
async (input) => {
const msg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: {
created: Date.now(),
},
})
await Session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
overflow: input.overflow,
})
},
(input) => runPromise((svc) => svc.create(input)),
)
}

View File

@@ -130,3 +130,60 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("rejects npm tui plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "mixed-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
id: "demo.mixed",
server: async () => ({}),
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec],
plugin_meta: {
[tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
},
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -128,6 +128,7 @@ describe("plugin.loader.shared", () => {
file,
[
"export default {",
' id: "demo.v1-default",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "default")`,
" return {}",
@@ -154,6 +155,82 @@ describe("plugin.loader.shared", () => {
expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
})
test("rejects v1 file server plugin without id", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
const errors = await errs(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
})
test("rejects v1 plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
' id: "demo.mixed",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "server")`,
" return {}",
" },",
" tui: async () => {},",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
const errors = await errs(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
})
test("resolves npm plugin specs with explicit and default versions", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -1,15 +1,30 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import path from "path"
import { Bus } from "../../src/bus"
import { SessionCompaction } from "../../src/session/compaction"
import { Token } from "../../src/util/token"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Provider } from "../../src/provider/provider"
import * as ProviderModule from "../../src/provider/provider"
import * as SessionProcessorModule from "../../src/session/processor"
Log.init({ print: false })
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
}
afterEach(() => {
mock.restore()
})
function createModel(opts: {
context: number
output: number
@@ -40,6 +55,105 @@ function createModel(opts: {
} as Provider.Model
}
async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
})
await Session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
async function assistant(sessionID: SessionID, parentID: MessageID, root: string) {
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "build",
agent: "build",
path: { cwd: root, root },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID,
time: { created: Date.now() },
finish: "end_turn",
}
await Session.updateMessage(msg)
return msg
}
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
return Session.updatePart({
id: PartID.ascending(),
messageID,
sessionID,
type: "tool",
callID: crypto.randomUUID(),
tool,
state: {
status: "completed",
input: {},
output,
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
}
function fake(
input: Parameters<(typeof SessionProcessorModule.SessionProcessor)["create"]>[0],
result: "continue" | "compact",
): ReturnType<(typeof SessionProcessorModule.SessionProcessor)["create"]> {
const msg = input.assistantMessage
return {
get message() {
return msg
},
partFromToolCall() {
return {
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "tool",
callID: "fake",
tool: "fake",
state: { status: "pending", input: {}, raw: "" },
}
},
process: async () => result,
}
}
function wait(ms = 50) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function defer() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
describe("session.compaction.isOverflow", () => {
test("returns true when token count exceeds usable context", async () => {
await using tmp = await tmpdir()
@@ -227,6 +341,272 @@ describe("session.compaction.isOverflow", () => {
})
})
describe("session.compaction.create", () => {
test("creates a compaction user message and part", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
})
const msgs = await Session.messages({ sessionID: session.id })
expect(msgs).toHaveLength(1)
expect(msgs[0].info.role).toBe("user")
expect(msgs[0].parts).toHaveLength(1)
expect(msgs[0].parts[0]).toMatchObject({
type: "compaction",
auto: true,
overflow: true,
})
},
})
})
})
describe("session.compaction.prune", () => {
test("compacts old completed tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "bash", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
await SessionCompaction.prune({ sessionID: session.id })
const msgs = await Session.messages({ sessionID: session.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeNumber()
}
},
})
})
test("skips protected skill tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "skill", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
await SessionCompaction.prune({ sessionID: session.id })
const msgs = await Session.messages({ sessionID: session.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeUndefined()
}
},
})
})
})
describe("session.compaction.process", () => {
test("publishes compacted event on continue", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const done = defer()
let seen = false
const unsub = Bus.subscribe(SessionCompaction.Event.Compacted, (evt) => {
if (evt.properties.sessionID !== session.id) return
seen = true
done.resolve()
})
const result = await SessionCompaction.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
abort: new AbortController().signal,
auto: false,
})
await Promise.race([
done.promise,
wait(500).then(() => {
throw new Error("timed out waiting for compacted event")
}),
])
unsub()
expect(result).toBe("continue")
expect(seen).toBe(true)
},
})
})
test("marks summary message as errored on compact result", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "compact"))
const session = await Session.create({})
const msg = await user(session.id, "hello")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: false,
})
const summary = (await Session.messages({ sessionID: session.id })).find(
(msg) => msg.info.role === "assistant" && msg.info.summary,
)
expect(result).toBe("stop")
expect(summary?.info.role).toBe("assistant")
if (summary?.info.role === "assistant") {
expect(summary.info.finish).toBe("error")
expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact")
}
},
})
})
test("adds synthetic continue prompt when auto is enabled", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
const msg = await user(session.id, "hello")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: true,
})
const msgs = await Session.messages({ sessionID: session.id })
const last = msgs.at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
expect(last?.parts[0]).toMatchObject({
type: "text",
synthetic: true,
})
if (last?.parts[0]?.type === "text") {
expect(last.parts[0].text).toContain("Continue if you have next steps")
}
},
})
})
test("replays the prior user turn on overflow when earlier context exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
await user(session.id, "root")
const replay = await user(session.id, "image")
await Session.updatePart({
id: PartID.ascending(),
messageID: replay.id,
sessionID: session.id,
type: "file",
mime: "image/png",
filename: "cat.png",
url: "https://example.com/cat.png",
})
const msg = await user(session.id, "current")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: true,
overflow: true,
})
const last = (await Session.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
expect(last?.parts.some((part) => part.type === "file")).toBe(false)
expect(
last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")),
).toBe(true)
},
})
})
test("falls back to overflow guidance when no replayable turn exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
await user(session.id, "earlier")
const msg = await user(session.id, "current")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: true,
overflow: true,
})
const last = (await Session.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
if (last?.parts[0]?.type === "text") {
expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit")
}
},
})
})
})
describe("util.token.estimate", () => {
test("estimates tokens from text (4 chars per token)", () => {
const text = "x".repeat(4000)

View File

@@ -42,7 +42,8 @@ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Ho
export type PluginModule = {
id?: string
server?: Plugin
server: Plugin
tui?: never
}
type Rule = {

View File

@@ -15,7 +15,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
import type { JSX, SolidPlugin } from "@opentui/solid"
import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js"
import type { Config as PluginConfig, PluginOptions } from "./index.js"
export type { CliRenderer, SlotMode } from "@opentui/core"
@@ -107,6 +107,8 @@ export type TuiDialogPromptProps = {
description?: () => JSX.Element
placeholder?: string
value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
@@ -414,6 +416,8 @@ export type TuiPluginApi = {
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
export type TuiPluginModule = PluginModule & {
tui?: TuiPlugin
export type TuiPluginModule = {
id?: string
tui: TuiPlugin
server?: never
}

View File

@@ -1 +0,0 @@
BlexMonoNerdFontMono-Bold.woff2

View File

@@ -1 +0,0 @@
BlexMonoNerdFontMono-Medium.woff2

View File

@@ -1 +0,0 @@
BlexMonoNerdFontMono-Regular.woff2

View File

@@ -2,24 +2,24 @@
import * as mod from "./font"
const docs = `### Overview
Loads OpenCode typography assets and mono nerd fonts.
Uses native system font stacks for sans and mono typography.
Render once at the app root or Storybook preview.
Optional compatibility component. Existing roots can keep rendering it, but it does nothing.
### API
- No props.
### Variants and states
- Fonts include sans and multiple mono families.
- No variants.
### Behavior
- Injects @font-face rules and preload links into the document head.
- Compatibility wrapper only. No font assets are injected or preloaded.
### Accessibility
- Not applicable.
### Theming/tokens
- Provides font families used by theme tokens.
- Theme tokens come from CSS variables, not this component.
`

View File

@@ -1,63 +1 @@
import { Link, Style } from "@solidjs/meta"
import { Show } from "solid-js"
import inter from "../assets/fonts/inter.woff2"
import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2"
import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
export const Font = () => {
return (
<>
<Style>{`
@font-face {
font-family: "Inter";
src: url("${inter}") format("woff2-variations");
font-display: swap;
font-style: normal;
font-weight: 100 900;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 100%;
ascent-override: 97%;
descent-override: 25%;
line-gap-override: 1%;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("${ibmPlexMonoRegular}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("${ibmPlexMonoMedium}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 500;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("${ibmPlexMonoBold}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 700;
}
@font-face {
font-family: "IBM Plex Mono Fallback";
src: local("Courier New");
size-adjust: 100%;
ascent-override: 97%;
descent-override: 25%;
line-gap-override: 1%;
}
`}</Style>
<Show when={typeof location === "undefined" || location.protocol !== "file:"}>
<Link rel="preload" href={inter} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoRegular} as="font" type="font/woff2" crossorigin="anonymous" />
</Show>
</>
)
}
export const Font = () => null

View File

@@ -1,8 +1,9 @@
:root {
--font-family-sans: "Inter", "Inter Fallback";
--font-family-sans--font-feature-settings: "ss03" 1;
--font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback";
--font-family-mono--font-feature-settings: "ss01" 1;
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-family-sans--font-feature-settings: normal;
--font-family-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-family-mono--font-feature-settings: normal;
--font-size-small: 13px;
--font-size-base: 14px;

70
script/sign-windows.ps1 Normal file
View File

@@ -0,0 +1,70 @@
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]] $Path
)
$ErrorActionPreference = "Stop"
if (-not $Path -or $Path.Count -eq 0) {
throw "At least one path is required"
}
if ($env:GITHUB_ACTIONS -ne "true") {
Write-Host "Skipping Windows signing because this is not running on GitHub Actions"
exit 0
}
$vars = @{
endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
profile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
}
if ($vars.Values | Where-Object { -not $_ }) {
Write-Host "Skipping Windows signing because Azure Artifact Signing is not configured"
exit 0
}
$moduleVersion = "0.5.8"
$module = Get-Module -ListAvailable -Name TrustedSigning | Where-Object { $_.Version -eq [version] $moduleVersion }
if (-not $module) {
try {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
}
catch {
Write-Host "NuGet package provider install skipped: $($_.Exception.Message)"
}
Install-Module -Name TrustedSigning -RequiredVersion $moduleVersion -Force -Repository PSGallery -Scope CurrentUser
}
Import-Module TrustedSigning -RequiredVersion $moduleVersion -Force
$files = @($Path | ForEach-Object { Resolve-Path $_ -ErrorAction SilentlyContinue } | Select-Object -ExpandProperty Path -Unique)
if (-not $files -or $files.Count -eq 0) {
throw "No files matched the requested paths"
}
$params = @{
Endpoint = $vars.endpoint
CodeSigningAccountName = $vars.account
CertificateProfileName = $vars.profile
Files = ($files -join ",")
FileDigest = "SHA256"
TimestampDigest = "SHA256"
TimestampRfc3161 = "http://timestamp.acs.microsoft.com"
ExcludeEnvironmentCredential = $true
ExcludeWorkloadIdentityCredential = $true
ExcludeManagedIdentityCredential = $true
ExcludeSharedTokenCacheCredential = $true
ExcludeVisualStudioCredential = $true
ExcludeVisualStudioCodeCredential = $true
ExcludeAzureCliCredential = $false
ExcludeAzurePowerShellCredential = $true
ExcludeAzureDeveloperCliCredential = $true
ExcludeInteractiveBrowserCredential = $true
}
Invoke-TrustedSigning @params