Merge dev into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory 2026-02-14 06:28:48 -05:00
commit 377812b98a
180 changed files with 7193 additions and 1921 deletions

View file

@ -16,15 +16,12 @@ wip:
For anything in the packages/web use the docs: prefix. For anything in the packages/web use the docs: prefix.
For anything in the packages/app use the ignore: prefix.
prefer to explain WHY something was done from an end user perspective instead of prefer to explain WHY something was done from an end user perspective instead of
WHAT was done. WHAT was done.
do not do generic messages like "improved agent experience" be very specific do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made about what user facing changes were made
if there are changes do a git pull --rebase
if there are conflicts DO NOT FIX THEM. notify me and I will fix them if there are conflicts DO NOT FIX THEM. notify me and I will fix them
## GIT DIFF ## GIT DIFF

View file

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

View file

@ -110,3 +110,4 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث) brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل) brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # اي نظام mise use -g opencode # اي نظام
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado) brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos) brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # qualquer sistema mise use -g opencode # qualquer sistema
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
``` ```

View file

@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno) brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira) brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Bilo koji OS mise use -g opencode # Bilo koji OS
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date) brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere) brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell) brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert) brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # jedes Betriebssystem mise use -g opencode # jedes Betriebssystem
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día) brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos) brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # cualquier sistema mise use -g opencode # cualquier sistema
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour) brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente) brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # n'importe quel OS mise use -g opencode # n'importe quel OS
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato) brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso) brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Qualsiasi OS mise use -g opencode # Qualsiasi OS
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS と Linux推奨。常に最新 brew install anomalyco/tap/opencode # macOS と Linux推奨。常に最新
brew install opencode # macOS と Linux公式 brew formula。更新頻度は低め brew install opencode # macOS と Linux公式 brew formula。更新頻度は低め
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # どのOSでも mise use -g opencode # どのOSでも
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신) brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음) brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 어떤 OS든 mise use -g opencode # 어떤 OS든
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치 nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
``` ```

View file

@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less) brew install opencode # macOS and Linux (official brew formula, updated less)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Any OS mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert) brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere) brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne) brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana) brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # dowolny system mise use -g opencode # dowolny system
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально) brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже) brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # любая ОС mise use -g opencode # любая ОС
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ) brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า) brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # ระบบปฏิบัติการใดก็ได้ mise use -g opencode # ระบบปฏิบัติการใดก็ได้
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel) brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir) brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Tüm işletim sistemleri mise use -g opencode # Tüm işletim sistemleri
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux推荐始终保持最新 brew install anomalyco/tap/opencode # macOS 和 Linux推荐始终保持最新
brew install opencode # macOS 和 Linux官方 brew formula更新频率较低 brew install opencode # macOS 和 Linux官方 brew formula更新频率较低
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任意系统 mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支 nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
``` ```

View file

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux推薦始終保持最新 brew install anomalyco/tap/opencode # macOS 與 Linux推薦始終保持最新
brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低 brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低
paru -S opencode-bin # Arch Linux sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任何作業系統 mise use -g opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支 nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
``` ```

599
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-XIf7b6yALzH1/MkGGrsmq2DeXIC9vgD9a7D/dxhi6iU=", "x86_64-linux": "sha256-hVf8rBEqy3q4xexOqyKDtKmlMydl1hFoDV0JiEvmfgs=",
"aarch64-linux": "sha256-mKDCs6QhIelWc3E17zOufaSDTovtjO/Xyh3JtlWl01s=", "aarch64-linux": "sha256-4m3UZllEmfJXB70cOgIoyWRIYMXxGzzenyOfF3kEQKk=",
"aarch64-darwin": "sha256-wC7bbbIyZ62uMxTr9FElTbEBMrfz0S/ndqwZZ3V9EOA=", "aarch64-darwin": "sha256-27xGR9+FVnC0rsUIyepk2tCP1eEUmGvqWUGAZ+rk7IQ=",
"x86_64-darwin": "sha256-/7Nn65m5Zhvzz0TKsG9nWd2v5WDHQNi3UzCfuAR8SLo=" "x86_64-darwin": "sha256-+At7bHSeg6QJu6yGawyvzt53Tu/fddDg6Ms+xhaMLhY="
} }
} }

View file

@ -40,6 +40,8 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",

View file

@ -0,0 +1,43 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl } from "../actions"
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.64", "version": "1.2.1",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {

View file

@ -1,5 +1,5 @@
import "@/index.css" import "@/index.css"
import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js" import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router" import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
@ -156,8 +156,11 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
if (!server.url) return null return (
return props.children <Show when={server.url} keyed>
{props.children}
</Show>
)
} }
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {

View file

@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const send = async () => { const send = async () => {
const ok = await waitForWorktree() const ok = await waitForWorktree()
if (!ok) return if (!ok) return
await client.session.prompt({ await client.session.promptAsync({
sessionID: session.id, sessionID: session.id,
agent, agent,
model, model,

View file

@ -552,7 +552,7 @@ export function SessionHeader() {
</Show> </Show>
</div> </div>
</Show> </Show>
<div class="hidden lg:flex items-center gap-3 ml-2 shrink-0"> <div class="flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind <TooltipKeybind
title={language.t("command.terminal.toggle")} title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")} keybind={command.keybind("terminal.toggle")}

View file

@ -156,6 +156,10 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
let fitFrame: number | undefined
let sizeTimer: ReturnType<typeof setTimeout> | undefined
let pendingSize: { cols: number; rows: number } | undefined
let lastSize: { cols: number; rows: number } | undefined
let disposed = false let disposed = false
const cleanups: VoidFunction[] = [] const cleanups: VoidFunction[] = []
const start = const start =
@ -209,6 +213,43 @@ export const Terminal = (props: TerminalProps) => {
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors()) const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
const scheduleFit = () => {
if (disposed) return
if (!fitAddon) return
if (fitFrame !== undefined) return
fitFrame = requestAnimationFrame(() => {
fitFrame = undefined
if (disposed) return
fitAddon.fit()
})
}
const scheduleSize = (cols: number, rows: number) => {
if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) return
pendingSize = { cols, rows }
if (!lastSize) {
lastSize = pendingSize
void pushSize(cols, rows)
return
}
if (sizeTimer !== undefined) return
sizeTimer = setTimeout(() => {
sizeTimer = undefined
const next = pendingSize
if (!next) return
pendingSize = undefined
if (disposed) return
if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
lastSize = next
void pushSize(next.cols, next.rows)
}, 100)
}
createEffect(() => { createEffect(() => {
const colors = getTerminalColors() const colors = getTerminalColors()
setTerminalColors(colors) setTerminalColors(colors)
@ -220,6 +261,16 @@ export const Terminal = (props: TerminalProps) => {
const font = monoFontFamily(settings.appearance.font()) const font = monoFontFamily(settings.appearance.font())
if (!term) return if (!term) return
setOptionIfSupported(term, "fontFamily", font) setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
})
let zoom = platform.webviewZoom?.()
createEffect(() => {
const next = platform.webviewZoom?.()
if (next === undefined) return
if (next === zoom) return
zoom = next
scheduleFit()
}) })
const focusTerminal = () => { const focusTerminal = () => {
@ -263,25 +314,6 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false } const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
ws = socket
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
restore && restore &&
@ -344,39 +376,16 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal() focusTerminal()
const startResize = () => { if (typeof document !== "undefined" && document.fonts) {
fit.observeResize() document.fonts.ready.then(scheduleFit)
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
} }
if (restore && restoreSize) { const onResize = t.onResize((size) => {
t.write(restore, () => { scheduleSize(size.cols, size.rows)
fit.fit()
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
}
startResize()
}
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await pushSize(size.cols, size.rows)
}
}) })
cleanups.push(() => disposeIfDisposable(onResize)) cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => { const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) ws.send(data)
socket.send(data)
}
}) })
cleanups.push(() => disposeIfDisposable(onData)) cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => { const onKey = t.onKey((key) => {
@ -385,17 +394,64 @@ export const Terminal = (props: TerminalProps) => {
} }
}) })
cleanups.push(() => disposeIfDisposable(onKey)) cleanups.push(() => disposeIfDisposable(onKey))
const startResize = () => {
fit.observeResize()
handleResize = scheduleFit
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
}
startResize()
}
// t.onScroll((ydisp) => { // t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp) // console.log("Scroll position:", ydisp)
// }) // })
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
const handleOpen = () => { const handleOpen = () => {
local.onConnect?.() local.onConnect?.()
void pushSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
} }
socket.addEventListener("open", handleOpen) socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen)) cleanups.push(() => socket.removeEventListener("open", handleOpen))
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
@ -462,6 +518,8 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => { onCleanup(() => {
disposed = true disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush() output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup() cleanup()
@ -477,7 +535,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{ classList={{
...(local.classList ?? {}), ...(local.classList ?? {}),
"select-text": true, "select-text": true,
"size-full px-6 py-3 font-mono": true, "size-full px-6 py-3 font-mono relative overflow-hidden": true,
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,
}} }}
{...others} {...others}

View file

@ -231,6 +231,24 @@ export function applyDirectoryEvent(input: {
} }
break break
} }
case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore(
"part",
props.messageID,
produce((draft) => {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
}),
)
break
}
case "vcs.branch.updated": { case "vcs.branch.updated": {
const props = event.properties as { branch: string } const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break if (input.store.vcs?.branch === props.branch) break

View file

@ -57,6 +57,10 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary> type Dictionary = i18n.Flatten<RawDictionary>
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
}
const LOCALES: readonly Locale[] = [ const LOCALES: readonly Locale[] = [
"en", "en",
"zh", "zh",
@ -199,6 +203,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
createEffect(() => { createEffect(() => {
if (typeof document !== "object") return if (typeof document !== "object") return
document.documentElement.lang = locale() document.documentElement.lang = locale()
document.cookie = cookie(locale())
}) })
return { return {

View file

@ -1758,7 +1758,7 @@ export default function Page() {
</div> </div>
<TerminalPanel <TerminalPanel
open={isDesktop() && view().terminal.opened()} open={view().terminal.opened()}
height={layout.terminal.height()} height={layout.terminal.height()}
resize={layout.terminal.resize} resize={layout.terminal.resize}
close={view().terminal.close} close={view().terminal.close}

View file

@ -1,7 +1,7 @@
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { checksum } from "@opencode-ai/util/encode" import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
@ -49,7 +49,7 @@ export function FileTabContent(props: {
return props.file.get(p) return props.file.get(p)
}) })
const contents = createMemo(() => state()?.content?.content ?? "") const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => checksum(contents())) const cacheKey = createMemo(() => sampledChecksum(contents()))
const isImage = createMemo(() => { const isImage = createMemo(() => {
const c = state()?.content const c = state()?.content
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
@ -163,11 +163,20 @@ export function FileTabContent(props: {
return return
} }
const estimateTop = (range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const height = 24
const offset = 2
return Math.max(0, (line - 1) * height + offset)
}
const large = contents().length > 500_000
const next: Record<string, number> = {} const next: Record<string, number> = {}
for (const comment of fileComments()) { for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection) const marker = findMarker(root, comment.selection)
if (!marker) continue if (marker) next[comment.id] = markerTop(el, marker)
next[comment.id] = markerTop(el, marker) else if (large) next[comment.id] = estimateTop(comment.selection)
} }
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined) const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
@ -194,12 +203,12 @@ export function FileTabContent(props: {
} }
const marker = findMarker(root, range) const marker = findMarker(root, range)
if (!marker) { if (marker) {
setNote("draftTop", undefined) setNote("draftTop", markerTop(el, marker))
return return
} }
setNote("draftTop", markerTop(el, marker)) setNote("draftTop", large ? estimateTop(range) : undefined)
} }
const scheduleComments = () => { const scheduleComments = () => {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.64", "version": "1.2.1",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -110,6 +110,7 @@ export async function handler(
providerInfo.modifyBody({ providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body), ...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model, model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
}), }),
) )
logger.debug("REQUEST URL: " + reqUrl) logger.debug("REQUEST URL: " + reqUrl)
@ -274,8 +275,8 @@ export async function handler(
part = part.trim() part = part.trim()
usageParser.parse(part) usageParser.parse(part)
if (providerInfo.bodyModifier) { if (providerInfo.responseModifier) {
for (const [k, v] of Object.entries(providerInfo.bodyModifier)) { for (const [k, v] of Object.entries(providerInfo.responseModifier)) {
part = part.replace(k, v) part = part.replace(k, v)
} }
c.enqueue(encoder.encode(part + "\n\n")) c.enqueue(encoder.encode(part + "\n\n"))
@ -285,7 +286,7 @@ export async function handler(
} }
} }
if (!providerInfo.bodyModifier && providerInfo.format === opts.format) { if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
c.enqueue(value) c.enqueue(value)
} }

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.64", "version": "1.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -12,7 +12,7 @@
"@opencode-ai/console-resource": "workspace:*", "@opencode-ai/console-resource": "workspace:*",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
"aws4fetch": "1.0.20", "aws4fetch": "1.0.20",
"drizzle-orm": "0.41.0", "drizzle-orm": "catalog:",
"postgres": "3.4.7", "postgres": "3.4.7",
"stripe": "18.0.0", "stripe": "18.0.0",
"ulid": "catalog:", "ulid": "catalog:",
@ -44,7 +44,7 @@
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0", "@types/bun": "1.3.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"drizzle-kit": "0.30.5", "drizzle-kit": "catalog:",
"mysql2": "3.14.4", "mysql2": "3.14.4",
"typescript": "catalog:", "typescript": "catalog:",
"@typescript/native-preview": "catalog:" "@typescript/native-preview": "catalog:"

View file

@ -4,7 +4,6 @@ export * from "drizzle-orm"
import { Client } from "@planetscale/database" import { Client } from "@planetscale/database"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core" import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import type { ExtractTablesWithRelations } from "drizzle-orm"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless" import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
import { Context } from "../context" import { Context } from "../context"
import { memo } from "../util/memo" import { memo } from "../util/memo"
@ -14,7 +13,7 @@ export namespace Database {
PlanetscaleQueryResultHKT, PlanetscaleQueryResultHKT,
PlanetScalePreparedQueryHKT, PlanetScalePreparedQueryHKT,
Record<string, never>, Record<string, never>,
ExtractTablesWithRelations<Record<string, never>> any
> >
const client = memo(() => { const client = memo(() => {
@ -23,7 +22,7 @@ export namespace Database {
username: Resource.Database.username, username: Resource.Database.username,
password: Resource.Database.password, password: Resource.Database.password,
}) })
const db = drizzle(result, {}) const db = drizzle({ client: result })
return db return db
}) })

View file

@ -62,12 +62,13 @@ export namespace ZenData {
apiKey: z.string(), apiKey: z.string(),
format: FormatSchema.optional(), format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(), headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
family: z.string().optional(), family: z.string().optional(),
}) })
const ProviderFamilySchema = z.object({ const ProviderFamilySchema = z.object({
headers: z.record(z.string(), z.string()).optional(), headers: z.record(z.string(), z.string()).optional(),
bodyModifier: z.record(z.string(), z.string()).optional(), responseModifier: z.record(z.string(), z.string()).optional(),
}) })
const ModelsSchema = z.object({ const ModelsSchema = z.object({

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.64", "version": "1.2.1",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.64", "version": "1.2.1",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.64", "version": "1.2.1",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,10 +1,3 @@
fn main() { fn main() {
if let Ok(git_ref) = std::env::var("GITHUB_REF") {
let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(&git_ref);
if branch == "beta" {
println!("cargo:rustc-env=OPENCODE_SQLITE=1");
}
}
tauri_build::build() tauri_build::build()
} }

View file

@ -566,7 +566,7 @@ async fn initialize(app: AppHandle) {
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor. // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
// Then in the loading task, we wait for sqlite migration to complete before // Then in the loading task, we wait for sqlite migration to complete before
// starting our health check against the server, otherwise long migrations could result in a timeout. // starting our health check against the server, otherwise long migrations could result in a timeout.
let needs_sqlite_migration = option_env!("OPENCODE_SQLITE").is_some() && !sqlite_file_exists(); let needs_sqlite_migration = !sqlite_file_exists();
let sqlite_done = needs_sqlite_migration.then(|| { let sqlite_done = needs_sqlite_migration.then(|| {
tracing::info!( tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(), path = %opencode_db_path().expect("failed to get db path").display(),

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.64", "version": "1.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.1.64" version = "1.2.1"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.1/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.1/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.1/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.1/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.1/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.64", "version": "1.2.1",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,27 +1,10 @@
# opencode agent guidelines # opencode database guide
## Build/Test Commands ## Database
- **Install**: `bun install` - **Schema**: Drizzle schema lives in `src/**/*.sql.ts`.
- **Run**: `bun run --conditions=browser ./src/index.ts` - **Naming**: tables and columns use snake*case; join columns are `<entity>_id`; indexes are `<table>*<column>\_idx`.
- **Typecheck**: `bun run typecheck` (npm run typecheck) - **Migrations**: generated by Drizzle Kit using `drizzle.config.ts` (schema: `./src/**/*.sql.ts`, output: `./migration`).
- **Test**: `bun test` (runs all tests) - **Command**: `bun run db generate --name <slug>`.
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file) - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
## Code Style
- **Runtime**: Bun with TypeScript ESM modules
- **Imports**: Use relative imports for local modules, named imports preferred
- **Types**: Zod schemas for validation, TypeScript interfaces for structure
- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
## Architecture
- **Tools**: Implement `Tool.Info` interface with `execute()` method
- **Context**: Pass `sessionID` in tool context, use `App.provide()` for DI
- **Validation**: All inputs validated with Zod schemas
- **Logging**: Use `Log.create({ service: "name" })` pattern
- **Storage**: Use `Storage` namespace for persistence
- **API Client**: The TypeScript TUI (built with SolidJS + OpenTUI) communicates with the OpenCode server using `@opencode-ai/sdk`. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `./script/generate.ts` to regenerate the SDK and related files.

View file

@ -2,4 +2,6 @@ preload = ["@opentui/solid/preload"]
[test] [test]
preload = ["./test/preload.ts"] preload = ["./test/preload.ts"]
timeout = 30000 # 30 seconds - allow time for package installation # timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
# using --timeout in package.json scripts instead
# https://github.com/oven-sh/bun/issues/7789

View file

@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "sqlite",
schema: "./src/**/*.sql.ts",
out: "./migration",
dbCredentials: {
url: "/home/thdxr/.local/share/opencode/opencode.db",
},
})

View file

@ -0,0 +1,90 @@
CREATE TABLE `project` (
`id` text PRIMARY KEY,
`worktree` text NOT NULL,
`vcs` text,
`name` text,
`icon_url` text,
`icon_color` text,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`time_initialized` integer,
`sandboxes` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `message` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `part` (
`id` text PRIMARY KEY,
`message_id` text NOT NULL,
`session_id` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_part_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `permission` (
`project_id` text PRIMARY KEY,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`project_id` text NOT NULL,
`parent_id` text,
`slug` text NOT NULL,
`directory` text NOT NULL,
`title` text NOT NULL,
`version` text NOT NULL,
`share_url` text,
`summary_additions` integer,
`summary_deletions` integer,
`summary_files` integer,
`summary_diffs` text,
`revert` text,
`permission` text,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`time_compacting` integer,
`time_archived` integer,
CONSTRAINT `fk_session_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `todo` (
`session_id` text NOT NULL,
`content` text NOT NULL,
`status` text NOT NULL,
`priority` text NOT NULL,
`position` integer NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `todo_pk` PRIMARY KEY(`session_id`, `position`),
CONSTRAINT `fk_todo_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `session_share` (
`session_id` text PRIMARY KEY,
`id` text NOT NULL,
`secret` text NOT NULL,
`url` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_session_share_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `message_session_idx` ON `message` (`session_id`);--> statement-breakpoint
CREATE INDEX `part_message_idx` ON `part` (`message_id`);--> statement-breakpoint
CREATE INDEX `part_session_idx` ON `part` (`session_id`);--> statement-breakpoint
CREATE INDEX `session_project_idx` ON `session` (`project_id`);--> statement-breakpoint
CREATE INDEX `session_parent_idx` ON `session` (`parent_id`);--> statement-breakpoint
CREATE INDEX `todo_session_idx` ON `todo` (`session_id`);

View file

@ -0,0 +1,796 @@
{
"version": "7",
"dialect": "sqlite",
"id": "068758ed-a97a-46f6-8a59-6c639ae7c20c",
"prevIds": ["00000000-0000-0000-0000-000000000000"],
"ddl": [
{
"name": "project",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
},
{
"name": "part",
"entityType": "tables"
},
{
"name": "permission",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "todo",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "worktree",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "vcs",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_url",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_color",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_initialized",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sandboxes",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "message_id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "parent_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "slug",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "version",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "share_url",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_additions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_deletions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_files",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_diffs",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "revert",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "permission",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_compacting",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_archived",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "priority",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "position",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_share"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_message_session_id_session_id_fk",
"entityType": "fks",
"table": "message"
},
{
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_part_message_id_message_id_fk",
"entityType": "fks",
"table": "part"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_permission_project_id_project_id_fk",
"entityType": "fks",
"table": "permission"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_project_id_project_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_todo_session_id_session_id_fk",
"entityType": "fks",
"table": "todo"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_share_session_id_session_id_fk",
"entityType": "fks",
"table": "session_share"
},
{
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["project_id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
"entityType": "pks"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "message_session_idx",
"entityType": "indexes",
"table": "message"
},
{
"columns": [
{
"value": "message_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_message_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_session_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "project_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_project_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "parent_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_parent_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "todo_session_idx",
"entityType": "indexes",
"table": "todo"
}
],
"renames": []
}

View file

@ -0,0 +1 @@
ALTER TABLE `project` ADD `commands` text;

View file

@ -0,0 +1,806 @@
{
"version": "7",
"dialect": "sqlite",
"id": "8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb",
"prevIds": ["068758ed-a97a-46f6-8a59-6c639ae7c20c"],
"ddl": [
{
"name": "project",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
},
{
"name": "part",
"entityType": "tables"
},
{
"name": "permission",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "todo",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "worktree",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "vcs",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_url",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_color",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_initialized",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sandboxes",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "commands",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "message_id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "parent_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "slug",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "version",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "share_url",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_additions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_deletions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_files",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_diffs",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "revert",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "permission",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_compacting",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_archived",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "priority",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "position",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_share"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_message_session_id_session_id_fk",
"entityType": "fks",
"table": "message"
},
{
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_part_message_id_message_id_fk",
"entityType": "fks",
"table": "part"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_permission_project_id_project_id_fk",
"entityType": "fks",
"table": "permission"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_project_id_project_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_todo_session_id_session_id_fk",
"entityType": "fks",
"table": "todo"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_share_session_id_session_id_fk",
"entityType": "fks",
"table": "session_share"
},
{
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["project_id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
"entityType": "pks"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "message_session_idx",
"entityType": "indexes",
"table": "message"
},
{
"columns": [
{
"value": "message_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_message_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_session_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "project_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_project_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "parent_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_parent_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "todo_session_idx",
"entityType": "indexes",
"table": "todo"
}
],
"renames": []
}

View file

@ -0,0 +1,11 @@
CREATE TABLE `control_account` (
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`active` integer NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `control_account_pk` PRIMARY KEY(`email`, `url`)
);

View file

@ -0,0 +1,897 @@
{
"version": "7",
"dialect": "sqlite",
"id": "d2736e43-700f-4e9e-8151-9f2f0d967bc8",
"prevIds": ["8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb"],
"ddl": [
{
"name": "control_account",
"entityType": "tables"
},
{
"name": "project",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
},
{
"name": "part",
"entityType": "tables"
},
{
"name": "permission",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "todo",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token_expiry",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "active",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "worktree",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "vcs",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_url",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_color",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_initialized",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sandboxes",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "commands",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "message_id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "parent_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "slug",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "version",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "share_url",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_additions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_deletions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_files",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_diffs",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "revert",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "permission",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_compacting",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_archived",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "priority",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "position",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_share"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_message_session_id_session_id_fk",
"entityType": "fks",
"table": "message"
},
{
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_part_message_id_message_id_fk",
"entityType": "fks",
"table": "part"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_permission_project_id_project_id_fk",
"entityType": "fks",
"table": "permission"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_project_id_project_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_todo_session_id_session_id_fk",
"entityType": "fks",
"table": "todo"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_share_session_id_session_id_fk",
"entityType": "fks",
"table": "session_share"
},
{
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["project_id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
"entityType": "pks"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "message_session_idx",
"entityType": "indexes",
"table": "message"
},
{
"columns": [
{
"value": "message_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_message_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_session_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "project_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_project_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "parent_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_parent_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "todo_session_idx",
"entityType": "indexes",
"table": "todo"
}
],
"renames": []
}

View file

@ -1,13 +1,13 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.64", "version": "1.2.1",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test", "test": "bun test --timeout 30000",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'", "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@ -15,7 +15,8 @@
"lint": "echo 'Running lint checks...' && bun test --coverage", "lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts", "format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;", "docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
"db": "bun drizzle-kit"
}, },
"bin": { "bin": {
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
@ -42,6 +43,8 @@
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2", "why-is-node-running": "3.2.2",
@ -51,12 +54,12 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1", "@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.74", "@ai-sdk/amazon-bedrock": "3.0.79",
"@ai-sdk/anthropic": "2.0.58", "@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/azure": "2.0.91", "@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36", "@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22", "@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.33", "@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30", "@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52", "@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98", "@ai-sdk/google-vertex": "3.0.98",
@ -100,6 +103,7 @@
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
"diff": "catalog:", "diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"hono": "catalog:", "hono": "catalog:",
@ -122,5 +126,8 @@
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "catalog:", "zod": "catalog:",
"zod-to-json-schema": "3.24.5" "zod-to-json-schema": "3.24.5"
},
"overrides": {
"drizzle-orm": "1.0.0-beta.12-a5629fb"
} }
} }

View file

@ -25,6 +25,32 @@ await Bun.write(
) )
console.log("Generated models-snapshot.ts") console.log("Generated models-snapshot.ts")
// Load migrations from migration directories
const migrationDirs = (await fs.promises.readdir(path.join(dir, "migration"), { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single") const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline") const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install") const skipInstall = process.argv.includes("--skip-install")
@ -156,6 +182,7 @@ for (const item of targets) {
entrypoints: ["./src/index.ts", parserWorker, workerPath], entrypoints: ["./src/index.ts", parserWorker, workerPath],
define: { define: {
OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath, OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`, OPENCODE_CHANNEL: `'${Script.channel}'`,

View file

@ -0,0 +1,16 @@
#!/usr/bin/env bun
import { $ } from "bun"
// drizzle-kit check compares schema to migrations, exits non-zero if drift
const result = await $`bun drizzle-kit check`.quiet().nothrow()
if (result.exitCode !== 0) {
console.error("Schema has changes not captured in migrations!")
console.error("Run: bun drizzle-kit generate")
console.error("")
console.error(result.stderr.toString())
process.exit(1)
}
console.log("Migrations are up to date")

View file

@ -435,46 +435,68 @@ export namespace ACP {
return return
} }
} }
return
}
if (part.type === "text") { case "message.part.delta": {
const delta = props.delta const props = event.properties
if (delta && part.ignored !== true) { const session = this.sessionManager.tryGet(props.sessionID)
await this.connection if (!session) return
.sessionUpdate({ const sessionId = session.id
sessionId,
update: { const message = await this.sdk.session
sessionUpdate: "agent_message_chunk", .message(
content: { {
type: "text", sessionID: props.sessionID,
text: delta, messageID: props.messageID,
}, directory: session.cwd,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((error) => {
log.error("unexpected error when fetching message", { error })
return undefined
})
if (!message || message.info.role !== "assistant") return
const part = message.parts.find((p) => p.id === props.partID)
if (!part) return
if (part.type === "text" && props.field === "text" && part.ignored !== true) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: props.delta,
}, },
}) },
.catch((error) => { })
log.error("failed to send text to ACP", { error }) .catch((error) => {
}) log.error("failed to send text delta to ACP", { error })
} })
return return
} }
if (part.type === "reasoning") { if (part.type === "reasoning" && props.field === "text") {
const delta = props.delta await this.connection
if (delta) { .sessionUpdate({
await this.connection sessionId,
.sessionUpdate({ update: {
sessionId, sessionUpdate: "agent_thought_chunk",
update: { content: {
sessionUpdate: "agent_thought_chunk", type: "text",
content: { text: props.delta,
type: "text",
text: delta,
},
}, },
}) },
.catch((error) => { })
log.error("failed to send reasoning to ACP", { error }) .catch((error) => {
}) log.error("failed to send reasoning delta to ACP", { error })
} })
} }
return return
} }

View file

@ -3,7 +3,8 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "../../session" import { Session } from "../../session"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { Storage } from "../../storage/storage" import { Database } from "../../storage/db"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next" import { ShareNext } from "../../share/share-next"
import { EOL } from "os" import { EOL } from "os"
@ -130,13 +131,35 @@ export const ImportCommand = cmd({
return return
} }
await Storage.write(["session", Instance.project.id, exportData.info.id], exportData.info) Database.use((db) => db.insert(SessionTable).values(Session.toRow(exportData.info)).onConflictDoNothing().run())
for (const msg of exportData.messages) { for (const msg of exportData.messages) {
await Storage.write(["message", exportData.info.id, msg.info.id], msg.info) Database.use((db) =>
db
.insert(MessageTable)
.values({
id: msg.info.id,
session_id: exportData.info.id,
time_created: msg.info.time?.created ?? Date.now(),
data: msg.info,
})
.onConflictDoNothing()
.run(),
)
for (const part of msg.parts) { for (const part of msg.parts) {
await Storage.write(["part", msg.info.id, part.id], part) Database.use((db) =>
db
.insert(PartTable)
.values({
id: part.id,
message_id: msg.info.id,
session_id: exportData.info.id,
data: part,
})
.onConflictDoNothing()
.run(),
)
} }
} }

View file

@ -274,6 +274,10 @@ export const RunCommand = cmd({
type: "string", type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)", describe: "attach to a running opencode server (e.g., http://localhost:4096)",
}) })
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
})
.option("port", { .option("port", {
type: "number", type: "number",
describe: "port for the local server (defaults to random port if no value provided)", describe: "port for the local server (defaults to random port if no value provided)",
@ -293,6 +297,18 @@ export const RunCommand = cmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ") .join(" ")
const directory = (() => {
if (!args.dir) return undefined
if (args.attach) return args.dir
try {
process.chdir(args.dir)
return process.cwd()
} catch {
UI.error("Failed to change directory to " + args.dir)
process.exit(1)
}
})()
const files: { type: "file"; url: string; filename: string; mime: string }[] = [] const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) { if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file] const list = Array.isArray(args.file) ? args.file : [args.file]
@ -390,20 +406,24 @@ export const RunCommand = cmd({
async function execute(sdk: OpencodeClient) { async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) { function tool(part: ToolPart) {
if (part.tool === "bash") return bash(props<typeof BashTool>(part)) try {
if (part.tool === "glob") return glob(props<typeof GlobTool>(part)) if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part)) if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "list") return list(props<typeof ListTool>(part)) if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part)) if (part.tool === "list") return list(props<typeof ListTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part)) if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part)) if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part)) if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part)) if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part)) if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part)) if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part)) if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part)) if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
return fallback(part) if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
} catch {
return fallback(part)
}
} }
function emit(type: string, data: Record<string, unknown>) { function emit(type: string, data: Record<string, unknown>) {
@ -582,7 +602,7 @@ export const RunCommand = cmd({
} }
if (args.attach) { if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach }) const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
return await execute(sdk) return await execute(sdk)
} }

View file

@ -2,7 +2,8 @@ import type { Argv } from "yargs"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Session } from "../../session" import { Session } from "../../session"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { Storage } from "../../storage/storage" import { Database } from "../../storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project" import { Project } from "../../project/project"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
@ -87,25 +88,8 @@ async function getCurrentProject(): Promise<Project.Info> {
} }
async function getAllSessions(): Promise<Session.Info[]> { async function getAllSessions(): Promise<Session.Info[]> {
const sessions: Session.Info[] = [] const rows = Database.use((db) => db.select().from(SessionTable).all())
return rows.map((row) => Session.fromRow(row))
const projectKeys = await Storage.list(["project"])
const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
for (const project of projects) {
if (!project) continue
const sessionKeys = await Storage.list(["session", project.id])
const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read<Session.Info>(key)))
for (const session of projectSessions) {
if (session) {
sessions.push(session)
}
}
}
return sessions
} }
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> { export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {

View file

@ -299,6 +299,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break break
} }
case "message.part.delta": {
const parts = store.part[event.properties.messageID]
if (!parts) break
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (!result.found) break
setStore(
"part",
event.properties.messageID,
produce((draft) => {
const part = draft[result.index]
const field = event.properties.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + event.properties.delta
}),
)
break
}
case "message.part.removed": { case "message.part.removed": {
const parts = store.part[event.properties.messageID] const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id) const result = Binary.search(parts, event.properties.partID, (p) => p.id)

View file

@ -2042,8 +2042,8 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
</For> </For>
</Match> </Match>
<Match when={true}> <Match when={true}>
<InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}> <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
apply_patch Patch
</InlineTool> </InlineTool>
</Match> </Match>
</Switch> </Switch>

View file

@ -31,6 +31,7 @@ import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry" import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied" import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Control } from "@/control"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@ -53,7 +54,7 @@ export namespace Config {
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir() const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them // Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info { function merge(target: Info, source: Info): Info {
const merged = mergeDeep(target, source) const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) { if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin])) merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@ -88,20 +89,21 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {} const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file // Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays( result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
result,
await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
)
log.debug("loaded remote config from well-known", { url: key }) log.debug("loaded remote config from well-known", { url: key })
} }
} }
const token = await Control.token()
if (token) {
}
// Global user config overrides remote config. // Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global()) result = merge(result, await global())
// Custom config path overrides global config. // Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) { if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG)) result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
@ -110,7 +112,7 @@ export namespace Config {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) { for (const resolved of found.toReversed()) {
result = mergeConfigConcatArrays(result, await loadFile(resolved)) result = merge(result, await loadFile(resolved))
} }
} }
} }
@ -153,7 +155,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`) log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) result = merge(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker // to satisfy the type checker
result.agent ??= {} result.agent ??= {}
result.mode ??= {} result.mode ??= {}
@ -175,14 +177,8 @@ export namespace Config {
} }
// Inline config content overrides all non-managed config sources. // Inline config content overrides all non-managed config sources.
// Route through load() to enable {env:} and {file:} token substitution.
// Use a path within Instance.directory so relative {file:} paths resolve correctly.
// The filename "OPENCODE_CONFIG_CONTENT" appears in error messages for clarity.
if (Flag.OPENCODE_CONFIG_CONTENT) { if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays( result = merge(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
result,
await load(Flag.OPENCODE_CONFIG_CONTENT, path.join(Instance.directory, "OPENCODE_CONFIG_CONTENT")),
)
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
@ -192,7 +188,7 @@ export namespace Config {
// This way it only loads config file and not skills/plugins/commands // This way it only loads config file and not skills/plugins/commands
if (existsSync(managedConfigDir)) { if (existsSync(managedConfigDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file))) result = merge(result, await loadFile(path.join(managedConfigDir, file)))
} }
} }

View file

@ -0,0 +1,22 @@
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
import { eq } from "drizzle-orm"
import { Timestamps } from "@/storage/schema.sql"
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.email, table.url] }),
// uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
],
)

View file

@ -0,0 +1,67 @@
import { eq, and } from "drizzle-orm"
import { Database } from "@/storage/db"
import { ControlAccountTable } from "./control.sql"
import z from "zod"
export * from "./control.sql"
export namespace Control {
export const Account = z.object({
email: z.string(),
url: z.string(),
})
export type Account = z.infer<typeof Account>
function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
return {
email: row.email,
url: row.url,
}
}
export function account(): Account | undefined {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
return row ? fromRow(row) : undefined
}
export async function token(): Promise<string | undefined> {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
if (!row) return undefined
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
const res = await fetch(`${row.url}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
}).toString(),
})
if (!res.ok) return
const json = (await res.json()) as {
access_token: string
refresh_token?: string
expires_in?: number
}
Database.use((db) =>
db
.update(ControlAccountTable)
.set({
access_token: json.access_token,
refresh_token: json.refresh_token ?? row.refresh_token,
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
})
.where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
.run(),
)
return json.access_token
}
}

View file

@ -8,7 +8,7 @@ export namespace Flag {
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_CONFIG_DIR: string | undefined export declare const OPENCODE_CONFIG_DIR: string | undefined
export declare const OPENCODE_CONFIG_CONTENT: string | undefined export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
@ -94,14 +94,3 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
enumerable: true, enumerable: true,
configurable: false, configurable: false,
}) })
// Dynamic getter for OPENCODE_CONFIG_CONTENT
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_CONFIG_CONTENT", {
get() {
return process.env["OPENCODE_CONFIG_CONTENT"]
},
enumerable: true,
configurable: false,
})

View file

@ -26,6 +26,10 @@ import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web" import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr" import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session" import { SessionCommand } from "./cli/cmd/session"
import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
process.on("unhandledRejection", (e) => { process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", { Log.Default.error("rejection", {
@ -74,6 +78,43 @@ const cli = yargs(hideBin(process.argv))
version: Installation.VERSION, version: Installation.VERSION,
args: process.argv.slice(2), args: process.argv.slice(2),
}) })
const marker = path.join(Global.Path.data, "opencode.db")
if (!(await Bun.file(marker).exists())) {
console.log("Performing one time database migration, may take a few minutes...")
const tty = process.stdout.isTTY
const width = 36
const orange = "\x1b[38;5;214m"
const muted = "\x1b[0;2m"
const reset = "\x1b[0m"
let last = -1
if (tty) process.stdout.write("\x1b[?25l")
try {
await JsonMigration.run(Database.Client().$client, {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last && event.current !== event.total) return
last = percent
if (tty) {
const fill = Math.round((percent / 100) * width)
const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
process.stdout.write(
`\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
)
if (event.current === event.total) process.stdout.write("\n")
} else {
console.log(`sqlite-migration:${percent}`)
}
},
})
} finally {
if (tty) process.stdout.write("\x1b[?25h")
else {
console.log(`sqlite-migration:done`)
}
}
console.log("Database migration complete.")
}
}) })
.usage("\n" + UI.logo()) .usage("\n" + UI.logo())
.completion("completion", "generate shell completion script") .completion("completion", "generate shell completion script")

View file

@ -3,7 +3,8 @@ import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Storage } from "@/storage/storage" import { Database, eq } from "@/storage/db"
import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
@ -105,9 +106,12 @@ export namespace PermissionNext {
), ),
} }
const state = Instance.state(async () => { const state = Instance.state(() => {
const projectID = Instance.project.id const projectID = Instance.project.id
const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset) const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
)
const stored = row?.data ?? ([] as Ruleset)
const pending: Record< const pending: Record<
string, string,
@ -222,7 +226,8 @@ export namespace PermissionNext {
// TODO: we don't save the permission ruleset to disk yet until there's // TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it // UI to manage it
// await Storage.write(["permission", Instance.project.id], s.approved) // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
return return
} }
}, },
@ -275,6 +280,7 @@ export namespace PermissionNext {
} }
export async function list() { export async function list() {
return state().then((x) => Object.values(x.pending).map((x) => x.info)) const s = await state()
return Object.values(s.pending).map((x) => x.info)
} }
} }

View file

@ -1,5 +1,4 @@
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Share } from "../share/share"
import { Format } from "../format" import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
@ -17,7 +16,6 @@ import { Truncate } from "../tool/truncation"
export async function InstanceBootstrap() { export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
Share.init()
ShareNext.init() ShareNext.init()
Format.init() Format.init()
await LSP.init() await LSP.init()

View file

@ -0,0 +1,15 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "@/storage/schema.sql"
export const ProjectTable = sqliteTable("project", {
id: text().primaryKey(),
worktree: text().notNull(),
vcs: text(),
name: text(),
icon_url: text(),
icon_color: text(),
...Timestamps,
time_initialized: integer(),
sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})

View file

@ -1,11 +1,11 @@
import z from "zod" import z from "zod"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import path from "path" import path from "path"
import { Storage } from "../storage/storage" import { Database, eq } from "../storage/db"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Session } from "../session"
import { work } from "../util/queue" import { work } from "../util/queue"
import { fn } from "@opencode-ai/util/fn" import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
@ -50,10 +50,33 @@ export namespace Project {
Updated: BusEvent.define("project.updated", Info), Updated: BusEvent.define("project.updated", Info),
} }
type Row = typeof ProjectTable.$inferSelect
export function fromRow(row: Row): Info {
const icon =
row.icon_url || row.icon_color
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined
return {
id: row.id,
worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined,
icon,
time: {
created: row.time_created,
updated: row.time_updated,
initialized: row.time_initialized ?? undefined,
},
sandboxes: row.sandboxes,
commands: row.commands ?? undefined,
}
}
export async function fromDirectory(directory: string) { export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory }) log.info("fromDirectory", { directory })
const { id, sandbox, worktree, vcs } = await iife(async () => { const data = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory }) const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value) const dotgit = await matches.next().then((x) => x.value)
await matches.return() await matches.return()
@ -169,47 +192,73 @@ export namespace Project {
} }
}) })
let existing = await Storage.read<Info>(["project", id]).catch(() => undefined) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
if (!existing) { const existing = await iife(async () => {
existing = { if (row) return fromRow(row)
id, const fresh: Info = {
worktree, id: data.id,
vcs: vcs as Info["vcs"], worktree: data.worktree,
vcs: data.vcs as Info["vcs"],
sandboxes: [], sandboxes: [],
time: { time: {
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),
}, },
} }
if (id !== "global") { if (data.id !== "global") {
await migrateFromGlobal(id, worktree) await migrateFromGlobal(data.id, data.worktree)
} }
} return fresh
})
// migrate old projects before sandboxes
if (!existing.sandboxes) existing.sandboxes = []
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = { const result: Info = {
...existing, ...existing,
worktree, worktree: data.worktree,
vcs: vcs as Info["vcs"], vcs: data.vcs as Info["vcs"],
time: { time: {
...existing.time, ...existing.time,
updated: Date.now(), updated: Date.now(),
}, },
} }
if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox) if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
await Storage.write<Info>(["project", id], result) const insert = {
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
}
const updateSet = {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
}
Database.use((db) =>
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
)
GlobalBus.emit("event", { GlobalBus.emit("event", {
payload: { payload: {
type: Event.Updated.type, type: Event.Updated.type,
properties: result, properties: result,
}, },
}) })
return { project: result, sandbox } return { project: result, sandbox: data.sandbox }
} }
export async function discover(input: Info) { export async function discover(input: Info) {
@ -242,43 +291,54 @@ export namespace Project {
return return
} }
async function migrateFromGlobal(newProjectID: string, worktree: string) { async function migrateFromGlobal(id: string, worktree: string) {
const globalProject = await Storage.read<Info>(["project", "global"]).catch(() => undefined) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
if (!globalProject) return if (!row) return
const globalSessions = await Storage.list(["session", "global"]).catch(() => []) const sessions = Database.use((db) =>
if (globalSessions.length === 0) return db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
)
if (sessions.length === 0) return
log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length }) log.info("migrating sessions from global", { newProjectID: id, worktree, count: sessions.length })
await work(10, globalSessions, async (key) => { await work(10, sessions, async (row) => {
const sessionID = key[key.length - 1] // Skip sessions that belong to a different directory
const session = await Storage.read<Session.Info>(key).catch(() => undefined) if (row.directory && row.directory !== worktree) return
if (!session) return
if (session.directory && session.directory !== worktree) return
session.projectID = newProjectID log.info("migrating session", { sessionID: row.id, from: "global", to: id })
log.info("migrating session", { sessionID, from: "global", to: newProjectID }) Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
await Storage.write(["session", newProjectID, sessionID], session)
await Storage.remove(key)
}).catch((error) => { }).catch((error) => {
log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID }) log.error("failed to migrate sessions from global to project", { error, projectId: id })
}) })
} }
export async function setInitialized(projectID: string) { export function setInitialized(id: string) {
await Storage.update<Info>(["project", projectID], (draft) => { Database.use((db) =>
draft.time.initialized = Date.now() db
}) .update(ProjectTable)
.set({
time_initialized: Date.now(),
})
.where(eq(ProjectTable.id, id))
.run(),
)
} }
export async function list() { export function list() {
const keys = await Storage.list(["project"]) return Database.use((db) =>
const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x))) db
return projects.map((project) => ({ .select()
...project, .from(ProjectTable)
sandboxes: project.sandboxes?.filter((x) => existsSync(x)), .all()
})) .map((row) => fromRow(row)),
)
}
export function get(id: string): Info | undefined {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return undefined
return fromRow(row)
} }
export const update = fn( export const update = fn(
@ -289,77 +349,90 @@ export namespace Project {
commands: Info.shape.commands.optional(), commands: Info.shape.commands.optional(),
}), }),
async (input) => { async (input) => {
const result = await Storage.update<Info>(["project", input.projectID], (draft) => { const result = Database.use((db) =>
if (input.name !== undefined) draft.name = input.name db
if (input.icon !== undefined) { .update(ProjectTable)
draft.icon = { .set({
...draft.icon, name: input.name,
} icon_url: input.icon?.url,
if (input.icon.url !== undefined) draft.icon.url = input.icon.url icon_color: input.icon?.color,
if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined commands: input.commands,
if (input.icon.color !== undefined) draft.icon.color = input.icon.color time_updated: Date.now(),
} })
.where(eq(ProjectTable.id, input.projectID))
if (input.commands?.start !== undefined) { .returning()
const start = input.commands.start || undefined .get(),
draft.commands = { )
...(draft.commands ?? {}), if (!result) throw new Error(`Project not found: ${input.projectID}`)
} const data = fromRow(result)
draft.commands.start = start
if (!draft.commands.start) draft.commands = undefined
}
draft.time.updated = Date.now()
})
GlobalBus.emit("event", { GlobalBus.emit("event", {
payload: { payload: {
type: Event.Updated.type, type: Event.Updated.type,
properties: result, properties: data,
}, },
}) })
return result return data
}, },
) )
export async function sandboxes(projectID: string) { export async function sandboxes(id: string) {
const project = await Storage.read<Info>(["project", projectID]).catch(() => undefined) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!project?.sandboxes) return [] if (!row) return []
const data = fromRow(row)
const valid: string[] = [] const valid: string[] = []
for (const dir of project.sandboxes) { for (const dir of data.sandboxes) {
const stat = await fs.stat(dir).catch(() => undefined) const stat = await Bun.file(dir)
.stat()
.catch(() => undefined)
if (stat?.isDirectory()) valid.push(dir) if (stat?.isDirectory()) valid.push(dir)
} }
return valid return valid
} }
export async function addSandbox(projectID: string, directory: string) { export async function addSandbox(id: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
const sandboxes = draft.sandboxes ?? [] if (!row) throw new Error(`Project not found: ${id}`)
if (!sandboxes.includes(directory)) sandboxes.push(directory) const sandboxes = [...row.sandboxes]
draft.sandboxes = sandboxes if (!sandboxes.includes(directory)) sandboxes.push(directory)
draft.time.updated = Date.now() const result = Database.use((db) =>
}) db
.update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result)
GlobalBus.emit("event", { GlobalBus.emit("event", {
payload: { payload: {
type: Event.Updated.type, type: Event.Updated.type,
properties: result, properties: data,
}, },
}) })
return result return data
} }
export async function removeSandbox(projectID: string, directory: string) { export async function removeSandbox(id: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
const sandboxes = draft.sandboxes ?? [] if (!row) throw new Error(`Project not found: ${id}`)
draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory) const sandboxes = row.sandboxes.filter((s) => s !== directory)
draft.time.updated = Date.now() const result = Database.use((db) =>
}) db
.update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result)
GlobalBus.emit("event", { GlobalBus.emit("event", {
payload: { payload: {
type: Event.Updated.type, type: Event.Updated.type,
properties: result, properties: data,
}, },
}) })
return result return data
} }
} }

View file

@ -458,6 +458,22 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
case "@ai-sdk/google-vertex/anthropic": case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
const efforts = ["low", "medium", "high", "max"]
return Object.fromEntries(
efforts.map((effort) => [
effort,
{
thinking: {
type: "adaptive",
},
effort,
},
]),
)
}
return { return {
high: { high: {
thinking: { thinking: {
@ -475,6 +491,20 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock": case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
const efforts = ["low", "medium", "high", "max"]
return Object.fromEntries(
efforts.map((effort) => [
effort,
{
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
},
},
]),
)
}
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens // For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
if (model.api.id.includes("anthropic")) { if (model.api.id.includes("anthropic")) {
return { return {

View file

@ -1,6 +1,6 @@
import { resolver } from "hono-openapi" import { resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { Storage } from "../storage/storage" import { NotFoundError } from "../storage/db"
export const ERRORS = { export const ERRORS = {
400: { 400: {
@ -25,7 +25,7 @@ export const ERRORS = {
description: "Not found", description: "Not found",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Storage.NotFoundError.Schema), schema: resolver(NotFoundError.Schema),
}, },
}, },
}, },

View file

@ -3,7 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun" import { upgradeWebSocket } from "hono/bun"
import z from "zod" import z from "zod"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { Storage } from "../../storage/storage" import { NotFoundError } from "../../storage/db"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@ -76,7 +76,7 @@ export const PtyRoutes = lazy(() =>
async (c) => { async (c) => {
const info = Pty.get(c.req.valid("param").ptyID) const info = Pty.get(c.req.valid("param").ptyID)
if (!info) { if (!info) {
throw new Storage.NotFoundError({ message: "Session not found" }) throw new NotFoundError({ message: "Session not found" })
} }
return c.json(info) return c.json(info)
}, },

View file

@ -276,18 +276,15 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json") const updates = c.req.valid("json")
const updatedSession = await Session.update( let session = await Session.get(sessionID)
sessionID, if (updates.title !== undefined) {
(session) => { session = await Session.setTitle({ sessionID, title: updates.title })
if (updates.title !== undefined) { }
session.title = updates.title if (updates.time?.archived !== undefined) {
} session = await Session.setArchived({ sessionID, time: updates.time.archived })
if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived }
},
{ touch: false },
)
return c.json(updatedSession) return c.json(session)
}, },
) )
.post( .post(

View file

@ -31,7 +31,7 @@ import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider" import { ProviderRoutes } from "./routes/provider"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap" import { InstanceBootstrap } from "../project/bootstrap"
import { Storage } from "../storage/storage" import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status" import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun" import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception" import { HTTPException } from "hono/http-exception"
@ -65,7 +65,7 @@ export namespace Server {
}) })
if (err instanceof NamedError) { if (err instanceof NamedError) {
let status: ContentfulStatusCode let status: ContentfulStatusCode
if (err instanceof Storage.NotFoundError) status = 404 if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400 else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name.startsWith("Worktree")) status = 400 else if (err.name.startsWith("Worktree")) status = 400
else status = 500 else status = 500

View file

@ -10,7 +10,9 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Storage } from "../storage/storage" import { Database, NotFoundError, eq, and, or, like } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log" import { Log } from "../util/log"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@ -41,6 +43,64 @@ export namespace Session {
).test(title) ).test(title)
} }
type SessionRow = typeof SessionTable.$inferSelect
export function fromRow(row: SessionRow): Info {
const summary =
row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null
? {
additions: row.summary_additions ?? 0,
deletions: row.summary_deletions ?? 0,
files: row.summary_files ?? 0,
diffs: row.summary_diffs ?? undefined,
}
: undefined
const share = row.share_url ? { url: row.share_url } : undefined
const revert = row.revert ?? undefined
return {
id: row.id,
slug: row.slug,
projectID: row.project_id,
directory: row.directory,
parentID: row.parent_id ?? undefined,
title: row.title,
version: row.version,
summary,
share,
revert,
permission: row.permission ?? undefined,
time: {
created: row.time_created,
updated: row.time_updated,
compacting: row.time_compacting ?? undefined,
archived: row.time_archived ?? undefined,
},
}
}
export function toRow(info: Info) {
return {
id: info.id,
project_id: info.projectID,
parent_id: info.parentID,
slug: info.slug,
directory: info.directory,
title: info.title,
version: info.version,
share_url: info.share?.url,
summary_additions: info.summary?.additions,
summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs,
revert: info.revert ?? null,
permission: info.permission,
time_created: info.time.created,
time_updated: info.time.updated,
time_compacting: info.time.compacting,
time_archived: info.time.archived,
}
}
function getForkedTitle(title: string): string { function getForkedTitle(title: string): string {
const match = title.match(/^(.+) \(fork #(\d+)\)$/) const match = title.match(/^(.+) \(fork #(\d+)\)$/)
if (match) { if (match) {
@ -94,16 +154,6 @@ export namespace Session {
}) })
export type Info = z.output<typeof Info> export type Info = z.output<typeof Info>
export const ShareInfo = z
.object({
secret: z.string(),
url: z.string(),
})
.meta({
ref: "SessionShare",
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = { export const Event = {
Created: BusEvent.define( Created: BusEvent.define(
"session.created", "session.created",
@ -200,8 +250,17 @@ export namespace Session {
) )
export const touch = fn(Identifier.schema("session"), async (sessionID) => { export const touch = fn(Identifier.schema("session"), async (sessionID) => {
await update(sessionID, (draft) => { const now = Date.now()
draft.time.updated = Date.now() Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_updated: now })
.where(eq(SessionTable.id, sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
}) })
}) })
@ -227,21 +286,19 @@ export namespace Session {
}, },
} }
log.info("created", result) log.info("created", result)
await Storage.write(["session", Instance.project.id, result.id], result) Database.use((db) => {
Bus.publish(Event.Created, { db.insert(SessionTable).values(toRow(result)).run()
info: result, Database.effect(() =>
Bus.publish(Event.Created, {
info: result,
}),
)
}) })
const cfg = await Config.get() const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
share(result.id) share(result.id).catch(() => {
.then((share) => { // Silently ignore sharing errors during session creation
update(result.id, (draft) => { })
draft.share = share
})
})
.catch(() => {
// Silently ignore sharing errors during session creation
})
Bus.publish(Event.Updated, { Bus.publish(Event.Updated, {
info: result, info: result,
}) })
@ -256,12 +313,9 @@ export namespace Session {
} }
export const get = fn(Identifier.schema("session"), async (id) => { export const get = fn(Identifier.schema("session"), async (id) => {
const read = await Storage.read<Info>(["session", Instance.project.id, id]) const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
return read as Info if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
}) return fromRow(row)
export const getShare = fn(Identifier.schema("session"), async (id) => {
return Storage.read<ShareInfo>(["share", id])
}) })
export const share = fn(Identifier.schema("session"), async (id) => { export const share = fn(Identifier.schema("session"), async (id) => {
@ -271,15 +325,12 @@ export namespace Session {
} }
const { ShareNext } = await import("@/share/share-next") const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id) const share = await ShareNext.create(id)
await update( Database.use((db) => {
id, const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
(draft) => { if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
draft.share = { const info = fromRow(row)
url: share.url, Database.effect(() => Bus.publish(Event.Updated, { info }))
} })
},
{ touch: false },
)
return share return share
}) })
@ -287,32 +338,155 @@ export namespace Session {
// Use ShareNext to remove the share (same as share function uses ShareNext to create) // Use ShareNext to remove the share (same as share function uses ShareNext to create)
const { ShareNext } = await import("@/share/share-next") const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id) await ShareNext.remove(id)
await update( Database.use((db) => {
id, const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
(draft) => { if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
draft.share = undefined const info = fromRow(row)
}, Database.effect(() => Bus.publish(Event.Updated, { info }))
{ touch: false }, })
)
}) })
export async function update(id: string, editor: (session: Info) => void, options?: { touch?: boolean }) { export const setTitle = fn(
const project = Instance.project z.object({
const result = await Storage.update<Info>(["session", project.id, id], (draft) => { sessionID: Identifier.schema("session"),
editor(draft) title: z.string(),
if (options?.touch !== false) { }),
draft.time.updated = Date.now() async (input) => {
} return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ title: input.title })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
export const setArchived = fn(
z.object({
sessionID: Identifier.schema("session"),
time: z.number().optional(),
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_archived: input.time })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
export const setPermission = fn(
z.object({
sessionID: Identifier.schema("session"),
permission: PermissionNext.Ruleset,
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ permission: input.permission, time_updated: Date.now() })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
export const setRevert = fn(
z.object({
sessionID: Identifier.schema("session"),
revert: Info.shape.revert,
summary: Info.shape.summary,
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
revert: input.revert ?? null,
summary_additions: input.summary?.additions,
summary_deletions: input.summary?.deletions,
summary_files: input.summary?.files,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
export const clearRevert = fn(Identifier.schema("session"), async (sessionID) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
revert: null,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
}) })
Bus.publish(Event.Updated, { })
info: result,
}) export const setSummary = fn(
return result z.object({
} sessionID: Identifier.schema("session"),
summary: Info.shape.summary,
}),
async (input) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
summary_additions: input.summary?.additions,
summary_deletions: input.summary?.deletions,
summary_files: input.summary?.files,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
export const diff = fn(Identifier.schema("session"), async (sessionID) => { export const diff = fn(Identifier.schema("session"), async (sessionID) => {
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID]) try {
return diffs ?? [] return await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
} catch {
return []
}
}) })
export const messages = fn( export const messages = fn(
@ -331,25 +505,37 @@ export namespace Session {
}, },
) )
export async function* list() { export function* list() {
const project = Instance.project const project = Instance.project
for (const item of await Storage.list(["session", project.id])) { // const rel = path.relative(Instance.worktree, Instance.directory)
const session = await Storage.read<Info>(item).catch(() => undefined) // const suffix = path.sep + rel
if (!session) continue const rows = Database.use((db) =>
yield session db
.select()
.from(SessionTable)
.where(
and(
eq(SessionTable.project_id, project.id),
// or(eq(SessionTable.directory, Instance.directory), like(SessionTable.directory, `%${suffix}`)),
),
)
.all(),
)
for (const row of rows) {
yield fromRow(row)
} }
} }
export const children = fn(Identifier.schema("session"), async (parentID) => { export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project const project = Instance.project
const result = [] as Session.Info[] const rows = Database.use((db) =>
for (const item of await Storage.list(["session", project.id])) { db
const session = await Storage.read<Info>(item).catch(() => undefined) .select()
if (!session) continue .from(SessionTable)
if (session.parentID !== parentID) continue .where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
result.push(session) .all(),
} )
return result return rows.map(fromRow)
}) })
export const remove = fn(Identifier.schema("session"), async (sessionID) => { export const remove = fn(Identifier.schema("session"), async (sessionID) => {
@ -360,15 +546,14 @@ export namespace Session {
await remove(child.id) await remove(child.id)
} }
await unshare(sessionID).catch(() => {}) await unshare(sessionID).catch(() => {})
for (const msg of await Storage.list(["message", sessionID])) { // CASCADE delete handles messages and parts automatically
for (const part of await Storage.list(["part", msg.at(-1)!])) { Database.use((db) => {
await Storage.remove(part) db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
} Database.effect(() =>
await Storage.remove(msg) Bus.publish(Event.Deleted, {
} info: session,
await Storage.remove(["session", project.id, sessionID]) }),
Bus.publish(Event.Deleted, { )
info: session,
}) })
} catch (e) { } catch (e) {
log.error(e) log.error(e)
@ -376,9 +561,23 @@ export namespace Session {
}) })
export const updateMessage = fn(MessageV2.Info, async (msg) => { export const updateMessage = fn(MessageV2.Info, async (msg) => {
await Storage.write(["message", msg.sessionID, msg.id], msg) const time_created = msg.role === "user" ? msg.time.created : msg.time.created
Bus.publish(MessageV2.Event.Updated, { const { id, sessionID, ...data } = msg
info: msg, Database.use((db) => {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.Updated, {
info: msg,
}),
)
}) })
return msg return msg
}) })
@ -389,10 +588,15 @@ export namespace Session {
messageID: Identifier.schema("message"), messageID: Identifier.schema("message"),
}), }),
async (input) => { async (input) => {
await Storage.remove(["message", input.sessionID, input.messageID]) // CASCADE delete handles parts automatically
Bus.publish(MessageV2.Event.Removed, { Database.use((db) => {
sessionID: input.sessionID, db.delete(MessageTable).where(eq(MessageTable.id, input.messageID)).run()
messageID: input.messageID, Database.effect(() =>
Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
}),
)
}) })
return input.messageID return input.messageID
}, },
@ -405,39 +609,58 @@ export namespace Session {
partID: Identifier.schema("part"), partID: Identifier.schema("part"),
}), }),
async (input) => { async (input) => {
await Storage.remove(["part", input.messageID, input.partID]) Database.use((db) => {
Bus.publish(MessageV2.Event.PartRemoved, { db.delete(PartTable).where(eq(PartTable.id, input.partID)).run()
sessionID: input.sessionID, Database.effect(() =>
messageID: input.messageID, Bus.publish(MessageV2.Event.PartRemoved, {
partID: input.partID, sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
}),
)
}) })
return input.partID return input.partID
}, },
) )
const UpdatePartInput = z.union([ const UpdatePartInput = MessageV2.Part
MessageV2.Part,
z.object({
part: MessageV2.TextPart,
delta: z.string(),
}),
z.object({
part: MessageV2.ReasoningPart,
delta: z.string(),
}),
])
export const updatePart = fn(UpdatePartInput, async (input) => { export const updatePart = fn(UpdatePartInput, async (part) => {
const part = "delta" in input ? input.part : input const { id, messageID, sessionID, ...data } = part
const delta = "delta" in input ? input.delta : undefined const time = Date.now()
await Storage.write(["part", part.messageID, part.id], part) Database.use((db) => {
Bus.publish(MessageV2.Event.PartUpdated, { db.insert(PartTable)
part, .values({
delta, id,
message_id: messageID,
session_id: sessionID,
time_created: time,
data,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartUpdated, {
part,
}),
)
}) })
return part return part
}) })
export const updatePartDelta = fn(
z.object({
sessionID: z.string(),
messageID: z.string(),
partID: z.string(),
field: z.string(),
delta: z.string(),
}),
async (input) => {
Bus.publish(MessageV2.Event.PartDelta, input)
},
)
export const getUsage = fn( export const getUsage = fn(
z.object({ z.object({
model: z.custom<Provider.Model>(), model: z.custom<Provider.Model>(),

View file

@ -6,6 +6,10 @@ import { Identifier } from "../id/id"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Database, eq, desc, inArray } from "@/storage/db"
import { MessageTable, PartTable } from "./session.sql"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error" import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@ -456,7 +460,16 @@ export namespace MessageV2 {
"message.part.updated", "message.part.updated",
z.object({ z.object({
part: Part, part: Part,
delta: z.string().optional(), }),
),
PartDelta: BusEvent.define(
"message.part.delta",
z.object({
sessionID: z.string(),
messageID: z.string(),
partID: z.string(),
field: z.string(),
delta: z.string(),
}), }),
), ),
PartRemoved: BusEvent.define( PartRemoved: BusEvent.define(
@ -701,23 +714,65 @@ export namespace MessageV2 {
} }
export const stream = fn(Identifier.schema("session"), async function* (sessionID) { export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
const list = await Array.fromAsync(await Storage.list(["message", sessionID])) const size = 50
for (let i = list.length - 1; i >= 0; i--) { let offset = 0
yield await get({ while (true) {
sessionID, const rows = Database.use((db) =>
messageID: list[i][2], db
}) .select()
.from(MessageTable)
.where(eq(MessageTable.session_id, sessionID))
.orderBy(desc(MessageTable.time_created))
.limit(size)
.offset(offset)
.all(),
)
if (rows.length === 0) break
const ids = rows.map((row) => row.id)
const partsByMessage = new Map<string, MessageV2.Part[]>()
if (ids.length > 0) {
const partRows = Database.use((db) =>
db
.select()
.from(PartTable)
.where(inArray(PartTable.message_id, ids))
.orderBy(PartTable.message_id, PartTable.id)
.all(),
)
for (const row of partRows) {
const part = {
...row.data,
id: row.id,
sessionID: row.session_id,
messageID: row.message_id,
} as MessageV2.Part
const list = partsByMessage.get(row.message_id)
if (list) list.push(part)
else partsByMessage.set(row.message_id, [part])
}
}
for (const row of rows) {
const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
yield {
info,
parts: partsByMessage.get(row.id) ?? [],
}
}
offset += rows.length
if (rows.length < size) break
} }
}) })
export const parts = fn(Identifier.schema("message"), async (messageID) => { export const parts = fn(Identifier.schema("message"), async (message_id) => {
const result = [] as MessageV2.Part[] const rows = Database.use((db) =>
for (const item of await Storage.list(["part", messageID])) { db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
const read = await Storage.read<MessageV2.Part>(item) )
result.push(read) return rows.map(
} (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part,
result.sort((a, b) => (a.id > b.id ? 1 : -1)) )
return result
}) })
export const get = fn( export const get = fn(
@ -726,8 +781,11 @@ export namespace MessageV2 {
messageID: Identifier.schema("message"), messageID: Identifier.schema("message"),
}), }),
async (input): Promise<WithParts> => { async (input): Promise<WithParts> => {
const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get())
if (!row) throw new Error(`Message not found: ${input.messageID}`)
const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
return { return {
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]), info,
parts: await parts(input.messageID), parts: await parts(input.messageID),
} }
}, },

View file

@ -63,17 +63,19 @@ export namespace SessionProcessor {
if (value.id in reasoningMap) { if (value.id in reasoningMap) {
continue continue
} }
reasoningMap[value.id] = { const reasoningPart = {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
messageID: input.assistantMessage.id, messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID, sessionID: input.assistantMessage.sessionID,
type: "reasoning", type: "reasoning" as const,
text: "", text: "",
time: { time: {
start: Date.now(), start: Date.now(),
}, },
metadata: value.providerMetadata, metadata: value.providerMetadata,
} }
reasoningMap[value.id] = reasoningPart
await Session.updatePart(reasoningPart)
break break
case "reasoning-delta": case "reasoning-delta":
@ -81,7 +83,13 @@ export namespace SessionProcessor {
const part = reasoningMap[value.id] const part = reasoningMap[value.id]
part.text += value.text part.text += value.text
if (value.providerMetadata) part.metadata = value.providerMetadata if (value.providerMetadata) part.metadata = value.providerMetadata
if (part.text) await Session.updatePart({ part, delta: value.text }) await Session.updatePartDelta({
sessionID: part.sessionID,
messageID: part.messageID,
partID: part.id,
field: "text",
delta: value.text,
})
} }
break break
@ -288,17 +296,20 @@ export namespace SessionProcessor {
}, },
metadata: value.providerMetadata, metadata: value.providerMetadata,
} }
await Session.updatePart(currentText)
break break
case "text-delta": case "text-delta":
if (currentText) { if (currentText) {
currentText.text += value.text currentText.text += value.text
if (value.providerMetadata) currentText.metadata = value.providerMetadata if (value.providerMetadata) currentText.metadata = value.providerMetadata
if (currentText.text) await Session.updatePartDelta({
await Session.updatePart({ sessionID: currentText.sessionID,
part: currentText, messageID: currentText.messageID,
delta: value.text, partID: currentText.id,
}) field: "text",
delta: value.text,
})
} }
break break

View file

@ -26,7 +26,6 @@ import { ToolRegistry } from "../tool/registry"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { ReadTool } from "../tool/read" import { ReadTool } from "../tool/read"
import { ListTool } from "../tool/ls"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { ulid } from "ulid" import { ulid } from "ulid"
@ -175,9 +174,7 @@ export namespace SessionPrompt {
} }
if (permissions.length > 0) { if (permissions.length > 0) {
session.permission = permissions session.permission = permissions
await Session.update(session.id, (draft) => { await Session.setPermission({ sessionID: session.id, permission: permissions })
draft.permission = permissions
})
} }
if (input.noReply === true) { if (input.noReply === true) {
@ -1198,7 +1195,7 @@ export namespace SessionPrompt {
} }
if (part.mime === "application/x-directory") { if (part.mime === "application/x-directory") {
const args = { path: filepath } const args = { filePath: filepath }
const listCtx: Tool.Context = { const listCtx: Tool.Context = {
sessionID: input.sessionID, sessionID: input.sessionID,
abort: new AbortController().signal, abort: new AbortController().signal,
@ -1209,7 +1206,7 @@ export namespace SessionPrompt {
metadata: async () => {}, metadata: async () => {},
ask: async () => {}, ask: async () => {},
} }
const result = await ListTool.init().then((t) => t.execute(args, listCtx)) const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
return [ return [
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
@ -1217,7 +1214,7 @@ export namespace SessionPrompt {
sessionID: input.sessionID, sessionID: input.sessionID,
type: "text", type: "text",
synthetic: true, synthetic: true,
text: `Called the list tool with the following input: ${JSON.stringify(args)}`, text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
}, },
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
@ -1947,21 +1944,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
], ],
}) })
const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) const text = await result.text.catch((err) => log.error("failed to generate title", { error: err }))
if (text) if (text) {
return Session.update( const cleaned = text
input.session.id, .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
(draft) => { .split("\n")
const cleaned = text .map((line) => line.trim())
.replace(/<think>[\s\S]*?<\/think>\s*/g, "") .find((line) => line.length > 0)
.split("\n") if (!cleaned) return
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!cleaned) return
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
draft.title = title return Session.setTitle({ sessionID: input.session.id, title })
}, }
{ touch: false },
)
} }
} }

View file

@ -4,8 +4,9 @@ import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Session } from "." import { Session } from "."
import { Log } from "../util/log" import { Log } from "../util/log"
import { splitWhen } from "remeda" import { Database, eq } from "../storage/db"
import { Storage } from "../storage/storage" import { MessageTable, PartTable } from "./session.sql"
import { Storage } from "@/storage/storage"
import { Bus } from "../bus" import { Bus } from "../bus"
import { SessionPrompt } from "./prompt" import { SessionPrompt } from "./prompt"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
@ -65,13 +66,14 @@ export namespace SessionRevert {
sessionID: input.sessionID, sessionID: input.sessionID,
diff: diffs, diff: diffs,
}) })
return Session.update(input.sessionID, (draft) => { return Session.setRevert({
draft.revert = revert sessionID: input.sessionID,
draft.summary = { revert,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0), additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length, files: diffs.length,
} },
}) })
} }
return session return session
@ -83,39 +85,54 @@ export namespace SessionRevert {
const session = await Session.get(input.sessionID) const session = await Session.get(input.sessionID)
if (!session.revert) return session if (!session.revert) return session
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot) if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
const next = await Session.update(input.sessionID, (draft) => { return Session.clearRevert(input.sessionID)
draft.revert = undefined
})
return next
} }
export async function cleanup(session: Session.Info) { export async function cleanup(session: Session.Info) {
if (!session.revert) return if (!session.revert) return
const sessionID = session.id const sessionID = session.id
let msgs = await Session.messages({ sessionID }) const msgs = await Session.messages({ sessionID })
const messageID = session.revert.messageID const messageID = session.revert.messageID
const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID) const preserve = [] as MessageV2.WithParts[]
msgs = preserve const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) {
preserve.push(msg)
continue
}
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
preserve.push(msg)
target = msg
continue
}
remove.push(msg)
}
for (const msg of remove) { for (const msg of remove) {
await Storage.remove(["message", sessionID, msg.info.id]) Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id }) await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
} }
const last = preserve.at(-1) if (session.revert.partID && target) {
if (session.revert.partID && last) {
const partID = session.revert.partID const partID = session.revert.partID
const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID) const removeStart = target.parts.findIndex((part) => part.id === partID)
last.parts = preserveParts if (removeStart >= 0) {
for (const part of removeParts) { const preserveParts = target.parts.slice(0, removeStart)
await Storage.remove(["part", last.info.id, part.id]) const removeParts = target.parts.slice(removeStart)
await Bus.publish(MessageV2.Event.PartRemoved, { target.parts = preserveParts
sessionID: sessionID, for (const part of removeParts) {
messageID: last.info.id, Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
partID: part.id, await Bus.publish(MessageV2.Event.PartRemoved, {
}) sessionID: sessionID,
messageID: target.info.id,
partID: part.id,
})
}
} }
} }
await Session.update(sessionID, (draft) => { await Session.clearRevert(sessionID)
draft.revert = undefined
})
} }
} }

View file

@ -0,0 +1,88 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "@/snapshot"
import type { PermissionNext } from "@/permission/next"
import { Timestamps } from "@/storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
export const SessionTable = sqliteTable(
"session",
{
id: text().primaryKey(),
project_id: text()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
parent_id: text(),
slug: text().notNull(),
directory: text().notNull(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
...Timestamps,
time_compacting: integer(),
time_archived: integer(),
},
(table) => [index("session_project_idx").on(table.project_id), index("session_parent_idx").on(table.parent_id)],
)
export const MessageTable = sqliteTable(
"message",
{
id: text().primaryKey(),
session_id: text()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<InfoData>(),
},
(table) => [index("message_session_idx").on(table.session_id)],
)
export const PartTable = sqliteTable(
"part",
{
id: text().primaryKey(),
message_id: text()
.notNull()
.references(() => MessageTable.id, { onDelete: "cascade" }),
session_id: text().notNull(),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<PartData>(),
},
(table) => [index("part_message_idx").on(table.message_id), index("part_session_idx").on(table.session_id)],
)
export const TodoTable = sqliteTable(
"todo",
{
session_id: text()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
content: text().notNull(),
status: text().notNull(),
priority: text().notNull(),
position: integer().notNull(),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.session_id, table.position] }),
index("todo_session_idx").on(table.session_id),
],
)
export const PermissionTable = sqliteTable("permission", {
project_id: text()
.primaryKey()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
})

View file

@ -90,12 +90,13 @@ export namespace SessionSummary {
async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) { async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) {
const diffs = await computeDiff({ messages: input.messages }) const diffs = await computeDiff({ messages: input.messages })
await Session.update(input.sessionID, (draft) => { await Session.setSummary({
draft.summary = { sessionID: input.sessionID,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0), additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length, files: diffs.length,
} },
}) })
await Storage.write(["session_diff", input.sessionID], diffs) await Storage.write(["session_diff", input.sessionID], diffs)
Bus.publish(Session.Event.Diff, { Bus.publish(Session.Event.Diff, {

View file

@ -1,7 +1,8 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import z from "zod" import z from "zod"
import { Storage } from "../storage/storage" import { Database, eq, asc } from "../storage/db"
import { TodoTable } from "./session.sql"
export namespace Todo { export namespace Todo {
export const Info = z export const Info = z
@ -9,7 +10,6 @@ export namespace Todo {
content: z.string().describe("Brief description of the task"), content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"), status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"), priority: z.string().describe("Priority level of the task: high, medium, low"),
id: z.string().describe("Unique identifier for the todo item"),
}) })
.meta({ ref: "Todo" }) .meta({ ref: "Todo" })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
@ -24,14 +24,33 @@ export namespace Todo {
), ),
} }
export async function update(input: { sessionID: string; todos: Info[] }) { export function update(input: { sessionID: string; todos: Info[] }) {
await Storage.write(["todo", input.sessionID], input.todos) Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
db.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
})
Bus.publish(Event.Updated, input) Bus.publish(Event.Updated, input)
} }
export async function get(sessionID: string) { export function get(sessionID: string) {
return Storage.read<Info[]>(["todo", sessionID]) const rows = Database.use((db) =>
.then((x) => x || []) db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
.catch(() => []) )
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
} }
} }

View file

@ -4,7 +4,8 @@ import { ulid } from "ulid"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Session } from "@/session" import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session/message-v2"
import { Storage } from "@/storage/storage" import { Database, eq } from "@/storage/db"
import { SessionShareTable } from "./share.sql"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import type * as SDK from "@opencode-ai/sdk/v2" import type * as SDK from "@opencode-ai/sdk/v2"
@ -77,17 +78,26 @@ export namespace ShareNext {
}) })
.then((x) => x.json()) .then((x) => x.json())
.then((x) => x as { id: string; url: string; secret: string }) .then((x) => x as { id: string; url: string; secret: string })
await Storage.write(["session_share", sessionID], result) Database.use((db) =>
db
.insert(SessionShareTable)
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
.onConflictDoUpdate({
target: SessionShareTable.session_id,
set: { id: result.id, secret: result.secret, url: result.url },
})
.run(),
)
fullSync(sessionID) fullSync(sessionID)
return result return result
} }
function get(sessionID: string) { function get(sessionID: string) {
return Storage.read<{ const row = Database.use((db) =>
id: string db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
secret: string )
url: string if (!row) return
}>(["session_share", sessionID]) return { id: row.id, secret: row.secret, url: row.url }
} }
type Data = type Data =
@ -132,7 +142,7 @@ export namespace ShareNext {
const queued = queue.get(sessionID) const queued = queue.get(sessionID)
if (!queued) return if (!queued) return
queue.delete(sessionID) queue.delete(sessionID)
const share = await get(sessionID).catch(() => undefined) const share = get(sessionID)
if (!share) return if (!share) return
await fetch(`${await url()}/api/share/${share.id}/sync`, { await fetch(`${await url()}/api/share/${share.id}/sync`, {
@ -152,7 +162,7 @@ export namespace ShareNext {
export async function remove(sessionID: string) { export async function remove(sessionID: string) {
if (disabled) return if (disabled) return
log.info("removing share", { sessionID }) log.info("removing share", { sessionID })
const share = await get(sessionID) const share = get(sessionID)
if (!share) return if (!share) return
await fetch(`${await url()}/api/share/${share.id}`, { await fetch(`${await url()}/api/share/${share.id}`, {
method: "DELETE", method: "DELETE",
@ -163,7 +173,7 @@ export namespace ShareNext {
secret: share.secret, secret: share.secret,
}), }),
}) })
await Storage.remove(["session_share", sessionID]) Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
} }
async function fullSync(sessionID: string) { async function fullSync(sessionID: string) {

View file

@ -0,0 +1,13 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { SessionTable } from "../session/session.sql"
import { Timestamps } from "@/storage/schema.sql"
export const SessionShareTable = sqliteTable("session_share", {
session_id: text()
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
id: text().notNull(),
secret: text().notNull(),
url: text().notNull(),
...Timestamps,
})

View file

@ -1,92 +0,0 @@
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
import { MessageV2 } from "../session/message-v2"
import { Log } from "../util/log"
export namespace Share {
const log = Log.create({ service: "share" })
let queue: Promise<void> = Promise.resolve()
const pending = new Map<string, any>()
export async function sync(key: string, content: any) {
if (disabled) return
const [root, ...splits] = key.split("/")
if (root !== "session") return
const [sub, sessionID] = splits
if (sub === "share") return
const share = await Session.getShare(sessionID).catch(() => {})
if (!share) return
const { secret } = share
pending.set(key, content)
queue = queue
.then(async () => {
const content = pending.get(key)
if (content === undefined) return
pending.delete(key)
return fetch(`${URL}/share_sync`, {
method: "POST",
body: JSON.stringify({
sessionID: sessionID,
secret,
key: key,
content,
}),
})
})
.then((x) => {
if (x) {
log.info("synced", {
key: key,
status: x.status,
})
}
})
}
export function init() {
Bus.subscribe(Session.Event.Updated, async (evt) => {
await sync("session/info/" + evt.properties.info.id, evt.properties.info)
})
Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
await sync("session/message/" + evt.properties.info.sessionID + "/" + evt.properties.info.id, evt.properties.info)
})
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
await sync(
"session/part/" +
evt.properties.part.sessionID +
"/" +
evt.properties.part.messageID +
"/" +
evt.properties.part.id,
evt.properties.part,
)
})
}
export const URL =
process.env["OPENCODE_API"] ??
(Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
export async function create(sessionID: string) {
if (disabled) return { url: "", secret: "" }
return fetch(`${URL}/share_create`, {
method: "POST",
body: JSON.stringify({ sessionID: sessionID }),
})
.then((x) => x.json())
.then((x) => x as { url: string; secret: string })
}
export async function remove(sessionID: string, secret: string) {
if (disabled) return {}
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ sessionID, secret }),
}).then((x) => x.json())
}
}

4
packages/opencode/src/sql.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.sql" {
const content: string
export default content
}

View file

@ -0,0 +1,140 @@
import { Database as BunDatabase } from "bun:sqlite"
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
import { Context } from "../util/context"
import { lazy } from "../util/lazy"
import { Global } from "../global"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import path from "path"
import { readFileSync, readdirSync } from "fs"
import * as schema from "./schema"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
export const NotFoundError = NamedError.create(
"NotFoundError",
z.object({
message: z.string(),
}),
)
const log = Log.create({ service: "db" })
export namespace Database {
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase<Schema>
type Journal = { sql: string; timestamp: number }[]
function time(tag: string) {
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
if (!match) return 0
return Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
}
function migrations(dir: string): Journal {
const dirs = readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
const sql = dirs
.map((name) => {
const file = path.join(dir, name, "migration.sql")
if (!Bun.file(file).size) return
return {
sql: readFileSync(file, "utf-8"),
timestamp: time(name),
}
})
.filter(Boolean) as Journal
return sql.sort((a, b) => a.timestamp - b.timestamp)
}
export const Client = lazy(() => {
log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
sqlite.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA synchronous = NORMAL")
sqlite.run("PRAGMA busy_timeout = 5000")
sqlite.run("PRAGMA cache_size = -64000")
sqlite.run("PRAGMA foreign_keys = ON")
const db = drizzle({ client: sqlite, schema })
// Apply schema migrations
const entries =
typeof OPENCODE_MIGRATIONS !== "undefined"
? OPENCODE_MIGRATIONS
: migrations(path.join(import.meta.dirname, "../../migration"))
if (entries.length > 0) {
log.info("applying migrations", {
count: entries.length,
mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev",
})
migrate(db, entries)
}
return db
})
export type TxOrDb = Transaction | Client
const ctx = Context.create<{
tx: TxOrDb
effects: (() => void | Promise<void>)[]
}>("database")
export function use<T>(callback: (trx: TxOrDb) => T): T {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
for (const effect of effects) effect()
return result
}
throw err
}
}
export function effect(fn: () => any | Promise<any>) {
try {
ctx.use().effects.push(fn)
} catch {
fn()
}
}
export function transaction<T>(callback: (tx: TxOrDb) => T): T {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = Client().transaction((tx) => {
return ctx.provide({ tx, effects }, () => callback(tx))
})
for (const effect of effects) effect()
return result
}
throw err
}
}
}

View file

@ -0,0 +1,437 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Global } from "../global"
import { Log } from "../util/log"
import { ProjectTable } from "../project/project.sql"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
import { SessionShareTable } from "../share/share.sql"
import path from "path"
import { existsSync } from "fs"
export namespace JsonMigration {
const log = Log.create({ service: "json-migration" })
export type Progress = {
current: number
total: number
label: string
}
type Options = {
progress?: (event: Progress) => void
}
export async function run(sqlite: Database, options?: Options) {
const storageDir = path.join(Global.Path.data, "storage")
if (!existsSync(storageDir)) {
log.info("storage directory does not exist, skipping migration")
return {
projects: 0,
sessions: 0,
messages: 0,
parts: 0,
todos: 0,
permissions: 0,
shares: 0,
errors: [] as string[],
}
}
log.info("starting json to sqlite migration", { storageDir })
const start = performance.now()
const db = drizzle({ client: sqlite })
// Optimize SQLite for bulk inserts
sqlite.exec("PRAGMA journal_mode = WAL")
sqlite.exec("PRAGMA synchronous = OFF")
sqlite.exec("PRAGMA cache_size = 10000")
sqlite.exec("PRAGMA temp_store = MEMORY")
const stats = {
projects: 0,
sessions: 0,
messages: 0,
parts: 0,
todos: 0,
permissions: 0,
shares: 0,
errors: [] as string[],
}
const orphans = {
sessions: 0,
todos: 0,
permissions: 0,
shares: 0,
}
const errs = stats.errors
const batchSize = 1000
const now = Date.now()
async function list(pattern: string) {
const items: string[] = []
const scan = new Bun.Glob(pattern)
for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
items.push(file)
}
return items
}
async function read(files: string[], start: number, end: number) {
const count = end - start
const tasks = new Array(count)
for (let i = 0; i < count; i++) {
tasks[i] = Bun.file(files[start + i]).json()
}
const results = await Promise.allSettled(tasks)
const items = new Array(count)
for (let i = 0; i < results.length; i++) {
const result = results[i]
if (result.status === "fulfilled") {
items[i] = result.value
continue
}
errs.push(`failed to read ${files[start + i]}: ${result.reason}`)
}
return items
}
function insert(values: any[], table: any, label: string) {
if (values.length === 0) return 0
try {
db.insert(table).values(values).onConflictDoNothing().run()
return values.length
} catch (e) {
errs.push(`failed to migrate ${label} batch: ${e}`)
return 0
}
}
// Pre-scan all files upfront to avoid repeated glob operations
log.info("scanning files...")
const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
list("project/*.json"),
list("session/*/*.json"),
list("message/*/*.json"),
list("part/*/*.json"),
list("todo/*.json"),
list("permission/*.json"),
list("session_share/*.json"),
])
log.info("file scan complete", {
projects: projectFiles.length,
sessions: sessionFiles.length,
messages: messageFiles.length,
parts: partFiles.length,
todos: todoFiles.length,
permissions: permFiles.length,
shares: shareFiles.length,
})
const total = Math.max(
1,
projectFiles.length +
sessionFiles.length +
messageFiles.length +
partFiles.length +
todoFiles.length +
permFiles.length +
shareFiles.length,
)
const progress = options?.progress
let current = 0
const step = (label: string, count: number) => {
current = Math.min(total, current + count)
progress?.({ current, total, label })
}
progress?.({ current, total, label: "starting" })
sqlite.exec("BEGIN TRANSACTION")
// Migrate projects first (no FK deps)
const projectIds = new Set<string>()
const projectValues = [] as any[]
for (let i = 0; i < projectFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, projectFiles.length)
const batch = await read(projectFiles, i, end)
projectValues.length = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
if (!data?.id) {
errs.push(`project missing id: ${projectFiles[i + j]}`)
continue
}
projectIds.add(data.id)
projectValues.push({
id: data.id,
worktree: data.worktree ?? "/",
vcs: data.vcs,
name: data.name ?? undefined,
icon_url: data.icon?.url,
icon_color: data.icon?.color,
time_created: data.time?.created ?? now,
time_updated: data.time?.updated ?? now,
time_initialized: data.time?.initialized,
sandboxes: data.sandboxes ?? [],
commands: data.commands,
})
}
stats.projects += insert(projectValues, ProjectTable, "project")
step("projects", end - i)
}
log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
// Migrate sessions (depends on projects)
const sessionIds = new Set<string>()
const sessionValues = [] as any[]
for (let i = 0; i < sessionFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, sessionFiles.length)
const batch = await read(sessionFiles, i, end)
sessionValues.length = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
if (!data?.id || !data?.projectID) {
errs.push(`session missing id or projectID: ${sessionFiles[i + j]}`)
continue
}
if (!projectIds.has(data.projectID)) {
orphans.sessions++
continue
}
sessionIds.add(data.id)
sessionValues.push({
id: data.id,
project_id: data.projectID,
parent_id: data.parentID ?? null,
slug: data.slug ?? "",
directory: data.directory ?? "",
title: data.title ?? "",
version: data.version ?? "",
share_url: data.share?.url ?? null,
summary_additions: data.summary?.additions ?? null,
summary_deletions: data.summary?.deletions ?? null,
summary_files: data.summary?.files ?? null,
summary_diffs: data.summary?.diffs ?? null,
revert: data.revert ?? null,
permission: data.permission ?? null,
time_created: data.time?.created ?? now,
time_updated: data.time?.updated ?? now,
time_compacting: data.time?.compacting ?? null,
time_archived: data.time?.archived ?? null,
})
}
stats.sessions += insert(sessionValues, SessionTable, "session")
step("sessions", end - i)
}
log.info("migrated sessions", { count: stats.sessions })
if (orphans.sessions > 0) {
log.warn("skipped orphaned sessions", { count: orphans.sessions })
}
// Migrate messages using pre-scanned file map
const allMessageFiles = [] as string[]
const allMessageSessions = [] as string[]
const messageSessions = new Map<string, string>()
for (const file of messageFiles) {
const sessionID = path.basename(path.dirname(file))
if (!sessionIds.has(sessionID)) continue
allMessageFiles.push(file)
allMessageSessions.push(sessionID)
}
for (let i = 0; i < allMessageFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, allMessageFiles.length)
const batch = await read(allMessageFiles, i, end)
const values = new Array(batch.length)
let count = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const file = allMessageFiles[i + j]
const id = data.id ?? path.basename(file, ".json")
if (!id) {
errs.push(`message missing id: ${file}`)
continue
}
const sessionID = allMessageSessions[i + j]
messageSessions.set(id, sessionID)
const rest = data
delete rest.id
delete rest.sessionID
values[count++] = {
id,
session_id: sessionID,
time_created: data.time?.created ?? now,
time_updated: data.time?.updated ?? now,
data: rest,
}
}
values.length = count
stats.messages += insert(values, MessageTable, "message")
step("messages", end - i)
}
log.info("migrated messages", { count: stats.messages })
// Migrate parts using pre-scanned file map
for (let i = 0; i < partFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, partFiles.length)
const batch = await read(partFiles, i, end)
const values = new Array(batch.length)
let count = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const file = partFiles[i + j]
const id = data.id ?? path.basename(file, ".json")
const messageID = data.messageID ?? path.basename(path.dirname(file))
if (!id || !messageID) {
errs.push(`part missing id/messageID/sessionID: ${file}`)
continue
}
const sessionID = messageSessions.get(messageID)
if (!sessionID) {
errs.push(`part missing message session: ${file}`)
continue
}
if (!sessionIds.has(sessionID)) continue
const rest = data
delete rest.id
delete rest.messageID
delete rest.sessionID
values[count++] = {
id,
message_id: messageID,
session_id: sessionID,
time_created: data.time?.created ?? now,
time_updated: data.time?.updated ?? now,
data: rest,
}
}
values.length = count
stats.parts += insert(values, PartTable, "part")
step("parts", end - i)
}
log.info("migrated parts", { count: stats.parts })
// Migrate todos
const todoSessions = todoFiles.map((file) => path.basename(file, ".json"))
for (let i = 0; i < todoFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, todoFiles.length)
const batch = await read(todoFiles, i, end)
const values = [] as any[]
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const sessionID = todoSessions[i + j]
if (!sessionIds.has(sessionID)) {
orphans.todos++
continue
}
if (!Array.isArray(data)) {
errs.push(`todo not an array: ${todoFiles[i + j]}`)
continue
}
for (let position = 0; position < data.length; position++) {
const todo = data[position]
if (!todo?.content || !todo?.status || !todo?.priority) continue
values.push({
session_id: sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
time_created: now,
time_updated: now,
})
}
}
stats.todos += insert(values, TodoTable, "todo")
step("todos", end - i)
}
log.info("migrated todos", { count: stats.todos })
if (orphans.todos > 0) {
log.warn("skipped orphaned todos", { count: orphans.todos })
}
// Migrate permissions
const permProjects = permFiles.map((file) => path.basename(file, ".json"))
const permValues = [] as any[]
for (let i = 0; i < permFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, permFiles.length)
const batch = await read(permFiles, i, end)
permValues.length = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const projectID = permProjects[i + j]
if (!projectIds.has(projectID)) {
orphans.permissions++
continue
}
permValues.push({ project_id: projectID, data })
}
stats.permissions += insert(permValues, PermissionTable, "permission")
step("permissions", end - i)
}
log.info("migrated permissions", { count: stats.permissions })
if (orphans.permissions > 0) {
log.warn("skipped orphaned permissions", { count: orphans.permissions })
}
// Migrate session shares
const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
const shareValues = [] as any[]
for (let i = 0; i < shareFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, shareFiles.length)
const batch = await read(shareFiles, i, end)
shareValues.length = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const sessionID = shareSessions[i + j]
if (!sessionIds.has(sessionID)) {
orphans.shares++
continue
}
if (!data?.id || !data?.secret || !data?.url) {
errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`)
continue
}
shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url })
}
stats.shares += insert(shareValues, SessionShareTable, "session_share")
step("shares", end - i)
}
log.info("migrated session shares", { count: stats.shares })
if (orphans.shares > 0) {
log.warn("skipped orphaned session shares", { count: orphans.shares })
}
sqlite.exec("COMMIT")
log.info("json migration complete", {
projects: stats.projects,
sessions: stats.sessions,
messages: stats.messages,
parts: stats.parts,
todos: stats.todos,
permissions: stats.permissions,
shares: stats.shares,
errorCount: stats.errors.length,
duration: Math.round(performance.now() - start),
})
if (stats.errors.length > 0) {
log.warn("migration errors", { errors: stats.errors.slice(0, 20) })
}
progress?.({ current: total, total, label: "complete" })
return stats
}
}

View file

@ -0,0 +1,10 @@
import { integer } from "drizzle-orm/sqlite-core"
export const Timestamps = {
time_created: integer()
.notNull()
.$default(() => Date.now()),
time_updated: integer()
.notNull()
.$onUpdate(() => Date.now()),
}

View file

@ -0,0 +1,4 @@
export { ControlAccountTable } from "../control/control.sql"
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
export { SessionShareTable } from "../share/share.sql"
export { ProjectTable } from "../project/project.sql"

View file

@ -149,9 +149,17 @@ export namespace ToolRegistry {
}) })
.map(async (t) => { .map(async (t) => {
using _ = log.time(t.id) using _ = log.time(t.id)
const tool = await t.init({ agent })
const output = {
description: tool.description,
parameters: tool.parameters,
}
await Plugin.trigger("tool.definition", { toolID: t.id }, output)
return { return {
id: t.id, id: t.id,
...(await t.init({ agent })), ...tool,
description: output.description,
parameters: output.parameters,
} }
}), }),
) )

View file

@ -40,7 +40,7 @@ interface McpSearchResponse {
export const WebSearchTool = Tool.define("websearch", async () => { export const WebSearchTool = Tool.define("websearch", async () => {
return { return {
get description() { get description() {
return DESCRIPTION.replace("{{date}}", new Date().toISOString().slice(0, 10)) return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
}, },
parameters: z.object({ parameters: z.object({
query: z.string().describe("Websearch query"), query: z.string().describe("Websearch query"),

View file

@ -10,5 +10,5 @@ Usage notes:
- Configurable context length for optimal LLM integration - Configurable context length for optimal LLM integration
- Domain filtering and advanced search options available - Domain filtering and advanced search options available
Today's date is {{date}}. You MUST use this year when searching for recent information or current events The current year is {{year}}. You MUST use this year when searching for recent information or current events
- Example: If today is 2025-07-15 and the user asks for "latest AI news", search for "AI news 2025", NOT "AI news 2024" - Example: If the current year is 2026 and the user asks for "latest AI news", search for "AI news 2026", NOT "AI news 2025"

View file

@ -4,9 +4,14 @@ export function lazy<T>(fn: () => T) {
const result = (): T => { const result = (): T => {
if (loaded) return value as T if (loaded) return value as T
loaded = true try {
value = fn() value = fn()
return value as T loaded = true
return value as T
} catch (e) {
// Don't mark as loaded if initialization failed
throw e
}
} }
result.reset = () => { result.reset = () => {

View file

@ -7,7 +7,8 @@ import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { InstanceBootstrap } from "../project/bootstrap" import { InstanceBootstrap } from "../project/bootstrap"
import { Project } from "../project/project" import { Project } from "../project/project"
import { Storage } from "../storage/storage" import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn" import { fn } from "../util/fn"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
@ -307,7 +308,8 @@ export namespace Worktree {
} }
async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) { async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) {
const project = await Storage.read<Project.Info>(["project", input.projectID]).catch(() => undefined) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? "" const startup = project?.commands?.start?.trim() ?? ""
const ok = await runStartScript(directory, startup, "project") const ok = await runStartScript(directory, startup, "project")
if (!ok) return false if (!ok) return false

Some files were not shown because too many files have changed in this diff Show more