diff --git a/bun.lock b/bun.lock
index cc4990883e6..ab50720b24e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -129,6 +129,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
+ "drizzle-orm": "catalog:",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
@@ -152,7 +153,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
- "electron": "40.8.5",
+ "electron": "41.2.1",
"electron-builder": "^26",
"electron-vite": "^5",
"solid-js": "catalog:",
@@ -412,6 +413,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@morphllm/morphsdk": "0.2.166",
"@npmcli/arborist": "9.4.0",
+ "@npmcli/config": "10.8.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -422,8 +424,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
- "@opentui/core": "catalog:",
- "@opentui/solid": "catalog:",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -527,8 +529,8 @@
"zod": "catalog:",
},
"devDependencies": {
- "@opentui/core": "catalog:",
- "@opentui/solid": "catalog:",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -677,6 +679,7 @@
],
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
+ "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch",
},
"overrides": {
@@ -716,7 +719,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
- "@types/bun": "1.3.11",
+ "@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
@@ -1504,6 +1507,8 @@
"@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="],
+ "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="],
+
"@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="],
"@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="],
@@ -2214,7 +2219,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+ "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
@@ -2698,7 +2703,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
@@ -3074,7 +3079,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
- "electron": ["electron@40.8.5", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A=="],
+ "electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="],
"electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="],
diff --git a/flake.lock b/flake.lock
index 5c1783a1e94..1c8e62bd825 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1775823930,
- "narHash": "sha256-ALT447J7FcxP/97J01A/gp/hgdO5lXRsm+zLMt+gIjc=",
+ "lastModified": 1776683584,
+ "narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "8c11f88bb9573a10a7d6bf87161ef08455ac70b9",
+ "rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
"type": "github"
},
"original": {
diff --git a/nix/kilo.nix b/nix/kilo.nix
index ba6c7afc966..6c6375405f3 100644
--- a/nix/kilo.nix
+++ b/nix/kilo.nix
@@ -7,6 +7,7 @@
sysctl,
makeBinaryWrapper,
models-dev,
+ ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
@@ -51,25 +52,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postBuild
'';
- installPhase =
- ''
- runHook preInstall
-
- install -Dm755 dist/@kilocode/cli-*/bin/kilo $out/bin/kilo
- install -Dm644 schema.json $out/share/kilo/schema.json
- ''
- # bun runs sysctl to detect if dunning on rosetta2
- + lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
- wrapProgram $out/bin/kilo \
- --prefix PATH : ${
- lib.makeBinPath [
- sysctl
+ installPhase = ''
+ runHook preInstall
+
+ install -Dm755 dist/@kilocode/cli-*/bin/kilo $out/bin/kilo
+ install -Dm644 schema.json $out/share/kilo/schema.json
+
+ wrapProgram $out/bin/kilo \
+ --prefix PATH : ${
+ lib.makeBinPath (
+ [
+ ripgrep
]
- }
- ''
- + ''
- runHook postInstall
- '';
+ # bun runs sysctl to detect if dunning on rosetta2
+ ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
+ )
+ }
+
+ runHook postInstall
+ '';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
diff --git a/package.json b/package.json
index 4039051fec8..0378928eecd 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,10 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
- "packageManager": "bun@1.3.11",
+ "packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
- "dev:desktop": "bun --cwd packages/desktop tauri dev",
+ "dev:desktop": "bun --cwd packages/desktop-electron dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
@@ -29,7 +29,7 @@
"@effect/opentelemetry": "4.0.0-beta.48",
"@effect/platform-node": "4.0.0-beta.48",
"@npmcli/arborist": "9.4.0",
- "@types/bun": "1.3.11",
+ "@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
@@ -141,6 +141,7 @@
"happy-dom": ">=20.8.9"
},
"patchedDependencies": {
+ "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch"
diff --git a/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2
new file mode 100644
index 00000000000..02a57c6f500
Binary files /dev/null and b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 differ
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index dc75754d477..6bec1de84f0 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -141,13 +141,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}>
-
-
-
- {props.children}
-
-
-
+
+
+ {props.children}
+
+
@@ -293,20 +291,22 @@ export function AppInterface(props: {
>
-
-
- {routerProps.children}}
- >
-
-
-
-
-
-
-
-
+
+
+
+ {routerProps.children}}
+ >
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx
index ea5d70065ad..8eb12daf52e 100644
--- a/packages/app/src/components/dialog-edit-project.tsx
+++ b/packages/app/src/components/dialog-edit-project.tsx
@@ -12,6 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/shared/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
+import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
@@ -26,8 +27,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [store, setStore] = createStore({
name: defaultName(),
- color: props.project.icon?.color || "pink",
- iconUrl: props.project.icon?.override || "",
+ color: props.project.icon?.color,
+ iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
@@ -39,7 +40,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
- setStore("iconUrl", e.target?.result as string)
+ setStore("iconOverride", e.target?.result as string)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
@@ -68,7 +69,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
}
function clearIcon() {
- setStore("iconUrl", "")
+ setStore("iconOverride", "")
}
const saveMutation = useMutation(() => ({
@@ -81,17 +82,17 @@ export function DialogEditProject(props: { project: LocalProject }) {
projectID: props.project.id,
directory: props.project.worktree,
name,
- icon: { color: store.color, override: store.iconUrl },
+ icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
- globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
+ globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
- icon: { color: store.color, override: store.iconUrl || undefined },
+ icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
@@ -130,13 +131,13 @@ export function DialogEditProject(props: { project: LocalProject }) {
classList={{
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
- "overflow-hidden": !!store.iconUrl,
+ "overflow-hidden": !!store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
- if (store.iconUrl && store.iconHover) {
+ if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
@@ -144,7 +145,11 @@ export function DialogEditProject(props: { project: LocalProject }) {
}}
>
}
>
-
+ {(src) => (
+
+ )}
@@ -174,8 +181,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -198,7 +205,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
+
@@ -215,7 +222,10 @@ export function DialogEditProject(props: { project: LocalProject }) {
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color,
}}
- onClick={() => setStore("color", color)}
+ onClick={() => {
+ if (store.color === color && !props.project.icon?.url) return
+ setStore("color", store.color === color ? undefined : color)
+ }}
>
-
+
{(i) => {
const key = ServerConnection.key(i)
@@ -619,7 +619,7 @@ export function DialogSelectServer() {
-
+
= (props) => {
}
}
- const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory))
- const agentsLoading = () => agentsQuery.isLoading
-
- const globalProvidersQuery = useQuery(() => loadProvidersQuery(null))
- const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory))
+ const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
+ queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)],
+ }))
+ const agentsLoading = () => agentsQuery.isLoading
+ const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
+ const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
+
+ const [promptReady] = createResource(
+ () => prompt.ready().promise,
+ (p) => p,
+ )
return (
+ {(promptReady(), null)}
(slashPopoverRef = el)}
@@ -1359,15 +1366,13 @@ export const PromptInput: Component = (props) => {
}}
style={{ "padding-bottom": space }}
/>
-
-
- {placeholder()}
-
-
+
+ {placeholder()}
+
= (props) => {
-
+
= (props) => {
-
+
0}
fallback={
@@ -1558,7 +1569,10 @@ export const PromptInput: Component = (props) => {
-
+
{
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
+ const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
@@ -276,6 +280,18 @@ export const SettingsGeneral: Component = () => {
/>
+
+
+
+ settings.general.setShowSessionProgressBar(checked)}
+ />
+
+
)
@@ -451,6 +467,29 @@ export const SettingsGeneral: Component = () => {
/>
+
+
+
+ settings.appearance.setTerminalFont(value)}
+ placeholder={terminalDefault}
+ spellcheck={false}
+ autocorrect="off"
+ autocomplete="off"
+ autocapitalize="off"
+ class="text-12-regular"
+ style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
+ />
+
+
)
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 57e91d6d335..ff5ff9dada8 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
-import { monoFontFamily, useSettings } from "@/context/settings"
+import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => {
})
createEffect(() => {
- const font = monoFontFamily(settings.appearance.font())
+ const font = terminalFontFamily(settings.appearance.terminalFont())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
@@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => {
cols: restoreSize?.cols,
rows: restoreSize?.rows,
fontSize: 14,
- fontFamily: monoFontFamily(settings.appearance.font()),
+ fontFamily: terminalFontFamily(settings.appearance.terminalFont()),
allowTransparency: false,
convertEol: false,
theme: terminalColors(),
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index b7edea70cd1..edebef2b909 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -9,10 +9,9 @@ import type {
} from "@kilocode/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path"
-import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
+import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
-import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
@@ -24,7 +23,6 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
-import { sanitizeProject } from "./global-sync/utils"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
@@ -56,15 +54,10 @@ function createGlobalSync() {
const sessionLoads = new Map
>()
const sessionMeta = new Map()
- const [projectCache, setProjectCache, projectInit] = persisted(
- Persist.global("globalSync.project", ["globalSync.project.v1"]),
- createStore({ value: [] as Project[] }),
- )
-
const [globalStore, setGlobalStore] = createStore({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
- project: projectCache.value,
+ project: [],
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
@@ -73,37 +66,18 @@ function createGlobalSync() {
})
const queryClient = useQueryClient()
- let active = true
- let projectWritten = false
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType | undefined
- onCleanup(() => {
- active = false
- })
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
- const cacheProjects = () => {
- setProjectCache(
- "value",
- untrack(() => globalStore.project.map(sanitizeProject)),
- )
- }
-
- const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
- projectWritten = true
- if (typeof next === "function") {
- setGlobalStore("project", produce(next))
- cacheProjects()
- return
- }
+ const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
- cacheProjects()
}
const setBootStore = ((...input: unknown[]) => {
@@ -116,22 +90,12 @@ function createGlobalSync() {
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
- setProjects(input[1] as Project[] | ((draft: Project[]) => void))
+ setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
- if (projectInit instanceof Promise) {
- void projectInit.then(() => {
- if (!active) return
- if (projectWritten) return
- const cached = projectCache.value
- if (cached.length === 0) return
- setGlobalStore("project", cached)
- })
- }
-
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
@@ -223,16 +187,18 @@ function createGlobalSync() {
limit,
permission: store.permission,
})
- setStore(
- "sessionTotal",
- estimateRootSessionTotal({
- count: nonArchived.length,
- limit: x.limit,
- limited: x.limited,
- }),
- )
- setStore("session", reconcile(sessions, { key: "id" }))
- cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
+ batch(() => {
+ setStore(
+ "sessionTotal",
+ estimateRootSessionTotal({
+ count: nonArchived.length,
+ limit: x.limit,
+ limited: x.limited,
+ }),
+ )
+ setStore("session", reconcile(sessions, { key: "id" }))
+ cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
+ })
sessionMeta.set(directory, { limit })
})
.catch((err) => {
@@ -298,6 +264,19 @@ function createGlobalSync() {
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
+ if (event.type === "session.error") {
+ const error = event.properties.error
+ if (error?.name !== "MessageAbortedError") {
+ console.error("[global-sync] session error", {
+ scope: directory === "global" ? "global" : "workspace",
+ directory: directory === "global" ? undefined : directory,
+ project: directory === "global" ? undefined : getFilename(directory),
+ sessionID: event.properties.sessionID,
+ error,
+ })
+ }
+ }
+
if (directory === "global") {
applyGlobalEvent({
event,
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 86386fd59a3..0b4d843026a 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -19,7 +19,6 @@ import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
-import { loadSessionsQuery } from "../global-sync"
type GlobalStore = {
ready: boolean
@@ -82,6 +81,9 @@ export async function bootstrapGlobal(input: {
input.setGlobalStore("config", x.data!)
}),
),
+ ]
+
+ const slow = [
() =>
input.queryClient.fetchQuery({
...loadProvidersQuery(null),
@@ -93,9 +95,6 @@ export async function bootstrapGlobal(input: {
}),
),
}),
- ]
-
- const slow = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
@@ -183,8 +182,43 @@ function warmSessions(input: {
export const loadProvidersQuery = (directory: string | null) =>
queryOptions({ queryKey: [directory, "providers"], queryFn: skipToken })
-export const loadAgentsQuery = (directory: string | null) =>
- queryOptions({ queryKey: [directory, "agents"], queryFn: skipToken })
+export const loadAgentsQuery = (
+ directory: string | null,
+ sdk?: KiloClient,
+ transform?: (x: Awaited>) => void,
+) =>
+ queryOptions({
+ queryKey: [directory, "agents"],
+ queryFn:
+ sdk && transform
+ ? () =>
+ retry(() =>
+ sdk.app
+ .agents()
+ .then(transform)
+ .then(() => null),
+ )
+ : skipToken,
+ })
+
+export const loadPathQuery = (
+ directory: string | null,
+ sdk?: KiloClient,
+ transform?: (x: Awaited>) => void,
+) =>
+ queryOptions({
+ queryKey: [directory, "path"],
+ queryFn:
+ sdk && transform
+ ? () =>
+ retry(() =>
+ sdk.path.get().then(async (x) => {
+ transform(x)
+ return x.data!
+ }),
+ )
+ : skipToken,
+ })
export async function bootstrapDirectory(input: {
directory: string
@@ -222,45 +256,27 @@ export async function bootstrapDirectory(input: {
input.setStore("lsp", [])
if (loading) input.setStore("status", "partial")
- const fast = [() => Promise.resolve(input.loadSessions(input.directory))]
-
- const errs = errors(await runAll(fast))
- if (errs.length > 0) {
- console.error("Failed to bootstrap instance", errs[0])
- const project = getFilename(input.directory)
- showToast({
- variant: "error",
- title: input.translate("toast.project.reloadFailed.title", { project }),
- description: formatServerError(errs[0], input.translate),
- })
- }
-
+ const rev = (providerRev.get(input.directory) ?? 0) + 1
+ providerRev.set(input.directory, rev)
;(async () => {
const slow = [
+ () => Promise.resolve(input.loadSessions(input.directory)),
() =>
- input.queryClient.ensureQueryData({
- ...loadAgentsQuery(input.directory),
- queryFn: () =>
- retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then(
- () => null,
- ),
- }),
+ input.queryClient.ensureQueryData(
+ loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
+ ),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
- () =>
- seededProject
- ? Promise.resolve()
- : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
- () =>
- seededPath
- ? Promise.resolve()
- : retry(() =>
- input.sdk.path.get().then((x) => {
- input.setStore("path", x.data!)
- const next = projectID(x.data?.directory ?? input.directory, input.global.project)
- if (next) input.setStore("project", next)
- }),
- ),
+ !seededProject &&
+ (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
+ !seededPath &&
+ (() =>
+ input.queryClient.ensureQueryData(
+ loadPathQuery(input.directory, input.sdk, (x) => {
+ const next = projectID(x.data?.directory ?? input.directory, input.global.project)
+ if (next) input.setStore("project", next)
+ }),
+ )),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
@@ -330,7 +346,28 @@ export async function bootstrapDirectory(input: {
input.setStore("mcp_ready", true)
}),
),
- ]
+ () =>
+ input.queryClient.ensureQueryData({
+ ...loadProvidersQuery(input.directory),
+ queryFn: () =>
+ retry(() => input.sdk.provider.list())
+ .then((x) => {
+ if (providerRev.get(input.directory) !== rev) return
+ input.setStore("provider", normalizeProviderList(x.data!))
+ input.setStore("provider_ready", true)
+ })
+ .catch((err) => {
+ if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
+ const project = getFilename(input.directory)
+ showToast({
+ variant: "error",
+ title: input.translate("toast.project.reloadFailed.title", { project }),
+ description: formatServerError(err, input.translate),
+ })
+ })
+ .then(() => null),
+ }),
+ ].filter(Boolean) as (() => Promise)[]
await waitForPaint()
const slowErrs = errors(await runAll(slow))
@@ -344,29 +381,6 @@ export async function bootstrapDirectory(input: {
})
}
- if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
-
- const rev = (providerRev.get(input.directory) ?? 0) + 1
- providerRev.set(input.directory, rev)
- void input.queryClient.ensureQueryData({
- ...loadSessionsQuery(input.directory),
- queryFn: () =>
- retry(() => input.sdk.provider.list())
- .then((x) => {
- if (providerRev.get(input.directory) !== rev) return
- input.setStore("provider", normalizeProviderList(x.data!))
- input.setStore("provider_ready", true)
- })
- .catch((err) => {
- if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
- const project = getFilename(input.directory)
- showToast({
- variant: "error",
- title: input.translate("toast.project.reloadFailed.title", { project }),
- description: formatServerError(err, input.translate),
- })
- })
- .then(() => null),
- })
+ if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
}
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index b94b03fbc35..1545fb42089 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -14,6 +14,8 @@ import {
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
+import { useQuery } from "@tanstack/solid-query"
+import { loadPathQuery } from "./bootstrap"
export function createChildStoreManager(input: {
owner: Owner
@@ -154,16 +156,21 @@ export function createChildStoreManager(input: {
const init = () =>
createRoot((dispose) => {
- const initialMeta = meta[0].value
const initialIcon = icon[0].value
+
+ const pathQuery = useQuery(() => loadPathQuery(directory))
const child = createStore({
project: "",
- projectMeta: initialMeta,
+ projectMeta: undefined,
icon: initialIcon,
provider_ready: false,
provider: { all: [], connected: [], default: {} },
config: {},
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ get path() {
+ if (pathQuery.isLoading || !pathQuery.data)
+ return { state: "", config: "", worktree: "", directory: "", home: "" }
+ return pathQuery.data
+ },
status: "loading" as const,
agent: [],
command: [],
@@ -200,11 +207,6 @@ export function createChildStoreManager(input: {
child[1]("vcs", (value) => value ?? cached)
})
- onPersistedInit(meta[2], () => {
- if (child[0].projectMeta !== initialMeta) return
- child[1]("projectMeta", meta[0].value)
- })
-
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index d628cad723d..9196d5b4f76 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -21,7 +21,7 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
- setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
+ setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
@@ -33,14 +33,18 @@ export function applyGlobalEvent(input: {
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
- input.setGlobalProject((draft) => {
- draft[result.index] = { ...draft[result.index], ...properties }
- })
+ input.setGlobalProject(
+ produce((draft) => {
+ draft[result.index] = { ...draft[result.index], ...properties }
+ }),
+ )
return
}
- input.setGlobalProject((draft) => {
- draft.splice(result.index, 0, properties)
- })
+ input.setGlobalProject(
+ produce((draft) => {
+ draft.splice(result.index, 0, properties)
+ }),
+ )
}
function cleanupSessionCaches(
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 192d2499900..603c3c5961c 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -391,37 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
- const local = childStore.projectMeta
- const localOverride =
- local?.name !== undefined ||
- local?.commands?.start !== undefined ||
- local?.icon?.override !== undefined ||
- local?.icon?.color !== undefined
-
- const base = {
- ...metadata,
- ...project,
- icon: {
- url: metadata?.icon?.url,
- override: metadata?.icon?.override ?? childStore.icon,
- color: metadata?.icon?.color,
- },
- }
-
- const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
- if (!isGlobal) return base
-
- return {
- ...base,
- id: base.id ?? "global",
- name: local?.name,
- commands: local?.commands,
- icon: {
- url: base.icon?.url,
- override: local?.icon?.override,
- color: local?.icon?.color,
- },
- }
+ return { ...metadata, ...project }
}
const roots = createMemo(() => {
@@ -516,7 +486,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
for (const project of projects) {
- if (project.icon?.color) continue
+ if (project.icon?.color || project.icon?.override || project.icon?.url) continue
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 9b666e5e751..15af57b355e 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -185,9 +185,9 @@ function createPromptSession(dir: string, id: string | undefined) {
return {
ready,
- current: createMemo(() => store.prompt),
+ current: () => store.prompt,
cursor: createMemo(() => store.cursor),
- dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+ dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
@@ -277,7 +277,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return {
- ready: () => session().ready(),
+ ready: () => session().ready,
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index a585789ce46..be2fb49d7e0 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -31,6 +31,7 @@ export interface Settings {
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
+ showSessionProgressBar: boolean
}
updates: {
startup: boolean
@@ -39,6 +40,7 @@ export interface Settings {
fontSize: number
mono: string
sans: string
+ terminal: string
}
keybinds: Record
permissions: {
@@ -50,13 +52,17 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
+export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
+const terminalFallback =
+ '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const monoBase = monoFallback
const sansBase = sansFallback
+const terminalBase = terminalFallback
function input(font: string | undefined) {
return font ?? ""
@@ -89,6 +95,14 @@ export function sansFontFamily(font: string | undefined) {
return stack(font, sansBase)
}
+export function terminalInput(font: string | undefined) {
+ return input(font)
+}
+
+export function terminalFontFamily(font: string | undefined) {
+ return stack(font, terminalBase)
+}
+
const defaultSettings: Settings = {
general: {
autoSave: true,
@@ -102,6 +116,7 @@ const defaultSettings: Settings = {
showReasoningSummaries: false,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
+ showSessionProgressBar: true,
},
updates: {
startup: true,
@@ -110,6 +125,7 @@ const defaultSettings: Settings = {
fontSize: 14,
mono: "",
sans: "",
+ terminal: "",
},
keybinds: {},
permissions: {
@@ -213,6 +229,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
+ showSessionProgressBar: withFallback(
+ () => store.general?.showSessionProgressBar,
+ defaultSettings.general.showSessionProgressBar,
+ ),
+ setShowSessionProgressBar(value: boolean) {
+ setStore("general", "showSessionProgressBar", value)
+ },
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
@@ -233,6 +256,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setUIFont(value: string) {
setStore("appearance", "sans", value.trim() ? value : "")
},
+ terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
+ setTerminalFont(value: string) {
+ setStore("appearance", "terminal", value.trim() ? value : "")
+ },
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index afc5d08765f..6a5c8a24a11 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -564,7 +564,9 @@ export const dict = {
"settings.general.row.theme.title": "السمة",
"settings.general.row.theme.description": "تخصيص سمة Kilo.",
"settings.general.row.font.title": "خط الكود",
- "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
+ "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",
@@ -579,6 +581,8 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
+ "settings.general.row.showSessionProgressBar.title": "إظهار شريط تقدم الجلسة",
+ "settings.general.row.showSessionProgressBar.description": "عرض شريط التقدم المتحرك أعلى الجلسة أثناء عمل الوكيل",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 217a017e606..23ab983bd65 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -572,7 +572,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personalize como o Kilo é tematizado.",
"settings.general.row.font.title": "Fonte de código",
- "settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
+ "settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Fonte da interface",
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
"settings.general.row.followup.title": "Comportamento de acompanhamento",
@@ -588,6 +590,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
+ "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progresso da sessão",
+ "settings.general.row.showSessionProgressBar.description":
+ "Exibir a barra de progresso animada no topo da sessão quando o agente estiver trabalhando",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index 46b40269b00..e8944d2406b 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -637,7 +637,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Prilagodi temu Kilo-a.",
"settings.general.row.font.title": "Font za kod",
- "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
+ "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
@@ -653,6 +655,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
+ "settings.general.row.showSessionProgressBar.title": "Prikaži traku napretka sesije",
+ "settings.general.row.showSessionProgressBar.description":
+ "Prikaži animiranu traku napretka na vrhu sesije kada agent radi",
"settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 66b6ead5799..a6fb99eeac6 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -632,7 +632,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpas hvordan Kilo er temabestemt.",
"settings.general.row.font.title": "Kode-skrifttype",
- "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
+ "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
"settings.general.row.followup.title": "Opfølgningsadfærd",
@@ -647,6 +649,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
+ "settings.general.row.showSessionProgressBar.title": "Vis sessionens fremdriftslinje",
+ "settings.general.row.showSessionProgressBar.description":
+ "Vis den animerede fremdriftslinje øverst i sessionen, når agenten arbejder",
"settings.general.row.wayland.title": "Brug native Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index e5ca0038a3e..4b6c2310714 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -581,7 +581,9 @@ export const dict = {
"settings.general.row.theme.title": "Thema",
"settings.general.row.theme.description": "Das Thema von Kilo anpassen.",
"settings.general.row.font.title": "Code-Schriftart",
- "settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
+ "settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-Schriftart",
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
@@ -598,6 +600,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
+ "settings.general.row.showSessionProgressBar.title": "Sitzungsfortschrittsleiste anzeigen",
+ "settings.general.row.showSessionProgressBar.description":
+ "Die animierte Fortschrittsleiste oben in der Sitzung anzeigen, wenn der Agent arbeitet",
"settings.general.row.wayland.title": "Natives Wayland verwenden",
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 30b5fce6361..11c83dde208 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -735,7 +735,9 @@ export const dict = {
"settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how Kilo is themed.",
"settings.general.row.font.title": "Code Font",
- "settings.general.row.font.description": "Customise the font used in code blocks and terminals",
+ "settings.general.row.font.description": "Customise the font used in code blocks",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI Font",
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
"settings.general.row.followup.title": "Follow-up behavior",
@@ -760,6 +762,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline",
+ "settings.general.row.showSessionProgressBar.title": "Show session progress bar",
+ "settings.general.row.showSessionProgressBar.description":
+ "Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index c00c2ba50c9..bae6b6e3b18 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -640,7 +640,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personaliza el tema de Kilo.",
"settings.general.row.font.title": "Fuente de código",
- "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
+ "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Fuente de la interfaz",
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
"settings.general.row.followup.title": "Comportamiento de seguimiento",
@@ -657,6 +659,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
+ "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progreso de la sesión",
+ "settings.general.row.showSessionProgressBar.description":
+ "Mostrar la barra de progreso animada en la parte superior de la sesión cuando el agente esté trabajando",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index 2bd6ea940e6..e73c54d2128 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -579,7 +579,9 @@ export const dict = {
"settings.general.row.theme.title": "Thème",
"settings.general.row.theme.description": "Personnaliser le thème d'Kilo.",
"settings.general.row.font.title": "Police de code",
- "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
+ "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Police de l'interface",
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
"settings.general.row.followup.title": "Comportement de suivi",
@@ -596,6 +598,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
+ "settings.general.row.showSessionProgressBar.title": "Afficher la barre de progression de la session",
+ "settings.general.row.showSessionProgressBar.description":
+ "Afficher la barre de progression animée en haut de la session lorsque l'agent travaille",
"settings.general.row.wayland.title": "Utiliser Wayland natif",
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index 9d1657c12f4..9d68b8975d4 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -569,7 +569,9 @@ export const dict = {
"settings.general.row.theme.title": "テーマ",
"settings.general.row.theme.description": "Kiloのテーマをカスタマイズします。",
"settings.general.row.font.title": "コードフォント",
- "settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
+ "settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",
@@ -585,6 +587,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
+ "settings.general.row.showSessionProgressBar.title": "セッション進行状況バーを表示",
+ "settings.general.row.showSessionProgressBar.description":
+ "エージェントの作業中に、セッション上部にアニメーション付きの進行状況バーを表示します",
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 18274900092..234980c1038 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -566,7 +566,9 @@ export const dict = {
"settings.general.row.theme.title": "테마",
"settings.general.row.theme.description": "Kilo 테마 사용자 지정",
"settings.general.row.font.title": "코드 글꼴",
- "settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
+ "settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",
@@ -581,6 +583,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
"settings.general.row.editToolPartsExpanded.description":
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
+ "settings.general.row.showSessionProgressBar.title": "세션 진행 표시줄 표시",
+ "settings.general.row.showSessionProgressBar.description":
+ "에이전트가 작업 중일 때 세션 상단에 애니메이션 진행 표시줄을 표시합니다",
"settings.general.row.wayland.title": "네이티브 Wayland 사용",
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index a77f8b025aa..0dbf94e347c 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -640,7 +640,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpass hvordan Kilo er tematisert.",
"settings.general.row.font.title": "Kodefont",
- "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
+ "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-skrift",
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
"settings.general.row.followup.title": "Oppfølgingsadferd",
@@ -654,6 +656,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
+ "settings.general.row.showSessionProgressBar.title": "Vis fremdriftslinje for sesjonen",
+ "settings.general.row.showSessionProgressBar.description":
+ "Vis den animerte fremdriftslinjen øverst i sesjonen når agenten jobber",
"settings.general.row.wayland.title": "Bruk innebygd Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 64170ee1d64..e0b11074339 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -571,7 +571,9 @@ export const dict = {
"settings.general.row.theme.title": "Motyw",
"settings.general.row.theme.description": "Dostosuj motyw Kilo.",
"settings.general.row.font.title": "Czcionka kodu",
- "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
+ "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Czcionka interfejsu",
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
"settings.general.row.followup.title": "Zachowanie kontynuacji",
@@ -586,6 +588,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
+ "settings.general.row.showSessionProgressBar.title": "Pokazuj pasek postępu sesji",
+ "settings.general.row.showSessionProgressBar.description":
+ "Wyświetlaj animowany pasek postępu u góry sesji, gdy agent pracuje",
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 89c9862951b..7838dcd14be 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -637,7 +637,9 @@ export const dict = {
"settings.general.row.theme.title": "Тема",
"settings.general.row.theme.description": "Настройте оформление Kilo.",
"settings.general.row.font.title": "Шрифт кода",
- "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
+ "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
@@ -654,6 +656,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
+ "settings.general.row.showSessionProgressBar.title": "Показывать индикатор прогресса сессии",
+ "settings.general.row.showSessionProgressBar.description":
+ "Показывать анимированный индикатор прогресса вверху сессии, когда агент работает",
"settings.general.row.wayland.title": "Использовать нативный Wayland",
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 4a6bffdcbb5..90f33affd5b 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -631,7 +631,9 @@ export const dict = {
"settings.general.row.theme.title": "ธีม",
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ Kilo มีธีม",
"settings.general.row.font.title": "ฟอนต์โค้ด",
- "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
+ "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "ฟอนต์ UI",
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
@@ -645,6 +647,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
+ "settings.general.row.showSessionProgressBar.title": "แสดงแถบความคืบหน้าของเซสชัน",
+ "settings.general.row.showSessionProgressBar.description":
+ "แสดงแถบความคืบหน้าแบบเคลื่อนไหวที่ด้านบนของเซสชันเมื่อเอเจนต์กำลังทำงาน",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts
index a49256cc13f..4a2ab351ff3 100644
--- a/packages/app/src/i18n/tr.ts
+++ b/packages/app/src/i18n/tr.ts
@@ -642,7 +642,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Kilo'un temasını özelleştirin.",
"settings.general.row.font.title": "Kod Yazı Tipi",
- "settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
+ "settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı",
@@ -659,6 +661,10 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
+ "settings.general.row.showSessionProgressBar.title": "Oturum ilerleme çubuğunu göster",
+ "settings.general.row.showSessionProgressBar.description":
+ "Ajan çalışırken oturumun üst kısmında animasyonlu ilerleme çubuğunu göster",
+
"settings.general.row.wayland.title": "Yerel Wayland kullan",
"settings.general.row.wayland.description":
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index 840c5ac9e11..7c54b6d60bc 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -631,7 +631,9 @@ export const dict = {
"settings.general.row.theme.title": "主题",
"settings.general.row.theme.description": "自定义 Kilo 的主题。",
"settings.general.row.font.title": "代码字体",
- "settings.general.row.font.description": "自定义代码块和终端使用的字体",
+ "settings.general.row.font.description": "自定义代码块使用的字体",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "界面字体",
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
"settings.general.row.followup.title": "跟进消息行为",
@@ -644,6 +646,8 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
+ "settings.general.row.showSessionProgressBar.title": "显示会话进度条",
+ "settings.general.row.showSessionProgressBar.description": "当智能体正在工作时,在会话顶部显示动画进度条",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index dcd680c9b93..1cc5cb521f8 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -626,7 +626,9 @@ export const dict = {
"settings.general.row.theme.title": "主題",
"settings.general.row.theme.description": "自訂 Kilo 的主題。",
"settings.general.row.font.title": "程式碼字型",
- "settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型",
+ "settings.general.row.font.description": "自訂程式碼區塊使用的字型",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "介面字型",
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
"settings.general.row.followup.title": "後續追問行為",
@@ -640,6 +642,8 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
+ "settings.general.row.showSessionProgressBar.title": "顯示工作階段進度列",
+ "settings.general.row.showSessionProgressBar.description": "當代理程式正在運作時,在工作階段頂部顯示動畫進度列",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index 629ac80a869..8db576dd834 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -1,5 +1,12 @@
@import "@opencode-ai/ui/styles/tailwind";
+@font-face {
+ font-family: "JetBrainsMono Nerd Font Mono";
+ src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
+ font-weight: normal;
+ font-style: normal;
+}
+
@layer components {
@keyframes session-progress-whip {
0% {
@@ -66,4 +73,13 @@
width: auto;
}
}
+
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
}
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index f604dd6c5c7..36514f56c63 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/shared/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
+import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
@@ -23,11 +23,10 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
- createEffect(() => {
- const id = params.id
- if (!id) return
- void sync.session.sync(id)
- })
+ createResource(
+ () => params.id,
+ (id) => sync.session.sync(id),
+ )
return (
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
-const roots = (store: SessionStore) =>
+export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 8cc554e723b..adb46170a87 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -19,6 +19,12 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const KILO_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
+ return id === KILO_PROJECT_ID
+ ? "https://kilo.ai/favicon.svg"
+ : (icon?.override ?? (icon?.color ? undefined : icon?.url))
+}
+
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const globalSync = useGlobalSync()
const notification = useNotification()
@@ -42,9 +48,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
-
+
{(child) => (
-
+
)}
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 8a685af3fe0..a39e28cdb3b 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -321,7 +321,7 @@ export const SortableWorkspace = (props: {
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
- const loading = () => query.isLoading
+ const loading = () => query.isLoading && count() === 0
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 4dbdb24633f..90e74e90a88 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -259,7 +259,7 @@ export function MessageTimeline(props: {
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
- const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
+ const working = createMemo(() => sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [timeoutDone, setTimeoutDone] = createSignal(true)
@@ -721,7 +721,7 @@ export function MessageTimeline(props: {
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
-
+
-
+
{(id) => (
@@ -878,12 +878,12 @@ export function MessageTimeline(props: {
-
void archiveSession(id())}>
+ void archiveSession(id)}>
{language.t("common.archive")}
dialog.show(() => )}
+ onSelect={() => dialog.show(() => )}
>
{language.t("common.delete")}
diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile
index 045ff7512ce..6998577bd53 100644
--- a/packages/containers/bun-node/Dockerfile
+++ b/packages/containers/bun-node/Dockerfile
@@ -6,7 +6,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
-ARG BUN_VERSION=1.3.11
+ARG BUN_VERSION=1.3.13
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts
index ad0a65a8140..22eedeada64 100644
--- a/packages/desktop-electron/electron.vite.config.ts
+++ b/packages/desktop-electron/electron.vite.config.ts
@@ -53,6 +53,10 @@ export default defineConfig({
build: {
rollupOptions: {
input: { index: "src/preload/index.ts" },
+ output: {
+ format: "cjs",
+ entryFileNames: "[name].js",
+ },
},
},
},
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index 1956e37bbec..ae8cef171f7 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -30,6 +30,7 @@
"electron-store": "^10",
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
+ "drizzle-orm": "catalog:",
"marked": "^15",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -53,7 +54,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
- "electron": "40.8.5",
+ "electron": "41.2.1",
"electron-builder": "^26",
"electron-vite": "^5",
"solid-js": "catalog:",
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index 5cee8a97194..98766e1b80d 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -28,8 +28,10 @@ const APP_IDS: Record = {
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
+const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
-app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
+app.setAppUserModelId(appId)
+app.setPath("userData", join(app.getPath("appData"), appId))
const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
@@ -40,7 +42,14 @@ import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
-import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
+import {
+ createLoadingWindow,
+ createMainWindow,
+ registerRendererProtocol,
+ setBackgroundColor,
+ setDockIcon,
+} from "./windows"
+import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
@@ -103,6 +112,7 @@ function setupApp() {
void app.whenReady().then(async () => {
app.setAsDefaultProtocolClient("opencode")
+ registerRendererProtocol()
setDockIcon()
setupAutoUpdater()
await initialize()
@@ -137,15 +147,6 @@ async function initialize() {
const url = `http://${hostname}:${port}`
const password = randomUUID()
- logger.log("spawning sidecar", { url })
- const { listener, health } = await spawnLocalServer(hostname, port, password)
- server = listener
- serverReady.resolve({
- url,
- username: "kilo", // kilocode_change
- password,
- })
-
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
@@ -156,10 +157,32 @@ async function initialize() {
if (progress.type === "Done") sqliteDone?.resolve()
})
+ if (needsMigration) {
+ const { Database, JsonMigration } = await import("virtual:opencode-server")
+ await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
+ progress: (event: { current: number; total: number }) => {
+ const percent = Math.round(event.current / event.total) * 100
+ initEmitter.emit("sqlite", { type: "InProgress", value: percent })
+ },
+ })
+ initEmitter.emit("sqlite", { type: "Done" })
+
+ sqliteDone?.resolve()
+ }
+
if (needsMigration) {
await sqliteDone?.promise
}
+ logger.log("spawning sidecar", { url })
+ const { listener, health } = await spawnLocalServer(hostname, port, password)
+ server = listener
+ serverReady.resolve({
+ url,
+ username: "kilo", // kilocode_change
+ password,
+ })
+
await Promise.race([
health.wait,
delay(30_000).then(() => {
@@ -172,15 +195,10 @@ async function initialize() {
logger.log("loading task finished")
})()
- const globals = {
- updaterEnabled: UPDATER_ENABLED,
- deepLinks: pendingDeepLinks,
- }
-
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
- overlay = createLoadingWindow(globals)
+ overlay = createLoadingWindow()
await delay(1_000)
}
}
@@ -192,7 +210,7 @@ async function initialize() {
await loadingComplete.promise
}
- mainWindow = createMainWindow(globals)
+ mainWindow = createMainWindow()
wireMenu()
overlay?.close()
@@ -229,6 +247,8 @@ registerIpcHandlers({
initEmitter.off("step", listener)
}
},
+ getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
+ consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
getWslConfig: () => Promise.resolve(getWslConfig()),
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
index 52d87ed7ee3..8dbca8eea17 100644
--- a/packages/desktop-electron/src/main/ipc.ts
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -2,7 +2,14 @@ import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
-import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
+import type {
+ InitStep,
+ ServerReadyData,
+ SqliteMigrationProgress,
+ TitlebarTheme,
+ WindowConfig,
+ WslConfig,
+} from "../preload/types"
import { getStore } from "./store"
import { setTitlebar } from "./windows"
@@ -14,6 +21,8 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise
+ getWindowConfig: () => Promise | WindowConfig
+ consumeInitialDeepLinks: () => Promise | string[]
getDefaultServerUrl: () => Promise | string | null
setDefaultServerUrl: (url: string | null) => Promise | void
getWslConfig: () => Promise
@@ -37,6 +46,8 @@ export function registerIpcHandlers(deps: Deps) {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
+ ipcMain.handle("get-window-config", () => deps.getWindowConfig())
+ ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts
index fcf209fb670..0d9a697fa90 100644
--- a/packages/desktop-electron/src/main/menu.ts
+++ b/packages/desktop-electron/src/main/menu.ts
@@ -47,7 +47,7 @@ export function createMenu(deps: Deps) {
{
label: "New Window",
accelerator: "Cmd+Shift+N",
- click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
+ click: () => createMainWindow(),
},
{ type: "separator" },
{ role: "close" },
diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts
index bad1349eeba..70e3dc9c750 100644
--- a/packages/desktop-electron/src/main/migrate.ts
+++ b/packages/desktop-electron/src/main/migrate.ts
@@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { CHANNEL } from "./constants"
-import { getStore, store } from "./store"
+import { getStore } from "./store"
const TAURI_MIGRATED_KEY = "tauriMigrated"
@@ -67,7 +67,7 @@ function migrateFile(datPath: string, filename: string) {
}
export function migrate() {
- if (store.get(TAURI_MIGRATED_KEY)) {
+ if (getStore().get(TAURI_MIGRATED_KEY)) {
log.log("tauri migration: already done, skipping")
return
}
@@ -77,7 +77,7 @@ export function migrate() {
if (!existsSync(dir)) {
log.log("tauri migration: no tauri data directory found, nothing to migrate")
- store.set(TAURI_MIGRATED_KEY, true)
+ getStore().set(TAURI_MIGRATED_KEY, true)
return
}
@@ -87,5 +87,5 @@ export function migrate() {
}
log.log("tauri migration: complete")
- store.set(TAURI_MIGRATED_KEY, true)
+ getStore().set(TAURI_MIGRATED_KEY, true)
}
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
index 72a83b5a05f..8c8f0895e93 100644
--- a/packages/desktop-electron/src/main/server.ts
+++ b/packages/desktop-electron/src/main/server.ts
@@ -1,33 +1,33 @@
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
-import { store } from "./store"
+import { getStore } from "./store"
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise }
export function getDefaultServerUrl(): string | null {
- const value = store.get(DEFAULT_SERVER_URL_KEY)
+ const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
if (url) {
- store.set(DEFAULT_SERVER_URL_KEY, url)
+ getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
}
- store.delete(DEFAULT_SERVER_URL_KEY)
+ getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function getWslConfig(): WslConfig {
- const value = store.get(WSL_ENABLED_KEY)
+ const value = getStore().get(WSL_ENABLED_KEY)
return { enabled: typeof value === "boolean" ? value : false }
}
export function setWslConfig(config: WslConfig) {
- store.set(WSL_ENABLED_KEY, config.enabled)
+ getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
@@ -39,6 +39,7 @@ export async function spawnLocalServer(hostname: string, port: number, password:
hostname,
username: "opencode",
password,
+ cors: ["oc://renderer"],
})
const wait = (async () => {
diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts
index 709e820e253..61f0c0a4938 100644
--- a/packages/desktop-electron/src/main/store.ts
+++ b/packages/desktop-electron/src/main/store.ts
@@ -4,6 +4,10 @@ import { SETTINGS_STORE } from "./constants"
const cache = new Map()
+// We cannot instantiate the electron-store at module load time because
+// module import hoisting causes this to run before app.setPath("userData", ...)
+// in index.ts has executed, which would result in files being written to the default directory
+// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
@@ -11,5 +15,3 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
-
-export const store = getStore(SETTINGS_STORE)
diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts
index 192e2dab9c9..337e1ca0bcc 100644
--- a/packages/desktop-electron/src/main/windows.ts
+++ b/packages/desktop-electron/src/main/windows.ts
@@ -1,15 +1,24 @@
import windowState from "electron-window-state"
-import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
-import { dirname, join } from "node:path"
-import { fileURLToPath } from "node:url"
+import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron"
+import { dirname, isAbsolute, join, relative, resolve } from "node:path"
+import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
-type Globals = {
- updaterEnabled: boolean
- deepLinks?: string[]
-}
-
const root = dirname(fileURLToPath(import.meta.url))
+const rendererRoot = join(root, "../renderer")
+const rendererProtocol = "oc"
+const rendererHost = "renderer"
+
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: rendererProtocol,
+ privileges: {
+ secure: true,
+ standard: true,
+ supportFetchAPI: true,
+ },
+ },
+])
let backgroundColor: string | undefined
@@ -54,7 +63,7 @@ export function setDockIcon() {
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
-export function createMainWindow(globals: Globals) {
+export function createMainWindow() {
const state = windowState({
defaultWidth: 1280,
defaultHeight: 800,
@@ -84,15 +93,29 @@ export function createMainWindow(globals: Globals) {
}
: {}),
webPreferences: {
- preload: join(root, "../preload/index.mjs"),
- sandbox: false,
+ preload: join(root, "../preload/index.js"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
},
})
+ win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
+ const { requestHeaders } = details
+ upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
+ callback({ requestHeaders })
+ })
+
+ win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
+ const { responseHeaders = {} } = details
+ upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"])
+ upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"])
+ callback({ responseHeaders })
+ })
+
state.manage(win)
loadWindow(win, "index.html")
wireZoom(win)
- injectGlobals(win, globals)
win.once("ready-to-show", () => {
win.show()
@@ -101,7 +124,7 @@ export function createMainWindow(globals: Globals) {
return win
}
-export function createLoadingWindow(globals: Globals) {
+export function createLoadingWindow() {
const mode = tone()
const win = new BrowserWindow({
width: 640,
@@ -120,17 +143,37 @@ export function createLoadingWindow(globals: Globals) {
}
: {}),
webPreferences: {
- preload: join(root, "../preload/index.mjs"),
- sandbox: false,
+ preload: join(root, "../preload/index.js"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
},
})
loadWindow(win, "loading.html")
- injectGlobals(win, globals)
return win
}
+export function registerRendererProtocol() {
+ if (protocol.isProtocolHandled(rendererProtocol)) return
+
+ protocol.handle(rendererProtocol, (request) => {
+ const url = new URL(request.url)
+ if (url.host !== rendererHost) {
+ return new Response("Not found", { status: 404 })
+ }
+
+ const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
+ const rel = relative(rendererRoot, file)
+ if (rel.startsWith("..") || isAbsolute(rel)) {
+ return new Response("Not found", { status: 404 })
+ }
+
+ return net.fetch(pathToFileURL(file).toString())
+ })
+}
+
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
@@ -139,25 +182,25 @@ function loadWindow(win: BrowserWindow, html: string) {
return
}
- void win.loadFile(join(root, `../renderer/${html}`))
-}
-
-function injectGlobals(win: BrowserWindow, globals: Globals) {
- win.webContents.on("dom-ready", () => {
- const deepLinks = globals.deepLinks ?? []
- const data = {
- updaterEnabled: globals.updaterEnabled,
- deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
- }
- void win.webContents.executeJavaScript(
- `window.__KILO__ = Object.assign(window.__KILO__ ?? {}, ${JSON.stringify(data)})`,
- )
- })
+ void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
-
function wireZoom(win: BrowserWindow) {
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", () => {
win.webContents.setZoomFactor(1)
})
}
+
+function upsertKeyValue(obj: Record, keyToChange: string, value: any) {
+ const keyToChangeLower = keyToChange.toLowerCase()
+ for (const key of Object.keys(obj)) {
+ if (key.toLowerCase() === keyToChangeLower) {
+ // Reassign old key
+ obj[key] = value
+ // Done
+ return
+ }
+ }
+ // Insert at end instead
+ obj[keyToChange] = value
+}
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
index 296fcb2f1cc..6261419ca55 100644
--- a/packages/desktop-electron/src/preload/index.ts
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -11,6 +11,8 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
+ getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
+ consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
index f8e6d52c7db..6e22954d18f 100644
--- a/packages/desktop-electron/src/preload/types.ts
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -15,10 +15,16 @@ export type TitlebarTheme = {
mode: "light" | "dark"
}
+export type WindowConfig = {
+ updaterEnabled: boolean
+}
+
export type ElectronAPI = {
killSidecar: () => Promise
installCli: () => Promise
awaitInitialization: (onStep: (step: InitStep) => void) => Promise
+ getWindowConfig: () => Promise
+ consumeInitialDeepLinks: () => Promise
getDefaultServerUrl: () => Promise
setDefaultServerUrl: (url: string | null) => Promise
getWslConfig: () => Promise
diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts
index d1590ff0486..6dff3baf1c4 100644
--- a/packages/desktop-electron/src/renderer/env.d.ts
+++ b/packages/desktop-electron/src/renderer/env.d.ts
@@ -4,8 +4,6 @@ declare global {
interface Window {
api: ElectronAPI
__OPENCODE__?: {
- updaterEnabled?: boolean
- wsl?: boolean
deepLinks?: string[]
}
}
diff --git a/packages/desktop-electron/src/renderer/html.test.ts b/packages/desktop-electron/src/renderer/html.test.ts
index bd8281c2fbe..1fc5c87178b 100644
--- a/packages/desktop-electron/src/renderer/html.test.ts
+++ b/packages/desktop-electron/src/renderer/html.test.ts
@@ -9,9 +9,9 @@ const root = resolve(dir, "../..")
const html = async (name: string) => Bun.file(join(dir, name)).text()
/**
- * Electron loads renderer HTML via `win.loadFile()` which uses the `file://`
- * protocol. Absolute paths like `src="/foo.js"` resolve to the filesystem root
- * (e.g. `file:///C:/foo.js` on Windows) instead of relative to the app bundle.
+ * Packaged Electron windows load renderer HTML via the privileged `oc://`
+ * protocol. Root-relative asset paths like `src="/foo.js"` would resolve from
+ * the protocol origin root instead of relative to the current HTML entrypoint.
*
* All local resource references must use relative paths (`./`).
*/
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index 54a7ea2b57b..a815a717126 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -20,7 +20,6 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
-import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme"
@@ -43,8 +42,7 @@ const emitDeepLinks = (urls: string[]) => {
}
const listenForDeepLinks = () => {
- const startUrls = window.__KILO__?.deepLinks ?? []
- if (startUrls.length) emitDeepLinks(startUrls)
+ void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
}
@@ -57,13 +55,21 @@ const createPlatform = (): Platform => {
return undefined
})()
+ const isWslEnabled = async () => {
+ if (os !== "windows") return false
+ return window.api
+ .getWslConfig()
+ .then((config) => config.enabled)
+ .catch(() => false)
+ }
+
const wslHome = async () => {
- if (os !== "windows" || !window.__KILO__?.wsl) return undefined
+ if (!(await isWslEnabled())) return undefined
return window.api.wslPath("~", "windows").catch(() => undefined)
}
const handleWslPicker = async (result: T | null): Promise => {
- if (!result || !window.__KILO__?.wsl) return result
+ if (!result || !(await isWslEnabled())) return result
if (Array.isArray(result)) {
return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
}
@@ -137,7 +143,7 @@ const createPlatform = (): Platform => {
if (os === "windows") {
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
const resolvedPath = await (async () => {
- if (window.__KILO__?.wsl) {
+ if (await isWslEnabled()) {
const converted = await window.api.wslPath(path, "windows").catch(() => null)
if (converted) return converted
}
@@ -159,12 +165,14 @@ const createPlatform = (): Platform => {
storage,
checkUpdate: async () => {
- if (!UPDATER_ENABLED()) return { updateAvailable: false }
+ const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
+ if (!config.updaterEnabled) return { updateAvailable: false }
return window.api.checkUpdate()
},
update: async () => {
- if (!UPDATER_ENABLED()) return
+ const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
+ if (!config.updaterEnabled) return
await window.api.installUpdate()
},
@@ -194,11 +202,7 @@ const createPlatform = (): Platform => {
return fetch(input, init)
},
- getWslEnabled: async () => {
- const next = await window.api.getWslConfig().catch(() => null)
- if (next) return next.enabled
- return window.__KILO__!.wsl ?? false
- },
+ getWslEnabled: () => isWslEnabled(),
setWslEnabled: async (enabled) => {
await window.api.setWslConfig({ enabled })
@@ -249,6 +253,7 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
+ const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -325,7 +330,15 @@ render(() => {
return (
-
+
{(_) => {
return (
window.__KILO__?.updaterEnabled ?? false
-
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n()
try {
diff --git a/packages/desktop/src-tauri/release/appstream.metainfo.xml b/packages/desktop/src-tauri/release/appstream.metainfo.xml
index ed21a0e5072..c15633a5a44 100644
--- a/packages/desktop/src-tauri/release/appstream.metainfo.xml
+++ b/packages/desktop/src-tauri/release/appstream.metainfo.xml
@@ -33,6 +33,9 @@
+
+ https://github.com/anomalyco/opencode/releases/tag/v1.4.0
+
https://github.com/Kilo-Org/kilocode/releases/tag/v1.0.223
diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json
index 30f02b3c30d..cbca92a9825 100644
--- a/packages/desktop/src-tauri/tauri.conf.json
+++ b/packages/desktop/src-tauri/tauri.conf.json
@@ -32,6 +32,7 @@
"icons/dev/icon.ico"
],
"active": true,
+ "category": "DeveloperTool",
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/kilo-cli"],
"linux": {
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index 51f7aba4706..c66b57cc447 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
-version = "7.2.23"
+version = "1.14.22"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.23/opencode-darwin-arm64.zip"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.23/opencode-darwin-x64.zip"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.23/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.23/opencode-linux-x64.tar.gz"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.23/opencode-windows-x64.zip"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md
index dd97d3bdfe3..dc51bd3d38d 100644
--- a/packages/kilo-docs/source-links.md
+++ b/packages/kilo-docs/source-links.md
@@ -1,7 +1,7 @@
# Source Code Links
-
+
-
@@ -33,6 +33,8 @@
-
+-
+
-
-
diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql b/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql
new file mode 100644
index 00000000000..e28a1d4e989
--- /dev/null
+++ b/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `project` ADD `icon_url_override` text;
+UPDATE `project` SET `icon_url_override` = `icon_url` WHERE `icon_url` IS NOT NULL;
diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json b/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json
new file mode 100644
index 00000000000..06dae8e44b7
--- /dev/null
+++ b/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json
@@ -0,0 +1,1409 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "66cbe0d7-def0-451b-b88a-7608513a9b44",
+ "prevIds": ["30b928c5-deef-472c-856d-b5b5064bf6d4"],
+ "ddl": [
+ {
+ "name": "account_state",
+ "entityType": "tables"
+ },
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "control_account",
+ "entityType": "tables"
+ },
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_entry",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "name": "event_sequence",
+ "entityType": "tables"
+ },
+ {
+ "name": "event",
+ "entityType": "tables"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active_account_id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active_org_id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "access_token",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refresh_token",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token_expiry",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "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": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''",
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "branch",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "extra",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "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_url_override",
+ "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_entry"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_entry"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "session_entry"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_entry"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_entry"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "session_entry"
+ },
+ {
+ "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": "workspace_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"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "aggregate_id",
+ "entityType": "columns",
+ "table": "event_sequence"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "seq",
+ "entityType": "columns",
+ "table": "event_sequence"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "event"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "aggregate_id",
+ "entityType": "columns",
+ "table": "event"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "seq",
+ "entityType": "columns",
+ "table": "event"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "event"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "event"
+ },
+ {
+ "columns": ["active_account_id"],
+ "tableTo": "account",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "SET NULL",
+ "nameExplicit": false,
+ "name": "fk_account_state_active_account_id_account_id_fk",
+ "entityType": "fks",
+ "table": "account_state"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_workspace_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "workspace"
+ },
+ {
+ "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": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_entry_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_entry"
+ },
+ {
+ "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": ["aggregate_id"],
+ "tableTo": "event_sequence",
+ "columnsTo": ["aggregate_id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+ "entityType": "fks",
+ "table": "event"
+ },
+ {
+ "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": "account_state_pk",
+ "table": "account_state",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "account_pk",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "workspace_pk",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "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_entry_pk",
+ "table": "session_entry",
+ "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": ["aggregate_id"],
+ "nameExplicit": false,
+ "name": "event_sequence_pk",
+ "table": "event_sequence",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "event_pk",
+ "table": "event",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ },
+ {
+ "value": "time_created",
+ "isExpression": false
+ },
+ {
+ "value": "id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_time_created_id_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ },
+ {
+ "value": "id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_id_id_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": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_entry_session_idx",
+ "entityType": "indexes",
+ "table": "session_entry"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ },
+ {
+ "value": "type",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_entry_session_type_idx",
+ "entityType": "indexes",
+ "table": "session_entry"
+ },
+ {
+ "columns": [
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_entry_time_created_idx",
+ "entityType": "indexes",
+ "table": "session_entry"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_workspace_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": []
+}
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 72fd1e43e2a..2b60c3ff245 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -117,6 +117,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@morphllm/morphsdk": "0.2.166",
"@npmcli/arborist": "9.4.0",
+ "@npmcli/config": "10.8.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -127,8 +128,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
- "@opentui/core": "catalog:",
- "@opentui/solid": "catalog:",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index 3d70edff9fd..4354d90b27b 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -126,12 +126,10 @@ const allTargets: {
arch: "x64",
avx2: false,
},
- // kilocode_change start - Windows ARM64 target
{
os: "win32",
arch: "arm64",
},
- // kilocode_change end
{
os: "win32",
arch: "x64",
@@ -141,12 +139,6 @@ const allTargets: {
arch: "x64",
avx2: false,
},
- // kilocode_change start - added Windows ARM64 target
- {
- os: "win32",
- arch: "arm64",
- },
- // kilocode_change end
]
const targets = singleFlag
@@ -195,7 +187,6 @@ for (const item of targets) {
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/cmd/tui/worker.ts"
- const rgPath = "./src/file/ripgrep.worker.ts"
// Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
@@ -220,19 +211,12 @@ for (const item of targets) {
windows: {},
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
- entrypoints: [
- "./src/index.ts",
- parserWorker,
- workerPath,
- rgPath,
- ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []),
- ],
+ entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
define: {
KILO_VERSION: `'${Script.version}'`,
KILO_MIGRATIONS: JSON.stringify(migrations),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
KILO_WORKER_PATH: workerPath,
- KILO_RIPGREP_WORKER_PATH: rgPath,
KILO_CHANNEL: `'${Script.channel}'`,
KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
},
diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts
index 98fbd082e54..ced313749bf 100755
--- a/packages/opencode/script/publish.ts
+++ b/packages/opencode/script/publish.ts
@@ -77,10 +77,10 @@ const image = "ghcr.io/kilo-org/kilo" // kilocode_change
const platforms = "linux/amd64,linux/arm64"
const tags = [`${image}:${version}`, `${image}:${Script.channel}`]
const tagFlags = tags.flatMap((t) => ["-t", t])
-await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
// registries
if (!Script.preview) {
+ await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
// Calculate SHA values
// kilocode_change start
const arm64Sha = await $`sha256sum ./dist/kilo-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
diff --git a/packages/opencode/script/run-workspace-server b/packages/opencode/script/run-workspace-server
new file mode 100755
index 00000000000..4371a157976
--- /dev/null
+++ b/packages/opencode/script/run-workspace-server
@@ -0,0 +1,106 @@
+#!/usr/bin/env bun
+
+// This script runs a separate OpenCode server to be used as a remote
+// workspace, simulating a remote environment but all local to make
+// debugger easier
+//
+// *Important*: make sure you add the debug workspace plugin first.
+// In `.opencode/opencode.jsonc` in the root of this project add:
+//
+// "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"]
+//
+// Afterwards, run `./packages/opencode/script/run-workspace-server`
+
+import { stat } from "node:fs/promises"
+import { setTimeout as sleep } from "node:timers/promises"
+
+const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json"
+const RESTART_POLL_INTERVAL = 250
+
+async function readData() {
+ return await Bun.file(DEV_DATA_FILE).json()
+}
+
+async function readDataMtime() {
+ return await stat(DEV_DATA_FILE)
+ .then((info) => info.mtimeMs)
+ .catch((error) => {
+ if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") {
+ return undefined
+ }
+
+ throw error
+ })
+}
+
+async function readSnapshot() {
+ while (true) {
+ try {
+ const before = await readDataMtime()
+ if (before === undefined) {
+ await sleep(RESTART_POLL_INTERVAL)
+ continue
+ }
+
+ const data = await readData()
+ const after = await readDataMtime()
+
+ if (before === after) {
+ return { data, mtime: after }
+ }
+ } catch (error) {
+ if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") {
+ await sleep(RESTART_POLL_INTERVAL)
+ continue
+ }
+
+ throw error
+ }
+ }
+}
+
+function startDevServer(data: any) {
+ const env = Object.fromEntries(Object.entries(data.env ?? {}).filter(([, value]) => value !== undefined))
+
+ return Bun.spawn(["bun", "run", "dev", "serve", "--port", String(data.port), "--print-logs"], {
+ env: {
+ ...process.env,
+ ...env,
+ XDG_DATA_HOME: "/tmp/data",
+ },
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ })
+}
+
+async function waitForRestartSignal(mtime: number, signal: AbortSignal) {
+ while (!signal.aborted) {
+ await sleep(RESTART_POLL_INTERVAL)
+ if (signal.aborted) return false
+ if ((await readDataMtime()) !== mtime) return true
+ }
+
+ return false
+}
+
+while (true) {
+ const { data, mtime } = await readSnapshot()
+ const proc = startDevServer(data)
+ const restartAbort = new AbortController()
+
+ const result = await Promise.race([
+ proc.exited.then((code) => ({ type: "exit" as const, code })),
+ waitForRestartSignal(mtime, restartAbort.signal).then((restart) => ({ type: "restart" as const, restart })),
+ ])
+
+ restartAbort.abort()
+
+ if (result.type === "restart" && result.restart) {
+ proc.kill()
+ await proc.exited
+ continue
+ }
+
+ process.exit(result.code)
+}
diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts
index c0f302f21ae..448760ae1aa 100755
--- a/packages/opencode/script/schema.ts
+++ b/packages/opencode/script/schema.ts
@@ -55,7 +55,7 @@ const configFile = process.argv[2]
const tuiFile = process.argv[3]
console.log(configFile)
-await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
+await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))
if (tuiFile) {
console.log(tuiFile)
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index 67f8abfbc4d..438f90796c9 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -224,7 +224,7 @@ When to use each:
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
-Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
+Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
@@ -373,9 +373,9 @@ The first slice is successful if:
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
-- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
+- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
-- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
+- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
### Integration
@@ -404,8 +404,7 @@ Current instance route inventory:
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
- bridged endpoint: `GET /config/providers`
- later endpoint: `GET /config`
+ bridged endpoints: `GET /config`, `GET /config/providers`
defer `PATCH /config` for now
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
@@ -431,9 +430,8 @@ Current instance route inventory:
Recommended near-term sequence:
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
-2. `config` full read endpoint (`GET /config`)
-3. `file` JSON read endpoints
-4. `mcp` JSON read endpoints
+2. `file` JSON read endpoints
+3. `mcp` JSON read endpoints
## Checklist
@@ -449,8 +447,8 @@ Recommended near-term sequence:
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
+- [x] port `GET /config` full read endpoint
- [ ] port `workspace` read endpoints
-- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md
index 7d0d7eb13c8..6d63715030f 100644
--- a/packages/opencode/specs/effect/instance-context.md
+++ b/packages/opencode/specs/effect/instance-context.md
@@ -224,7 +224,6 @@ These tools mostly use direct getters for path resolution and repo-relative disp
- `src/tool/bash.ts`
- `src/tool/edit.ts`
- `src/tool/lsp.ts`
-- `src/tool/multiedit.ts`
- `src/tool/plan.ts`
- `src/tool/read.ts`
- `src/tool/write.ts`
diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md
index 72ee10350dd..9ff6859cee1 100644
--- a/packages/opencode/specs/effect/schema.md
+++ b/packages/opencode/specs/effect/schema.md
@@ -97,7 +97,7 @@ creating a parallel schema source of truth.
## Escape hatches
-The walker in `@/util/effect-zod` exposes three explicit escape hatches for
+The walker in `@/util/effect-zod` exposes two explicit escape hatches for
cases the pure-Schema path cannot express. Each one stays in the codebase
only as long as its upstream or local dependency requires it — inline
comments document when each can be deleted.
@@ -109,19 +109,7 @@ Replaces the entire derivation with a hand-crafted zod schema. Used when:
- the target carries external `$ref` metadata (e.g.
`config/model-id.ts` points at `https://models.dev/...`)
- the target is a zod-only schema that cannot yet be expressed as Schema
- (e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`)
-
-### `ZodPreprocess` annotation
-
-Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by
-`config/permission.ts` to inject `__originalKeys` before parsing, because
-`Schema.StructWithRest` canonicalises output (known fields first, catchall
-after) and destroys the user's original property order — which permission
-rule precedence depends on.
-
-Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder
-(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and
-the `__originalKeys` hack can both be deleted.
+ (e.g. `ConfigAgent.Info`, `Log.Level`)
### Local `DeepMutable` in `config/config.ts`
@@ -171,21 +159,95 @@ Schema at source.
These are the highest-priority next targets. Each is a small, self-contained
schema module with a clear domain.
-- [ ] `src/control-plane/schema.ts`
-- [ ] `src/permission/schema.ts`
-- [ ] `src/project/schema.ts`
-- [ ] `src/provider/schema.ts`
-- [ ] `src/pty/schema.ts`
-- [ ] `src/question/schema.ts`
-- [ ] `src/session/schema.ts`
-- [ ] `src/sync/schema.ts`
-- [ ] `src/tool/schema.ts`
+- [x] `src/control-plane/schema.ts`
+- [x] `src/permission/schema.ts`
+- [x] `src/project/schema.ts`
+- [x] `src/provider/schema.ts`
+- [x] `src/pty/schema.ts`
+- [x] `src/question/schema.ts`
+- [x] `src/session/schema.ts`
+- [x] `src/sync/schema.ts`
+- [x] `src/tool/schema.ts`
### Session domain
Major cluster. Message + event types flow through the SSE API and every SDK
output, so byte-identical SDK surface is critical.
+Suggested order for this cluster, starting from the leaves that `session.ts`
+and the SSE/event surface depend on:
+
+1. `src/session/schema.ts` ✅ already migrated
+2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs
+3. `src/lsp/*` schema leaves needed by `LSP.Range`
+4. `src/snapshot/*` leaves used by `Snapshot.FileDiff`
+5. `src/session/message-v2.ts`
+6. `src/session/message.ts`
+7. `src/session/prompt.ts`
+8. `src/session/revert.ts`
+9. `src/session/summary.ts`
+10. `src/session/status.ts`
+11. `src/session/todo.ts`
+12. `src/session/session.ts`
+13. `src/session/compaction.ts`
+
+Dependency sketch:
+
+```text
+session.ts
+|- project/schema.ts
+|- control-plane/schema.ts
+|- permission/schema.ts
+|- snapshot/*
+|- message-v2.ts
+| |- provider/schema.ts
+| |- lsp/*
+| |- snapshot/*
+| |- sync/index.ts
+| `- bus/bus-event.ts
+|- sync/index.ts
+|- bus/bus-event.ts
+`- util/update-schema.ts
+```
+
+Working rule for this cluster:
+
+- migrate reusable leaf schemas and nested payload objects first
+- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as
+ named Schema values
+- leave zod-only event/update helpers in place temporarily when converting
+ them would force unrelated churn across sync/bus boundaries
+
+`message-v2.ts` first-pass outline:
+
+1. Schema-backed imports already available
+ - `SessionID`, `MessageID`, `PartID`
+ - `ProviderID`, `ModelID`
+2. Local leaf objects to extract and migrate first
+ - output format payloads
+ - common part bases like `PartBase`
+ - timestamp/range helper objects like `time.start/end`
+ - file/source helper objects
+ - token/cost/model helper objects
+3. Part variants built from those leaves
+ - `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart`
+ - `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart`
+ - retry/step/tool related parts
+4. Higher-level unions and DTOs
+ - `FilePartSource`
+ - part unions
+ - message unions and assistant/user payloads
+5. Errors and event payloads last
+ - `NamedError.create(...)` shapes can stay temporarily if converting them to
+ `Schema.TaggedErrorClass` would force unrelated churn
+ - `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using
+ derived `.zod` until the sync/bus layers are migrated
+
+Possible later tightening after the Schema-first migration is stable:
+
+- promote repeated opaque strings and timestamp numbers into branded/newtype
+ leaf schemas where that adds domain value without changing the wire format
+
- [ ] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts`
- [ ] `src/session/message.ts`
@@ -216,7 +278,6 @@ emitted JSON Schema must stay byte-identical.
- [ ] `src/tool/grep.ts`
- [ ] `src/tool/invalid.ts`
- [ ] `src/tool/lsp.ts`
-- [ ] `src/tool/multiedit.ts`
- [ ] `src/tool/plan.ts`
- [ ] `src/tool/question.ts`
- [ ] `src/tool/read.ts`
diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md
index 7b47831709a..3cc277357be 100644
--- a/packages/opencode/specs/effect/tools.md
+++ b/packages/opencode/specs/effect/tools.md
@@ -46,7 +46,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `lsp.ts`
-- [x] `multiedit.ts`
- [x] `plan.ts`
- [x] `question.ts`
- [x] `read.ts`
@@ -82,7 +81,6 @@ Notable items that are already effectively on the target path and do not need se
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
-- `multiedit.ts`
- `edit.ts`
## Filesystem notes
diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt
index 11deccb3afc..c7cb838bbaa 100644
--- a/packages/opencode/src/agent/prompt/compaction.txt
+++ b/packages/opencode/src/agent/prompt/compaction.txt
@@ -1,15 +1,9 @@
-You are a helpful AI assistant tasked with summarizing conversations.
+You are an anchored context summarization assistant for coding sessions.
-When asked to summarize, provide a detailed but concise summary of the conversation.
-Focus on information that would be helpful for continuing the conversation, including:
-- What was done
-- What is currently being worked on
-- Which files are being modified
-- What needs to be done next
-- Key user requests, constraints, or preferences that should persist
-- Important technical decisions and why they were made
+Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
-Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
+If the prompt includes a block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
-Do not respond to any questions in the conversation, only output the summary.
-Respond in the same language the user used in the conversation.
+Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
+
+Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.
diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts
index 185cab9c758..47db6358b6e 100644
--- a/packages/opencode/src/cli/cmd/debug/lsp.ts
+++ b/packages/opencode/src/cli/cmd/debug/lsp.ts
@@ -23,8 +23,7 @@ const DiagnosticsCommand = cmd({
const out = await AppRuntime.runPromise(
LSP.Service.use((lsp) =>
Effect.gen(function* () {
- yield* lsp.touchFile(args.file, true)
- yield* Effect.sleep(1000)
+ yield* lsp.touchFile(args.file, "full")
return yield* lsp.diagnostics()
}),
),
diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts
index c64f3edafe7..b62ca11c2fb 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/packages/opencode/src/cli/cmd/github.ts
@@ -992,7 +992,8 @@ export const GithubRunCommand = cmd({
const err = result.info.error
console.error("Agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
- throw new Error(`${err.name}: ${err.data?.message || ""}`)
+ const message = "message" in err.data ? err.data.message : ""
+ throw new Error(`${err.name}: ${message}`)
}
const text = extractResponseText(result.parts)
@@ -1021,7 +1022,8 @@ export const GithubRunCommand = cmd({
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
- throw new Error(`${err.name}: ${err.data?.message || ""}`)
+ const message = "message" in err.data ? err.data.message : ""
+ throw new Error(`${err.name}: ${message}`)
}
const summaryText = extractResponseText(summary.parts)
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 72cd5e71cc2..e9a435096d6 100644
--- a/packages/opencode/src/cli/cmd/import.ts
+++ b/packages/opencode/src/cli/cmd/import.ts
@@ -197,7 +197,7 @@ export const ImportCommand = cmd({
)
for (const msg of exportData.messages) {
- const msgInfo = MessageV2.Info.parse(msg.info)
+ const msgInfo = MessageV2.Info.zod.parse(msg.info)
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
@@ -213,7 +213,7 @@ export const ImportCommand = cmd({
)
for (const part of msg.parts) {
- const partInfo = MessageV2.Part.parse(part)
+ const partInfo = MessageV2.Part.zod.parse(part)
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 8dca70bdcb5..b43f24588ee 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -126,8 +126,8 @@ export function tui(input: {
const mode = await Terminal.getTerminalBackgroundColor()
- // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
- // the original console mode which re-enables ENABLE_PROCESSED_INPUT.
+ // Re-clear after getTerminalBackgroundColor() because setRawMode(false)
+ // restores the original console mode, including processed input on Windows.
win32DisableProcessedInput()
const onExit = async () => {
diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts
index 2b0273c3c6d..a1d745753bb 100644
--- a/packages/opencode/src/cli/cmd/tui/attach.ts
+++ b/packages/opencode/src/cli/cmd/tui/attach.ts
@@ -5,6 +5,8 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
+import { errorMessage } from "@/util/error"
+import { validateSession } from "./validate-session"
export const AttachCommand = cmd({
command: "attach ",
@@ -98,6 +100,20 @@ export const AttachCommand = cmd({
}
// kilocode_change end
const config = await TuiConfig.get()
+
+ try {
+ await validateSession({
+ url: args.url,
+ sessionID: args.session,
+ directory,
+ headers,
+ })
+ } catch (error) {
+ UI.error(errorMessage(error))
+ process.exitCode = 1
+ return
+ }
+
await tui({
url: args.url,
config,
diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx
index 803752e7664..43266315bf0 100644
--- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx
@@ -1,7 +1,9 @@
import { Global } from "@/global"
import { Filesystem } from "@/util"
+import { Flock } from "@opencode-ai/shared/util/flock"
+import { rename, rm } from "fs/promises"
import { createSignal, type Setter } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import path from "path"
@@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore>()
const filePath = path.join(Global.Path.state, "kv.json")
+ const lock = `tui-kv:${filePath}`
+ // Queue same-process writes so rapid updates persist in order.
+ let write = Promise.resolve()
- Filesystem.readJson>(filePath)
+ // Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence.
+ function writeSnapshot(snapshot: Record) {
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
+ return Filesystem.writeJson(tempPath, snapshot)
+ .then(() => rename(tempPath, filePath))
+ .catch(async (error) => {
+ await rm(tempPath, { force: true }).catch(() => undefined)
+ throw error
+ })
+ }
+
+ // Read under the same lock used for writes because kv.json is shared across processes.
+ Flock.withLock(lock, () => Filesystem.readJson>(filePath))
.then((x) => {
setStore(x)
})
- .catch(() => {})
+ .catch((error) => {
+ console.error("Failed to read KV state", { filePath, error })
+ })
.finally(() => {
setReady(true)
})
@@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
},
set(key: string, value: any) {
setStore(key, value)
- void Filesystem.writeJson(filePath, store)
+ const snapshot = structuredClone(unwrap(store))
+ write = write
+ .then(() => Flock.withLock(lock, () => writeSnapshot(snapshot)))
+ .catch((error) => {
+ console.error("Failed to write KV state", { filePath, error })
+ })
},
}
return result
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 2e47afeab2a..055015a501e 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -327,8 +327,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
- const mode = pick(kv.get("theme_mode", props.mode))
- draft.mode = lock ?? mode ?? props.mode
+ const mode = lock ?? props.mode
+ if (!lock && pick(kv.get("theme_mode")) !== undefined) {
+ kv.set("theme_mode", undefined)
+ }
+ draft.mode = mode
draft.lock = lock
const active = config.theme ?? kv.get("theme", "kilo") // kilocode_change
draft.active = typeof active === "string" ? active : "kilo" // kilocode_change
@@ -386,7 +389,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
function apply(mode: "dark" | "light") {
- kv.set("theme_mode", mode)
+ if (store.lock !== undefined) kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
renderer.clearPaletteCache()
@@ -402,6 +405,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
+ kv.set("theme_mode", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
}
@@ -410,7 +414,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.lock) return
apply(mode)
}
- // renderer.on(CliRenderEvents.THEME_MODE, handle)
+ renderer.on(CliRenderEvents.THEME_MODE, handle)
const refresh = () => {
renderer.clearPaletteCache()
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 02b64629034..e074880165c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -63,6 +63,7 @@ import { Flag } from "@/flag/flag"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import * as Clipboard from "../../util/clipboard"
+import { errorMessage } from "@/util/error"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import * as Editor from "../../util/editor"
@@ -261,31 +262,43 @@ export function Session() {
const toast = useToast()
const sdk = useSDK()
- createEffect(async () => {
- const previousWorkspace = project.workspace.current()
- const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
- if (!result.data) {
+ createEffect(() => {
+ const sessionID = route.sessionID
+ void (async () => {
+ const previousWorkspace = project.workspace.current()
+ const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
+ if (!result.data) {
+ toast.show({
+ message: `Session not found: ${sessionID}`,
+ variant: "error",
+ duration: 5000,
+ })
+ navigate({ type: "home" })
+ return
+ }
+
+ if (result.data.workspaceID !== previousWorkspace) {
+ project.workspace.set(result.data.workspaceID)
+
+ // Sync all the data for this workspace. Note that this
+ // workspace may not exist anymore which is why this is not
+ // fatal. If it doesn't we still want to show the session
+ // (which will be non-interactive)
+ try {
+ await sync.bootstrap({ fatal: false })
+ } catch {}
+ }
+ await sync.session.sync(sessionID)
+ if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
+ })().catch((error) => {
+ if (route.sessionID !== sessionID) return
toast.show({
- message: `Session not found: ${route.sessionID}`,
+ message: errorMessage(error),
variant: "error",
+ duration: 5000,
})
navigate({ type: "home" })
- return
- }
-
- if (result.data.workspaceID !== previousWorkspace) {
- project.workspace.set(result.data.workspaceID)
-
- // Sync all the data for this workspace. Note that this
- // workspace may not exist anymore which is why this is not
- // fatal. If it doesn't we still want to show the session
- // (which will be non-interactive)
- try {
- await sync.bootstrap({ fatal: false })
- } catch (e) {}
- }
- await sync.session.sync(route.sessionID)
- if (scroll) scroll.scrollBy(100_000)
+ })
})
let lastSwitch: string | undefined = undefined
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index 3e0e037bd4b..155e9d52efc 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -9,6 +9,7 @@ import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
+import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util"
@@ -132,6 +133,7 @@ function TextBody(props: { title: string; description?: string; icon?: string })
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
+ const project = useProject()
const sync = useSync()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
@@ -191,6 +193,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
void sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
+ workspace: project.workspace.current(),
})
}}
/>
@@ -202,6 +205,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
reply: "reject",
requestID: props.request.id,
message: message || undefined,
+ workspace: project.workspace.current(),
})
}}
onCancel={() => {
@@ -467,12 +471,14 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
+ workspace: project.workspace.current(),
})
return
}
void sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
+ workspace: project.workspace.current(),
})
}}
/>
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 45b6195676d..123631addfe 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -19,6 +19,7 @@ import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import { KILO_PROCESS_ROLE, KILO_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
+import { validateSession } from "./validate-session"
declare global {
const KILO_WORKER_PATH: string
@@ -285,6 +286,19 @@ export const TuiThreadCommand = cmd({
events: createEventSource(client),
}
+ try {
+ await validateSession({
+ url: transport.url,
+ sessionID: args.session,
+ directory: cwd,
+ fetch: transport.fetch,
+ })
+ } catch (error) {
+ UI.error(errorMessage(error))
+ process.exitCode = 1
+ return
+ }
+
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts
index d0fe4b12da3..f57eb0b289c 100644
--- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts
@@ -41,9 +41,9 @@ function parse(color: string): RGBA | null {
return null
}
-function mode(bg: RGBA | null): "dark" | "light" {
- if (!bg) return "dark"
- const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
+function mode(background: RGBA | null): "dark" | "light" {
+ if (!background) return "dark"
+ const luminance = (0.299 * background.r + 0.587 * background.g + 0.114 * background.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts
new file mode 100644
index 00000000000..da94eb9ca24
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts
@@ -0,0 +1,24 @@
+import { createKiloClient } from "@kilocode/sdk/v2"
+import { SessionID } from "@/session/schema"
+
+export async function validateSession(input: {
+ url: string
+ sessionID?: string
+ directory?: string
+ fetch?: typeof fetch
+ headers?: RequestInit["headers"]
+}) {
+ if (!input.sessionID) return
+
+ const result = SessionID.zod.safeParse(input.sessionID)
+ if (!result.success) {
+ throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
+ }
+
+ await createKiloClient({
+ baseUrl: input.url,
+ directory: input.directory,
+ fetch: input.fetch,
+ headers: input.headers,
+ }).session.get({ sessionID: result.data }, { throwOnError: true })
+}
diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts
index e56f57ebb84..ec623fcf81e 100644
--- a/packages/opencode/src/cli/upgrade.ts
+++ b/packages/opencode/src/cli/upgrade.ts
@@ -7,6 +7,7 @@ import { InstallationVersion } from "@/installation/version"
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
+ if (config.autoupdate === false || Flag.KILO_DISABLE_AUTOUPDATE) return
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
// kilocode_change start - only auto-upgrade for npm/pnpm/bun (we only publish @kilocode/cli via npm registry)
if (method !== "npm" && method !== "pnpm" && method !== "bun") return
@@ -20,7 +21,6 @@ export async function upgrade() {
}
if (InstallationVersion === latest) return
- if (config.autoupdate === false || Flag.KILO_DISABLE_AUTOUPDATE) return
const kind = Installation.getReleaseType(InstallationVersion, latest)
diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts
index 3918507ee0a..8ae4d3f818f 100644
--- a/packages/opencode/src/config/agent.ts
+++ b/packages/opencode/src/config/agent.ts
@@ -3,7 +3,7 @@ export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
-import { zod, ZodOverride } from "@/util/effect-zod"
+import { zod } from "@/util/effect-zod"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
@@ -25,12 +25,6 @@ const Color = Schema.Union([
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
-// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
-// shape lives outside the Effect Schema type system), so the walker reaches it
-// via ZodOverride rather than a pure Schema reference. This preserves the
-// `$ref: PermissionConfig` emitted in openapi.json.
-const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
-
const AgentSchema = Schema.StructWithRest(
Schema.Struct({
model: Schema.optional(Schema.NullOr(ConfigModelID)), // kilocode_change - nullable for delete sentinel
@@ -57,7 +51,7 @@ const AgentSchema = Schema.StructWithRest(
description: "Maximum number of agentic iterations before forcing text-only response",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
- permission: Schema.optional(PermissionRef),
+ permission: Schema.optional(ConfigPermission.Info),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
@@ -96,7 +90,7 @@ const normalize = (agent: z.infer) => {
const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ if (tool === "write" || tool === "edit" || tool === "patch") {
permission.edit = action
continue
}
@@ -104,7 +98,8 @@ const normalize = (agent: z.infer) => {
}
globalThis.Object.assign(permission, agent.permission)
- return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
+ const steps = agent.steps ?? agent.maxSteps
+ return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
}
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 1c59befd81d..63d1126e152 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -25,6 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
@@ -101,13 +102,20 @@ export type Layout = ConfigLayout.Layout
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
// exact zod directly, preserving component $refs.
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
-const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
-const InfoSchema = Schema.Struct({
+// The Effect Schema is the canonical source of truth. The `.zod` compatibility
+// surface is derived so existing Hono validators keep working without a parallel
+// Zod definition.
+//
+// The walker emits `z.object({...})` which is non-strict by default. Config
+// historically uses `.strict()` (additionalProperties: false in openapi.json),
+// so layer that on after derivation. Re-apply the Config ref afterward
+// since `.strict()` strips the walker's meta annotation.
+export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
@@ -220,7 +228,7 @@ const InfoSchema = Schema.Struct({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
- permission: Schema.optional(PermissionRef),
+ permission: Schema.optional(ConfigPermission.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
enterprise: Schema.optional(
Schema.Struct({
@@ -236,6 +244,13 @@ const InfoSchema = Schema.Struct({
prune: Schema.optional(Schema.Boolean).annotate({
description: "Enable pruning of old tool outputs (default: true)",
}),
+ tail_turns: Schema.optional(NonNegativeInt).annotate({
+ description:
+ "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
+ }),
+ preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
+ description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
+ }),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
}),
@@ -263,6 +278,14 @@ const InfoSchema = Schema.Struct({
}),
),
})
+ .annotate({ identifier: "Config" })
+ .pipe(
+ withStatics((s) => ({
+ zod: (zod(s) as unknown as z.ZodObject).strict().meta({ ref: "Config" }) as unknown as z.ZodType<
+ DeepMutable>
+ >,
+ })),
+ )
// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
@@ -284,15 +307,7 @@ type DeepMutable = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable }
: T
-// The walker emits `z.object({...})` which is non-strict by default. Config
-// historically uses `.strict()` (additionalProperties: false in openapi.json),
-// so layer that on after derivation. Re-apply the Config ref afterward
-// since `.strict()` strips the walker's meta annotation.
-export const Info = (zod(InfoSchema) as unknown as z.ZodObject)
- .strict()
- .meta({ ref: "Config" }) as unknown as z.ZodType>>
-
-export type Info = z.output & {
+export type Info = DeepMutable> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
plugin_origins?: ConfigPlugin.Origin[]
@@ -414,7 +429,7 @@ export const layer = Layer.effect(
),
)
const parsed = ConfigParse.jsonc(expanded, source)
- const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
+ const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
@@ -666,9 +681,8 @@ export const layer = Layer.effect(
const deps: Fiber.Fiber[] = []
+ // kilocode_change start
for (const dir of unique(directories)) {
- // kilocode_change
- // kilocode_change start
if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) {
for (const file of KilocodeConfig.ALL_CONFIG_FILES) {
const source = path.join(dir, file)
@@ -826,7 +840,7 @@ export const layer = Layer.effect(
const perms: Record = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ if (tool === "write" || tool === "edit" || tool === "patch") {
perms.edit = action
continue
}
@@ -932,13 +946,13 @@ export const layer = Layer.effect(
let next: Info
if (!file.endsWith(".jsonc")) {
- const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
+ const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file)
const merged = KilocodeConfig.mergeConfig(writable(existing), writable(config)) // kilocode_change
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, writable(config))
- next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
+ next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts
index 8b77bc4c286..0887fa984ab 100644
--- a/packages/opencode/src/config/mcp.ts
+++ b/packages/opencode/src/config/mcp.ts
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
-export class Local extends Schema.Class("McpLocalConfig")({
+export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Command and arguments to run the MCP server",
@@ -16,11 +16,12 @@ export class Local extends Schema.Class("McpLocalConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "McpLocalConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Local = Schema.Schema.Type
-export class OAuth extends Schema.Class("McpOAuthConfig")({
+export const OAuth = Schema.Struct({
clientId: Schema.optional(Schema.String).annotate({
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
}),
@@ -31,11 +32,12 @@ export class OAuth extends Schema.Class("McpOAuthConfig")({
redirectUri: Schema.optional(Schema.String).annotate({
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "McpOAuthConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type OAuth = Schema.Schema.Type
-export class Remote extends Schema.Class("McpRemoteConfig")({
+export const Remote = Schema.Struct({
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
enabled: Schema.optional(Schema.Boolean).annotate({
@@ -50,9 +52,10 @@ export class Remote extends Schema.Class("McpRemoteConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "McpRemoteConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Remote = Schema.Schema.Type
export const Info = Schema.Union([Local, Remote])
.annotate({ discriminator: "type" })
diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts
index 632ea571cdf..f2ced3112e2 100644
--- a/packages/opencode/src/config/permission.ts
+++ b/packages/opencode/src/config/permission.ts
@@ -1,6 +1,6 @@
export * as ConfigPermission from "./permission"
-import { Schema } from "effect"
-import { zod, ZodPreprocess } from "@/util/effect-zod"
+import { Schema, SchemaGetter } from "effect"
+import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Action = Schema.NullOr(Schema.Literals(["ask", "allow", "deny"])) // kilocode_change - nullable allows null as a delete sentinel
@@ -18,21 +18,19 @@ export const Rule = Schema.Union([Action, Object])
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type
-// Captures the user's original property insertion order before Schema.Struct
-// canonicalises the object. See the `ZodPreprocess` comment in
-// `util/effect-zod.ts` for the full rationale — in short: rule precedence is
-// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win)
-// and `Schema.StructWithRest` would otherwise drop that order.
-const permissionPreprocess = (val: unknown) => {
- if (typeof val === "object" && val !== null && !Array.isArray(val)) {
- return { __originalKeys: globalThis.Object.keys(val), ...val }
- }
- return val
-}
-
-const ObjectShape = Schema.StructWithRest(
+// Known permission keys get explicit types — most are full Rule (either a
+// single Action or a per-pattern object), but a handful of tools take no
+// sub-target patterns and are Action-only. Unknown keys fall through the
+// Record rest signature as Rule.
+//
+// StructWithRest canonicalises key order on decode (known first, then rest),
+// which used to require the `__originalKeys` preprocess hack because
+// `Permission.fromConfig` depended on the user's insertion order. That
+// dependency is gone — `fromConfig` now sorts top-level keys so wildcard
+// permissions come before specifics, making the final precedence
+// order-independent.
+const InputObject = Schema.StructWithRest(
Schema.Struct({
- __originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Rule),
edit: Schema.optional(Rule),
glob: Schema.optional(Rule),
@@ -53,24 +51,29 @@ const ObjectShape = Schema.StructWithRest(
[Schema.Record(Schema.String, Rule)],
)
-const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
- [ZodPreprocess]: permissionPreprocess,
-})
+// Input the user writes in config: either a single Action (shorthand for "*")
+// or an object of per-target rules.
+const InputSchema = Schema.Union([Action, InputObject])
-// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the
-// user's original insertion order. A plain string input (the Action branch of
-// the union) becomes `{ "*": action }`.
-const transform = (x: unknown): Record => {
- if (typeof x === "string") return { "*": x as Action }
- const obj = x as { __originalKeys?: string[] } & Record
- const { __originalKeys, ...rest } = obj
- if (!__originalKeys) return rest as Record
- const result: Record = {}
- for (const key of __originalKeys) {
- if (key in rest) result[key] = rest[key] as Rule
- }
- return result
-}
+// Normalise the Action shorthand into `{ "*": action }`. Object inputs pass
+// through untouched.
+const normalizeInput = (input: Schema.Schema.Type): Schema.Schema.Type =>
+ input === null || typeof input === "string" ? { "*": input } : input // kilocode_change
-export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
-export type Info = Record
+export const Info = InputSchema.pipe(
+ Schema.decodeTo(InputObject, {
+ decode: SchemaGetter.transform(normalizeInput),
+ // Not perfectly invertible (we lose whether the user originally typed an
+ // Action shorthand), but the object form is always a valid representation
+ // of the same rules.
+ encode: SchemaGetter.passthrough({ strict: false }),
+ }),
+)
+ .annotate({ identifier: "PermissionConfig" })
+ .pipe(
+ // Walker already emits the decodeTo transform into the derived zod (see
+ // `encoded()` in effect-zod.ts), so just expose that directly.
+ withStatics((s) => ({ zod: zod(s) })),
+ )
+type _Info = Schema.Schema.Type
+export type Info = { -readonly [K in keyof _Info]: _Info[K] }
diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts
index d17606fe60f..e6305449787 100644
--- a/packages/opencode/src/config/provider.ts
+++ b/packages/opencode/src/config/provider.ts
@@ -60,7 +60,8 @@ export const Model = Schema.Struct({
variants: Schema.optional(
Schema.Record(
Schema.String,
- Schema.NullOr( // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save
+ Schema.NullOr(
+ // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save
Schema.StructWithRest(
Schema.Struct({
disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
@@ -72,7 +73,7 @@ export const Model = Schema.Struct({
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
-export class Info extends Schema.Class("ProviderConfig")({
+export const Info = Schema.Struct({
api: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
@@ -109,8 +110,9 @@ export class Info extends Schema.Class("ProviderConfig")({
),
),
models: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(Model))), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "ProviderConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type
export * as ConfigProvider from "./provider"
diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts
index 5d1171f2456..8ba60c3e585 100644
--- a/packages/opencode/src/config/server.ts
+++ b/packages/opencode/src/config/server.ts
@@ -1,7 +1,8 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
-export class Server extends Schema.Class("ServerConfig")({
+export const Server = Schema.Struct({
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
description: "Port to listen on",
}),
@@ -13,8 +14,9 @@ export class Server extends Schema.Class("ServerConfig")({
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional domains to allow for CORS",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "ServerConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Server = Schema.Schema.Type
export * as ConfigServer from "./server"
diff --git a/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts
new file mode 100644
index 00000000000..a8265c5da9b
--- /dev/null
+++ b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts
@@ -0,0 +1,73 @@
+import type { Plugin } from "@kilocode/plugin"
+import { rename, writeFile } from "node:fs/promises"
+import { randomInt } from "node:crypto"
+import { setTimeout as sleep } from "node:timers/promises"
+
+const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json"
+const DEV_DATA_TEMP_FILE = `${DEV_DATA_FILE}.tmp`
+
+async function waitForHealth(port: number) {
+ const url = `http://127.0.0.1:${port}/global/health`
+ const started = Date.now()
+
+ while (Date.now() - started < 30_000) {
+ try {
+ const response = await fetch(url)
+ if (response.ok) {
+ return
+ }
+ } catch {}
+
+ await sleep(250)
+ }
+
+ throw new Error(`Timed out waiting for debug server health check at ${url}`)
+}
+
+let PORT: number | undefined
+
+async function writeDebugData(port: number, id: string, env: Record) {
+ await writeFile(
+ DEV_DATA_TEMP_FILE,
+ JSON.stringify(
+ {
+ port,
+ id,
+ env,
+ },
+ null,
+ 2,
+ ),
+ )
+
+ await rename(DEV_DATA_TEMP_FILE, DEV_DATA_FILE)
+}
+
+export const DebugWorkspacePlugin: Plugin = async ({ experimental_workspace }) => {
+ experimental_workspace.register("debug", {
+ name: "Debug",
+ description: "Create a debugging server",
+ configure(config) {
+ return config
+ },
+ async create(config, env) {
+ const port = randomInt(5000, 9001)
+ PORT = port
+
+ await writeDebugData(port, config.id, env)
+
+ await waitForHealth(port)
+ },
+ async remove(_config) {},
+ target(_config) {
+ return {
+ type: "remote",
+ url: `http://localhost:${PORT!}/`,
+ }
+ },
+ })
+
+ return {}
+}
+
+export default DebugWorkspacePlugin
diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts
index 4c7ced010d6..5a0850a2495 100644
--- a/packages/opencode/src/control-plane/schema.ts
+++ b/packages/opencode/src/control-plane/schema.ts
@@ -1,8 +1,7 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
@@ -14,6 +13,6 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
- zod: Identifier.schema("workspace").pipe(z.custom()),
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index 5f909bfd1a8..0200401113b 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -1,15 +1,29 @@
-import fs from "fs/promises"
import path from "path"
-import { fileURLToPath } from "url"
import z from "zod"
-import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
-import { ripgrep } from "ripgrep"
-import { Filesystem } from "@/util"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Cause, Context, Effect, Fiber, Layer, Queue, Stream } from "effect"
+import type { PlatformError } from "effect/PlatformError"
+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { Global } from "@/global"
import { Log } from "@/util"
import { sanitizedProcessEnv } from "@/util/opencode-process"
-import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - UTF-8 safe stream decoder shared with worker
+import { which } from "@/util/which"
const log = Log.create({ service: "ripgrep" })
+const VERSION = "15.1.0"
+const PLATFORM = {
+ "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
+ "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
+ "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
+ "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
+ "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
+ "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
+ "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
+} as const
const Stats = z.object({
elapsed: z.object({
@@ -121,62 +135,20 @@ export interface TreeInput {
}
export interface Interface {
- readonly files: (input: FilesInput) => Stream.Stream
- readonly tree: (input: TreeInput) => Effect.Effect
- readonly search: (input: SearchInput) => Effect.Effect
+ readonly files: (input: FilesInput) => Stream.Stream
+ readonly tree: (input: TreeInput) => Effect.Effect
+ readonly search: (input: SearchInput) => Effect.Effect
}
export class Service extends Context.Service()("@opencode/Ripgrep") {}
-type Run = { kind: "files" | "search"; cwd: string; args: string[] }
-
-type WorkerResult = {
- type: "result"
- code: number
- stdout: string
- stderr: string
-}
-
-type WorkerLine = {
- type: "line"
- line: string
-}
-
-type WorkerDone = {
- type: "done"
- code: number
- stderr: string
-}
-
-type WorkerError = {
- type: "error"
- error: {
- message: string
- name?: string
- stack?: string
- }
-}
-
function env() {
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
-function text(input: unknown) {
- if (typeof input === "string") return input
- if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
- if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
- return String(input)
-}
-
-function toError(input: unknown) {
- if (input instanceof Error) return input
- if (typeof input === "string") return new Error(input)
- return new Error(String(input))
-}
-
-function abort(signal?: AbortSignal) {
+function aborted(signal?: AbortSignal) {
const err = signal?.reason
if (err instanceof Error) return err
const out = new Error("Aborted")
@@ -184,6 +156,16 @@ function abort(signal?: AbortSignal) {
return out
}
+function waitForAbort(signal?: AbortSignal) {
+ if (!signal) return Effect.never
+ if (signal.aborted) return Effect.fail(aborted(signal))
+ return Effect.callback((resume) => {
+ const onabort = () => resume(Effect.fail(aborted(signal)))
+ signal.addEventListener("abort", onabort, { once: true })
+ return Effect.sync(() => signal.removeEventListener("abort", onabort))
+ })
+}
+
function error(stderr: string, code: number) {
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
err.name = "RipgrepError"
@@ -204,376 +186,300 @@ function row(data: Row): Row {
}
}
-function opts(cwd: string) {
- return {
- env: env(),
- preopens: { ".": cwd },
- }
+function parse(line: string) {
+ return Effect.try({
+ try: () => Result.parse(JSON.parse(line)),
+ catch: (cause) => new Error("invalid ripgrep output", { cause }),
+ })
}
-function check(cwd: string) {
- return Effect.tryPromise({
- try: () => fs.stat(cwd).catch(() => undefined),
- catch: toError,
- }).pipe(
- Effect.flatMap((stat) =>
- stat?.isDirectory()
- ? Effect.void
- : Effect.fail(
- Object.assign(new Error(`No such file or directory: '${cwd}'`), {
- code: "ENOENT",
- errno: -2,
- path: cwd,
- }),
- ),
- ),
- )
+function fail(queue: Queue.Queue, err: PlatformError | Error) {
+ Queue.failCauseUnsafe(queue, Cause.fail(err))
}
function filesArgs(input: FilesInput) {
- const args = ["--files", "--glob=!.git/*"]
+ const args = ["--no-config", "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
+ if (input.hidden === false) args.push("--glob=!.*")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
- for (const glob of input.glob) {
- args.push(`--glob=${glob}`)
- }
+ for (const glob of input.glob) args.push(`--glob=${glob}`)
}
args.push(".")
return args
}
function searchArgs(input: SearchInput) {
- const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"]
+ const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
if (input.follow) args.push("--follow")
if (input.glob) {
- for (const glob of input.glob) {
- args.push(`--glob=${glob}`)
- }
+ for (const glob of input.glob) args.push(`--glob=${glob}`)
}
if (input.limit) args.push(`--max-count=${input.limit}`)
args.push("--", input.pattern, ...(input.file ?? ["."]))
return args
}
-function parse(stdout: string) {
- return stdout
- .trim()
- .split(/\r?\n/)
- .filter(Boolean)
- .map((line) => Result.parse(JSON.parse(line)))
- .flatMap((item) => (item.type === "match" ? [row(item.data)] : []))
+function raceAbort(effect: Effect.Effect, signal?: AbortSignal) {
+ return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
}
-declare const KILO_RIPGREP_WORKER_PATH: string
-
-function target(): Effect.Effect {
- if (typeof KILO_RIPGREP_WORKER_PATH !== "undefined") {
- return Effect.succeed(KILO_RIPGREP_WORKER_PATH)
- }
- const js = new URL("./ripgrep.worker.js", import.meta.url)
- return Effect.tryPromise({
- try: () => Filesystem.exists(fileURLToPath(js)),
- catch: toError,
- }).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url))))
-}
+export const layer: Layer.Layer =
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
+ const spawner = yield* ChildProcessSpawner
+
+ const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
+ const handle = yield* spawner.spawn(
+ ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
+ )
+ const [stdout, stderr, code] = yield* Effect.all(
+ [
+ Stream.mkString(Stream.decodeText(handle.stdout)),
+ Stream.mkString(Stream.decodeText(handle.stderr)),
+ handle.exitCode,
+ ],
+ { concurrency: "unbounded" },
+ )
+ return { stdout, stderr, code }
+ }, Effect.scoped)
+
+ const extract = Effect.fnUntraced(function* (
+ archive: string,
+ config: (typeof PLATFORM)[keyof typeof PLATFORM],
+ target: string,
+ ) {
+ const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
+
+ if (config.extension === "zip") {
+ const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
+ const result = yield* run(shell, [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
+ ])
+ if (result.code !== 0) {
+ return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
+ }
+ }
-function worker() {
- return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() }))))
-}
+ if (config.extension === "tar.gz") {
+ const result = yield* run("tar", ["-xzf", archive, "-C", dir])
+ if (result.code !== 0) {
+ return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
+ }
+ }
-// kilocode_change start - delegate to KiloRipgrepStream for UTF-8 safe decoding
-function drain(
- dec: ReturnType,
- buf: string,
- chunk: unknown,
- push: (line: string) => void,
-) {
- return KiloRipgrepStream.drain(dec, buf, chunk, push)
-}
-// kilocode_change end
+ const extracted = path.join(
+ dir,
+ `ripgrep-${VERSION}-${config.platform}`,
+ process.platform === "win32" ? "rg.exe" : "rg",
+ )
+ if (!(yield* fs.isFile(extracted))) {
+ return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
+ }
-function fail(queue: Queue.Queue, err: Error) {
- Queue.failCauseUnsafe(queue, Cause.fail(err))
-}
+ yield* fs.copyFile(extracted, target)
+ if (process.platform === "win32") return
+ yield* fs.chmod(target, 0o755)
+ }, Effect.scoped)
-function searchDirect(input: SearchInput) {
- return Effect.tryPromise({
- try: () =>
- ripgrep(searchArgs(input), {
- buffer: true,
- ...opts(input.cwd),
- }),
- catch: toError,
- }).pipe(
- Effect.flatMap((ret) => {
- const out = ret.stdout ?? ""
- if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) {
- return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1))
- }
- return Effect.sync(() => ({
- items: ret.code === 1 ? [] : parse(out),
- partial: ret.code === 2,
- }))
- }),
- )
-}
+ const filepath = yield* Effect.cached(
+ Effect.gen(function* () {
+ const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
+ if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
-function searchWorker(input: SearchInput) {
- if (input.signal?.aborted) return Effect.fail(abort(input.signal))
-
- return Effect.acquireUseRelease(
- worker(),
- (w) =>
- Effect.callback((resume, signal) => {
- let open = true
- const done = (effect: Effect.Effect) => {
- if (!open) return
- open = false
- resume(effect)
- }
- const onabort = () => done(Effect.fail(abort(input.signal)))
+ const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
+ if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
- w.onerror = (evt) => {
- done(Effect.fail(toError(evt.error ?? evt.message)))
- }
- w.onmessage = (evt: MessageEvent) => {
- const msg = evt.data
- if (msg.type === "error") {
- done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error)))
- return
- }
- if (msg.code === 1) {
- done(Effect.succeed({ items: [], partial: false }))
- return
+ const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
+ const config = PLATFORM[platformKey]
+ if (!config) {
+ return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
}
- if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) {
- done(Effect.fail(error(msg.stderr, msg.code)))
- return
- }
- done(
- Effect.sync(() => ({
- items: parse(msg.stdout),
- partial: msg.code === 2,
- })),
- )
- }
- input.signal?.addEventListener("abort", onabort, { once: true })
- signal.addEventListener("abort", onabort, { once: true })
- w.postMessage({
- kind: "search",
- cwd: input.cwd,
- args: searchArgs(input),
- } satisfies Run)
-
- return Effect.sync(() => {
- input.signal?.removeEventListener("abort", onabort)
- signal.removeEventListener("abort", onabort)
- w.onerror = null
- w.onmessage = null
- })
- }),
- (w) => Effect.sync(() => w.terminate()),
- )
-}
+ const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
+ const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
+ const archive = path.join(Global.Path.bin, filename)
-function filesDirect(input: FilesInput) {
- return Stream.callback(
- Effect.fnUntraced(function* (queue: Queue.Queue) {
- let buf = ""
- let err = ""
- // kilocode_change start
- const decoder = KiloRipgrepStream.decoder()
- const out = {
- write(chunk: unknown) {
- buf = drain(decoder, buf, chunk, (line) => {
- Queue.offerUnsafe(queue, clean(line))
- })
- },
- }
- // kilocode_change end
-
- const stderr = {
- write(chunk: unknown) {
- err += text(chunk)
- },
- }
-
- yield* Effect.forkScoped(
- Effect.gen(function* () {
- yield* check(input.cwd)
- const ret = yield* Effect.tryPromise({
- try: () =>
- ripgrep(filesArgs(input), {
- stdout: out,
- stderr,
- ...opts(input.cwd),
- }),
- catch: toError,
- })
- buf += decoder.end() // kilocode_change
- if (buf) Queue.offerUnsafe(queue, clean(buf))
- if (ret.code === 0 || ret.code === 1) {
- Queue.endUnsafe(queue)
- return
+ log.info("downloading ripgrep", { url })
+ yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
+
+ const bytes = yield* HttpClientRequest.get(url).pipe(
+ http.execute,
+ Effect.flatMap((response) => response.arrayBuffer),
+ Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
+ )
+ if (bytes.byteLength === 0) {
+ return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
}
- fail(queue, error(err, ret.code ?? 1))
- }).pipe(
- Effect.catch((err) =>
- Effect.sync(() => {
- fail(queue, err)
- }),
- ),
- ),
+
+ yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
+ yield* extract(archive, config, target)
+ yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
+ return target
+ }),
)
- }),
- )
-}
-function filesWorker(input: FilesInput) {
- return Stream.callback(
- Effect.fnUntraced(function* (queue: Queue.Queue) {
- if (input.signal?.aborted) {
- fail(queue, abort(input.signal))
- return
- }
-
- const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate()))
- let open = true
- const close = () => {
- if (!open) return false
- open = false
- return true
- }
- const onabort = () => {
- if (!close()) return
- fail(queue, abort(input.signal))
- }
-
- w.onerror = (evt) => {
- if (!close()) return
- fail(queue, toError(evt.error ?? evt.message))
- }
- w.onmessage = (evt: MessageEvent) => {
- const msg = evt.data
- if (msg.type === "line") {
- if (open) Queue.offerUnsafe(queue, msg.line)
- return
+ const check = Effect.fnUntraced(function* (cwd: string) {
+ if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
+ return yield* Effect.fail(
+ Object.assign(new Error(`No such file or directory: '${cwd}'`), {
+ code: "ENOENT",
+ errno: -2,
+ path: cwd,
+ }),
+ )
+ })
+
+ const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
+ const binary = yield* filepath
+ return ChildProcess.make(binary, args, {
+ cwd,
+ env: env(),
+ extendEnv: true,
+ stdin: "ignore",
+ })
+ })
+
+ const files: Interface["files"] = (input) =>
+ Stream.callback((queue) =>
+ Effect.gen(function* () {
+ yield* Effect.forkScoped(
+ Effect.gen(function* () {
+ yield* check(input.cwd)
+ const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
+ const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
+ const stdout = yield* Stream.decodeText(handle.stdout).pipe(
+ Stream.splitLines,
+ Stream.filter((line) => line.length > 0),
+ Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
+ Effect.forkScoped,
+ )
+ const code = yield* raceAbort(handle.exitCode, input.signal)
+ yield* Fiber.join(stdout)
+ if (code === 0 || code === 1) {
+ Queue.endUnsafe(queue)
+ return
+ }
+ fail(queue, error(yield* Fiber.join(stderr), code))
+ }).pipe(
+ Effect.catch((err) =>
+ Effect.sync(() => {
+ fail(queue, err)
+ }),
+ ),
+ ),
+ )
+ }),
+ )
+
+ const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
+ yield* check(input.cwd)
+
+ const program = Effect.scoped(
+ Effect.gen(function* () {
+ const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
+
+ const [items, stderr, code] = yield* Effect.all(
+ [
+ Stream.decodeText(handle.stdout).pipe(
+ Stream.splitLines,
+ Stream.filter((line) => line.length > 0),
+ Stream.mapEffect(parse),
+ Stream.filter((item): item is Match => item.type === "match"),
+ Stream.map((item) => row(item.data)),
+ Stream.runCollect,
+ Effect.map((chunk) => [...chunk]),
+ ),
+ Stream.mkString(Stream.decodeText(handle.stderr)),
+ handle.exitCode,
+ ],
+ { concurrency: "unbounded" },
+ )
+
+ if (code !== 0 && code !== 1 && code !== 2) {
+ return yield* Effect.fail(error(stderr, code))
+ }
+
+ return {
+ items: code === 1 ? [] : items,
+ partial: code === 2,
+ }
+ }),
+ )
+
+ return yield* raceAbort(program, input.signal)
+ })
+
+ const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
+ log.info("tree", input)
+ const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
+
+ interface Node {
+ name: string
+ children: Map
}
- if (!close()) return
- if (msg.type === "error") {
- fail(queue, Object.assign(new Error(msg.error.message), msg.error))
- return
+
+ function child(node: Node, name: string) {
+ const item = node.children.get(name)
+ if (item) return item
+ const next = { name, children: new Map() }
+ node.children.set(name, next)
+ return next
}
- if (msg.code === 0 || msg.code === 1) {
- Queue.endUnsafe(queue)
- return
+
+ function count(node: Node): number {
+ return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
}
- fail(queue, error(msg.stderr, msg.code))
- }
-
- yield* Effect.acquireRelease(
- Effect.sync(() => {
- input.signal?.addEventListener("abort", onabort, { once: true })
- w.postMessage({
- kind: "files",
- cwd: input.cwd,
- args: filesArgs(input),
- } satisfies Run)
- }),
- () =>
- Effect.sync(() => {
- input.signal?.removeEventListener("abort", onabort)
- w.onerror = null
- w.onmessage = null
- }),
- )
- }),
- )
-}
-export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const source = (input: FilesInput) => {
- const useWorker = !!input.signal && typeof Worker !== "undefined"
- if (!useWorker && input.signal) {
- log.warn("worker unavailable, ripgrep abort disabled")
- }
- return useWorker ? filesWorker(input) : filesDirect(input)
- }
-
- const files: Interface["files"] = (input) => source(input)
-
- const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
- log.info("tree", input)
- const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
-
- interface Node {
- name: string
- children: Map
- }
-
- function child(node: Node, name: string) {
- const item = node.children.get(name)
- if (item) return item
- const next = { name, children: new Map() }
- node.children.set(name, next)
- return next
- }
-
- function count(node: Node): number {
- return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
- }
-
- const root: Node = { name: "", children: new Map() }
- for (const file of list) {
- if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change
- const parts = file.split(path.sep)
- if (parts.length < 2) continue
- let node = root
- for (const part of parts.slice(0, -1)) {
- node = child(node, part)
+ const root: Node = { name: "", children: new Map() }
+ for (const file of list) {
+ if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change
+ const parts = file.split(path.sep)
+ if (parts.length < 2) continue
+ let node = root
+ for (const part of parts.slice(0, -1)) {
+ node = child(node, part)
+ }
+ }
+
+ const total = count(root)
+ const limit = input.limit ?? total
+ const lines: string[] = []
+ const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((node) => ({ node, path: node.name }))
+
+ let used = 0
+ for (let i = 0; i < queue.length && used < limit; i++) {
+ const item = queue[i]
+ lines.push(item.path)
+ used++
+ queue.push(
+ ...Array.from(item.node.children.values())
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((node) => ({ node, path: `${item.path}/${node.name}` })),
+ )
}
- }
-
- const total = count(root)
- const limit = input.limit ?? total
- const lines: string[] = []
- const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
- .sort((a, b) => a.name.localeCompare(b.name))
- .map((node) => ({ node, path: node.name }))
-
- let used = 0
- for (let i = 0; i < queue.length && used < limit; i++) {
- const item = queue[i]
- lines.push(item.path)
- used++
- queue.push(
- ...Array.from(item.node.children.values())
- .sort((a, b) => a.name.localeCompare(b.name))
- .map((node) => ({ node, path: `${item.path}/${node.name}` })),
- )
- }
- if (total > used) lines.push(`[${total - used} truncated]`)
- return lines.join("\n")
- })
+ if (total > used) lines.push(`[${total - used} truncated]`)
+ return lines.join("\n")
+ })
- const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
- const useWorker = !!input.signal && typeof Worker !== "undefined"
- if (!useWorker && input.signal) {
- log.warn("worker unavailable, ripgrep abort disabled")
- }
- return yield* useWorker ? searchWorker(input) : searchDirect(input)
- })
+ return Service.of({ files, tree, search })
+ }),
+ )
- return Service.of({ files, tree, search })
- }),
+export const defaultLayer = layer.pipe(
+ Layer.provide(FetchHttpClient.layer),
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(CrossSpawnSpawner.defaultLayer),
)
-export const defaultLayer = layer
-
export * as Ripgrep from "./ripgrep"
diff --git a/packages/opencode/src/file/ripgrep.worker.ts b/packages/opencode/src/file/ripgrep.worker.ts
deleted file mode 100644
index 10566a8225a..00000000000
--- a/packages/opencode/src/file/ripgrep.worker.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { ripgrep } from "ripgrep"
-import { sanitizedProcessEnv } from "@/util/opencode-process"
-import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - share UTF-8 stream decoding
-
-function env() {
- const env = sanitizedProcessEnv()
- delete env.RIPGREP_CONFIG_PATH
- return env
-}
-
-function opts(cwd: string) {
- return {
- env: env(),
- preopens: { ".": cwd },
- }
-}
-
-type Run = {
- kind: "files" | "search"
- cwd: string
- args: string[]
-}
-
-function text(input: unknown) {
- if (typeof input === "string") return input
- if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
- if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
- return String(input)
-}
-
-function error(input: unknown) {
- if (input instanceof Error) {
- return {
- message: input.message,
- name: input.name,
- stack: input.stack,
- }
- }
-
- return {
- message: String(input),
- }
-}
-
-function clean(file: string) {
- return file.replace(/^\.[\\/]/, "")
-}
-
-onmessage = async (evt: MessageEvent) => {
- const msg = evt.data
-
- try {
- if (msg.kind === "search") {
- const ret = await ripgrep(msg.args, {
- buffer: true,
- ...opts(msg.cwd),
- })
- postMessage({
- type: "result",
- code: ret.code ?? 0,
- stdout: ret.stdout ?? "",
- stderr: ret.stderr ?? "",
- })
- return
- }
-
- let buf = ""
- let err = ""
- // kilocode_change start - keep decoder state across stdout chunks
- const decoder = KiloRipgrepStream.decoder()
- const out = {
- write(chunk: unknown) {
- buf = KiloRipgrepStream.drain(decoder, buf, chunk, (line) => postMessage({ type: "line", line: clean(line) }))
- },
- }
- // kilocode_change end
- const stderr = {
- write(chunk: unknown) {
- err += text(chunk)
- },
- }
-
- const ret = await ripgrep(msg.args, {
- stdout: out,
- stderr,
- ...opts(msg.cwd),
- })
-
- buf += decoder.end() // kilocode_change - flush any trailing buffered bytes
- if (buf) postMessage({ type: "line", line: clean(buf) })
- postMessage({
- type: "done",
- code: ret.code ?? 0,
- stderr: err,
- })
- } catch (err) {
- postMessage({
- type: "error",
- error: error(err),
- })
- }
-}
diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts
index 85934ce9c9a..53a2c10119b 100644
--- a/packages/opencode/src/format/index.ts
+++ b/packages/opencode/src/format/index.ts
@@ -25,7 +25,7 @@ export type Status = z.infer
export interface Interface {
readonly init: () => Effect.Effect
readonly status: () => Effect.Effect
- readonly file: (filepath: string) => Effect.Effect
+ readonly file: (filepath: string) => Effect.Effect
}
export class Service extends Context.Service()("@opencode/Format") {}
@@ -70,16 +70,19 @@ export const layer = Layer.effect(
}
}),
)
- return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
+ return checks
+ .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false)
+ .map((x) => ({ item: x.item, cmd: x.cmd }))
}
function formatFile(filepath: string) {
return Effect.gen(function* () {
log.info("formatting", { file: filepath })
- const ext = path.extname(filepath)
+ const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath)))
- for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) {
- if (cmd === false) continue
+ if (!formatters.length) return false
+
+ for (const { item, cmd } of formatters) {
log.info("running", { command: cmd })
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory
@@ -113,6 +116,8 @@ export const layer = Layer.effect(
})
}
}
+
+ return true
})
}
@@ -188,7 +193,7 @@ export const layer = Layer.effect(
const file = Effect.fn("Format.file")(function* (filepath: string) {
const { formatFile } = yield* InstanceState.get(state)
- yield* formatFile(filepath)
+ return yield* formatFile(filepath)
})
return Service.of({ init, status, file })
diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts
index ee3141a6f88..40feff1afe5 100644
--- a/packages/opencode/src/kilo-sessions/remote-sender.ts
+++ b/packages/opencode/src/kilo-sessions/remote-sender.ts
@@ -277,7 +277,7 @@ export namespace RemoteSender {
return
}
dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => {
- await SessionPrompt.prompt(input.data)
+ await SessionPrompt.prompt(input.data as SessionPrompt.PromptInput)
})
return
}
diff --git a/packages/opencode/src/kilocode/config-validation.ts b/packages/opencode/src/kilocode/config-validation.ts
index a14f946edbb..24c9abb5b28 100644
--- a/packages/opencode/src/kilocode/config-validation.ts
+++ b/packages/opencode/src/kilocode/config-validation.ts
@@ -43,7 +43,7 @@ export namespace ConfigValidation {
return `\n\n\nERROR: Config file at ${label(filepath)} is not valid JSON(C)\n ${detail}\n`
}
- const result = Config.Info.safeParse(data)
+ const result = Config.Info.zod.safeParse(data)
if (!result.success) {
const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")
return `\n\n\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n`
diff --git a/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts b/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts
deleted file mode 100644
index eacedee8a55..00000000000
--- a/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { StringDecoder } from "string_decoder"
-
-export namespace KiloRipgrepStream {
- export function decoder() {
- return new StringDecoder("utf8")
- }
-
- function decode(dec: StringDecoder, input: unknown) {
- if (typeof input === "string") return input
- if (input instanceof ArrayBuffer) return dec.write(Buffer.from(input))
- if (ArrayBuffer.isView(input)) return dec.write(Buffer.from(input.buffer, input.byteOffset, input.byteLength))
- return String(input)
- }
-
- export function drain(dec: StringDecoder, buf: string, chunk: unknown, push: (line: string) => void) {
- const lines = (buf + decode(dec, chunk)).split(/\r?\n/)
- const rest = lines.pop() || ""
- for (const line of lines) {
- if (line) push(line)
- }
- return rest
- }
-}
diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts
index bf416d0a24c..c516235ad80 100644
--- a/packages/opencode/src/kilocode/review/worktree-diff.ts
+++ b/packages/opencode/src/kilocode/review/worktree-diff.ts
@@ -3,23 +3,26 @@ import { $ } from "bun"
import { createTwoFilesPatch } from "diff"
import fs from "node:fs/promises"
import path from "node:path"
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
import { FileIgnore } from "@/file/ignore"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util"
+import { withStatics } from "@/util/schema"
export namespace WorktreeDiff {
- export const Item = Snapshot.FileDiff.extend({
- before: z.string(),
- after: z.string(),
- tracked: z.boolean(),
- generatedLike: z.boolean(),
- summarized: z.boolean(),
- stamp: z.string(),
- }).meta({
- ref: "WorktreeDiffItem",
+ export const Item = Schema.Struct({
+ ...Snapshot.FileDiff.fields,
+ before: Schema.String,
+ after: Schema.String,
+ tracked: Schema.Boolean,
+ generatedLike: Schema.Boolean,
+ summarized: Schema.Boolean,
+ stamp: Schema.String,
})
- export type Item = z.infer
+ .annotate({ identifier: "WorktreeDiffItem" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+ export type Item = typeof Item.Type
type Status = NonNullable
diff --git a/packages/opencode/src/kilocode/ts-client.ts b/packages/opencode/src/kilocode/ts-client.ts
index 6c9ccc24d90..3cd3a955be2 100644
--- a/packages/opencode/src/kilocode/ts-client.ts
+++ b/packages/opencode/src/kilocode/ts-client.ts
@@ -72,12 +72,13 @@ export namespace TsClient {
// trigger notify.open() but should NOT spawn tsgo. The actual
// check is deferred to waitForDiagnostics() which is only
// called when tools need diagnostics (write, edit, apply_patch).
+ return 0
},
},
get diagnostics() {
return diagnostics
},
- async waitForDiagnostics(_input: { path: string }) {
+ async waitForDiagnostics(_input: { path: string; version: number; mode?: "document" | "full"; after?: number }) {
// Run tsgo --noEmit and wait for results. Coalesces concurrent calls.
// 30s cap matches the process timeout in TsCheck.run(). Silent catch
// matches the real LSPClient's .catch(() => {}) on its 3s timeout.
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index b20e8ae7f00..f6d5110a6c4 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -14,6 +14,16 @@ import { withTimeout } from "../util/timeout"
import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
+const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
+const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000
+const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000
+
+const INITIALIZE_TIMEOUT_MS = 45_000
+
+// LSP spec constants
+const FILE_CHANGE_CREATED = 1
+const FILE_CHANGE_CHANGED = 2
+const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" })
@@ -38,48 +48,194 @@ export const Event = {
),
}
+type DocumentDiagnosticReport = {
+ items?: Diagnostic[]
+ relatedDocuments?: Record
+}
+
+type WorkspaceDiagnosticReport = {
+ items?: {
+ uri?: string
+ items?: Diagnostic[]
+ }[]
+}
+
+type DiagnosticRequestResult = {
+ handled: boolean
+ matched: boolean
+ byFile: Map
+}
+
+type CapabilityRegistration = {
+ id: string
+ method: string
+ registerOptions?: {
+ identifier?: string
+ workspaceDiagnostics?: boolean
+ }
+}
+
+type ServerCapabilities = {
+ textDocumentSync?:
+ | number
+ | {
+ change?: number
+ }
+ diagnosticProvider?: unknown
+ [key: string]: unknown
+}
+
+function getFilePath(uri: string) {
+ if (!uri.startsWith("file://")) return
+ return Filesystem.normalizePath(fileURLToPath(uri))
+}
+
+function getSyncKind(capabilities?: ServerCapabilities) {
+ if (!capabilities) return
+ const sync = capabilities.textDocumentSync
+ if (typeof sync === "number") return sync
+ return sync?.change
+}
+
+function endPosition(text: string) {
+ const lines = text.split(/\r\n|\r|\n/)
+ return {
+ line: lines.length - 1,
+ character: lines.at(-1)?.length ?? 0,
+ }
+}
+
+function dedupeDiagnostics(items: Diagnostic[]) {
+ const seen = new Set()
+ return items.filter((item) => {
+ const key = JSON.stringify({
+ code: item.code,
+ severity: item.severity,
+ message: item.message,
+ source: item.source,
+ range: item.range,
+ })
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+function configurationValue(settings: unknown, section?: string) {
+ if (!section) return settings ?? null
+ const result = section.split(".").reduce((acc, key) => {
+ if (!acc || typeof acc !== "object" || !(key in acc)) return undefined
+ return (acc as Record)[key]
+ }, settings)
+ return result ?? null
+}
+
+// TypeScript's built-in LSP pushes diagnostics aggressively on first open.
+// We seed the push cache on the very first publish so waitForFreshPush can
+// resolve immediately instead of waiting for a second debounced push.
+function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
+ return serverID === "typescript"
+}
+
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
- const l = log.clone().tag("serverID", input.serverID)
- l.info("starting client")
+ const logger = log.clone().tag("serverID", input.serverID)
+ logger.info("starting client")
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any),
new StreamMessageWriter(input.server.process.stdin as any),
)
+ // Server stderr can contain both real errors and routine informational logs,
+ // which is normal stderr practice for some tools. Keep the raw stream at
+ // debug so users can opt in with --print-logs --log-level DEBUG without
+ // polluting normal logs.
+ input.server.process.stderr?.on("data", (data: Buffer) => {
+ const text = data.toString().trim()
+ if (text) logger.debug("server stderr", { text: text.slice(0, 1000) })
+ })
+
+ // --- Connection state ---
+
+ const pushDiagnostics = new Map()
+ const pullDiagnostics = new Map()
+ const published = new Map()
+ const diagnosticRegistrations = new Map()
+ const registrationListeners = new Set<() => void>()
+ const mergedDiagnostics = (filePath: string) =>
+ dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
+ const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
+ pushDiagnostics.set(filePath, next)
+ Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
+ }
+ const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
+ pullDiagnostics.set(filePath, next)
+ }
+ const emitRegistrationChange = () => {
+ for (const listener of [...registrationListeners]) listener()
+ }
+
+ // --- LSP connection handlers ---
- const diagnostics = new Map()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
- const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
- l.info("textDocument/publishDiagnostics", {
+ const filePath = getFilePath(params.uri)
+ if (!filePath) return
+ logger.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
+ version: params.version,
})
- const exists = diagnostics.has(filePath)
- diagnostics.set(filePath, params.diagnostics)
- if (!exists && input.serverID === "typescript") return
- Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
+ published.set(filePath, {
+ at: Date.now(),
+ version: typeof params.version === "number" ? params.version : undefined,
+ })
+ if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) {
+ pushDiagnostics.set(filePath, params.diagnostics)
+ return
+ }
+ updatePushDiagnostics(filePath, params.diagnostics)
})
connection.onRequest("window/workDoneProgress/create", (params) => {
- l.info("window/workDoneProgress/create", params)
+ logger.info("window/workDoneProgress/create", params)
return null
})
- connection.onRequest("workspace/configuration", async () => {
- // Return server initialization options
- return [input.server.initialization ?? {}]
+ connection.onRequest("workspace/configuration", async (params) => {
+ const items = (params as { items?: { section?: string }[] }).items ?? []
+ return items.map((item) => configurationValue(input.server.initialization, item.section))
+ })
+ connection.onRequest("client/registerCapability", async (params) => {
+ const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? []
+ let changed = false
+ for (const registration of registrations) {
+ if (registration.method !== "textDocument/diagnostic") continue
+ diagnosticRegistrations.set(registration.id, registration)
+ changed = true
+ }
+ if (changed) emitRegistrationChange()
+ })
+ connection.onRequest("client/unregisterCapability", async (params) => {
+ const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? []
+ let changed = false
+ for (const registration of registrations) {
+ if (registration.method !== "textDocument/diagnostic") continue
+ diagnosticRegistrations.delete(registration.id)
+ changed = true
+ }
+ if (changed) emitRegistrationChange()
})
- connection.onRequest("client/registerCapability", async () => {})
- connection.onRequest("client/unregisterCapability", async () => {})
connection.onRequest("workspace/workspaceFolders", async () => [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
])
+ connection.onRequest("workspace/diagnostic/refresh", async () => null)
connection.listen()
- l.info("sending initialize")
- await withTimeout(
- connection.sendRequest("initialize", {
+ // --- Initialize handshake ---
+
+ logger.info("sending initialize")
+ const initialized = await withTimeout(
+ connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [
@@ -100,21 +256,28 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
didChangeWatchedFiles: {
dynamicRegistration: true,
},
+ diagnostics: {
+ refreshSupport: false,
+ },
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
+ diagnostic: {
+ dynamicRegistration: true,
+ relatedDocumentSupport: true,
+ },
publishDiagnostics: {
- versionSupport: true,
+ versionSupport: false,
},
},
},
}),
- 45_000,
+ INITIALIZE_TIMEOUT_MS,
).catch((err) => {
- l.error("initialize error", { error: err })
+ logger.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
@@ -123,6 +286,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
)
})
+ const syncKind = getSyncKind(initialized.capabilities)
+ const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider)
+
await connection.sendNotification("initialized", {})
if (input.server.initialization) {
@@ -131,9 +297,280 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
})
}
- const files: {
- [path: string]: number
- } = {}
+ const files: Record = {}
+
+ // --- Diagnostic helpers ---
+
+ const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
+ const handled = results.some((result) => result.handled)
+ const matched = results.some((result) => result.matched)
+ if (!handled) return { handled: false, matched: false }
+
+ const merged = new Map()
+ for (const result of results) {
+ for (const [target, items] of result.byFile.entries()) {
+ const existing = merged.get(target) ?? []
+ merged.set(target, existing.concat(items))
+ }
+ }
+
+ if (matched && !merged.has(filePath)) merged.set(filePath, [])
+ for (const [target, items] of merged.entries()) {
+ updatePullDiagnostics(target, dedupeDiagnostics(items))
+ }
+
+ return { handled, matched }
+ }
+
+ async function requestDiagnosticReport(filePath: string, identifier?: string): Promise {
+ const report = await withTimeout(
+ connection.sendRequest("textDocument/diagnostic", {
+ ...(identifier ? { identifier } : {}),
+ textDocument: {
+ uri: pathToFileURL(filePath).href,
+ },
+ }),
+ DIAGNOSTICS_REQUEST_TIMEOUT_MS,
+ ).catch(() => null)
+ if (!report) return { handled: false, matched: false, byFile: new Map() }
+
+ const byFile = new Map()
+ const push = (target: string, items: Diagnostic[]) => {
+ const existing = byFile.get(target) ?? []
+ byFile.set(target, existing.concat(items))
+ }
+
+ let handled = false
+ let matched = false
+ if (Array.isArray(report.items)) {
+ push(filePath, report.items)
+ handled = true
+ matched = true
+ }
+ for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
+ const relatedPath = getFilePath(uri)
+ if (!relatedPath || !Array.isArray(related.items)) continue
+ push(relatedPath, related.items)
+ handled = true
+ matched = matched || relatedPath === filePath
+ }
+
+ return { handled, matched, byFile }
+ }
+
+ async function requestWorkspaceDiagnosticReport(
+ filePath: string,
+ identifier?: string,
+ ): Promise {
+ const report = await withTimeout(
+ connection.sendRequest("workspace/diagnostic", {
+ ...(identifier ? { identifier } : {}),
+ previousResultIds: [],
+ }),
+ DIAGNOSTICS_REQUEST_TIMEOUT_MS,
+ ).catch(() => null)
+ if (!report) return { handled: false, matched: false, byFile: new Map() }
+
+ const byFile = new Map()
+ let matched = false
+ for (const item of report.items ?? []) {
+ const relatedPath = item.uri ? getFilePath(item.uri) : undefined
+ if (!relatedPath || !Array.isArray(item.items)) continue
+ const existing = byFile.get(relatedPath) ?? []
+ byFile.set(relatedPath, existing.concat(item.items))
+ matched = matched || relatedPath === filePath
+ }
+
+ return { handled: true, matched, byFile }
+ }
+
+ function documentPullState() {
+ const documentRegistrations = [...diagnosticRegistrations.values()].filter(
+ (registration) => registration.registerOptions?.workspaceDiagnostics !== true,
+ )
+ return {
+ documentIdentifiers: [
+ ...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])),
+ ],
+ supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
+ }
+ }
+
+ function workspacePullState() {
+ const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
+ (registration) => registration.registerOptions?.workspaceDiagnostics === true,
+ )
+ return {
+ workspaceIdentifiers: [
+ ...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])),
+ ],
+ supported: workspaceRegistrations.length > 0,
+ }
+ }
+
+ const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
+ results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0)
+
+ async function requestDiagnostics(
+ filePath: string,
+ requests: Promise[],
+ done: (results: DiagnosticRequestResult[]) => boolean,
+ ) {
+ if (!requests.length) return { handled: false, matched: false }
+
+ const results: DiagnosticRequestResult[] = []
+ return new Promise<{ handled: boolean; matched: boolean }>((resolve) => {
+ let pending = requests.length
+ let resolved = false
+ const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
+ if (resolved) return
+ if (!force && !done(results)) return
+ resolved = true
+ resolve(merged)
+ }
+
+ for (const request of requests) {
+ request.then((result) => {
+ results.push(result)
+ pending -= 1
+ const merged = mergeResults(filePath, results)
+ finish(merged)
+ if (pending === 0) finish(merged, true)
+ })
+ }
+ })
+ }
+
+ // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one
+ // batch already produced diagnostics for the current file. Let slower pulls keep
+ // merging in the background; do not sequence identifier-by-identifier, and do
+ // not add a post-match settle/debounce delay. See PR #23771.
+ async function requestDocumentDiagnostics(filePath: string) {
+ const state = documentPullState()
+ if (!state.supported) return { handled: false, matched: false }
+ return requestDiagnostics(
+ filePath,
+ [
+ requestDiagnosticReport(filePath),
+ ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
+ ],
+ (results) => hasCurrentFileDiagnostics(filePath, results),
+ )
+ }
+
+ async function requestFullDiagnostics(filePath: string) {
+ const documentState = documentPullState()
+ const workspaceState = workspacePullState()
+ if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false }
+ return mergeResults(
+ filePath,
+ await Promise.all([
+ ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
+ ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
+ ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
+ ...workspaceState.workspaceIdentifiers.map((identifier) =>
+ requestWorkspaceDiagnosticReport(filePath, identifier),
+ ),
+ ]),
+ )
+ }
+
+ function waitForRegistrationChange(timeout: number) {
+ if (timeout <= 0) return Promise.resolve(false)
+ return new Promise((resolve) => {
+ let finished = false
+ let timer: ReturnType | undefined
+ const finish = (result: boolean) => {
+ if (finished) return
+ finished = true
+ if (timer) clearTimeout(timer)
+ registrationListeners.delete(listener)
+ resolve(result)
+ }
+ const listener = () => finish(true)
+ registrationListeners.add(listener)
+ timer = setTimeout(() => finish(false), timeout)
+ })
+ }
+
+ function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) {
+ if (request.timeout <= 0) return Promise.resolve(false)
+ return new Promise((resolve) => {
+ let finished = false
+ let debounceTimer: ReturnType | undefined
+ let timeoutTimer: ReturnType | undefined
+ let unsub: (() => void) | undefined
+ const finish = (result: boolean) => {
+ if (finished) return
+ finished = true
+ if (debounceTimer) clearTimeout(debounceTimer)
+ if (timeoutTimer) clearTimeout(timeoutTimer)
+ unsub?.()
+ resolve(result)
+ }
+ const schedule = () => {
+ const hit = published.get(request.path)
+ if (!hit) return
+ if (typeof hit.version === "number" && hit.version !== request.version) return
+ if (hit.at < request.after && hit.version !== request.version) return
+ if (debounceTimer) clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)))
+ }
+
+ timeoutTimer = setTimeout(() => finish(false), request.timeout)
+ unsub = Bus.subscribe(Event.Diagnostics, (event) => {
+ if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
+ schedule()
+ })
+ schedule()
+ })
+ }
+
+ async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) {
+ const startedAt = request.after ?? Date.now()
+ const pushWait = waitForFreshPush({
+ path: request.path,
+ version: request.version,
+ after: startedAt,
+ timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
+ })
+
+ while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
+ const result = await requestDocumentDiagnostics(request.path)
+ if (result.matched) return
+ const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
+ if (remaining <= 0) return
+ const next = await Promise.race([
+ pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
+ waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
+ ])
+ if (next !== "registration") return
+ }
+ }
+
+ async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) {
+ const startedAt = request.after ?? Date.now()
+ const pushWait = waitForFreshPush({
+ path: request.path,
+ version: request.version,
+ after: startedAt,
+ timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
+ })
+
+ while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
+ const result = await requestFullDiagnostics(request.path)
+ if (result.handled || result.matched) return
+ const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
+ if (remaining <= 0) return
+ const next = await Promise.race([
+ pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
+ waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
+ ])
+ if (next !== "registration") return
+ }
+ }
+
+ // --- Public API ---
const result = {
root: input.root,
@@ -145,26 +582,32 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
},
notify: {
async open(request: { path: string }) {
- request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
+ request.path = Filesystem.normalizePath(
+ path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
+ )
const text = await Filesystem.readText(request.path)
const extension = path.extname(request.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
- const version = files[request.path]
- if (version !== undefined) {
- log.info("workspace/didChangeWatchedFiles", request)
+ const document = files[request.path]
+ if (document !== undefined) {
+ // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only
+ // re-emit diagnostics when the content actually changes, so clearing
+ // here would lose errors for no-op touchFile calls. Let the server's
+ // next push/pull overwrite naturally.
+ logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
- type: 2, // Changed
+ type: FILE_CHANGE_CHANGED,
},
],
})
- const next = version + 1
- files[request.path] = next
- log.info("textDocument/didChange", {
+ const next = document.version + 1
+ files[request.path] = { version: next, text }
+ logger.info("textDocument/didChange", {
path: request.path,
version: next,
})
@@ -173,23 +616,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
uri: pathToFileURL(request.path).href,
version: next,
},
- contentChanges: [{ text }],
+ contentChanges:
+ syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
+ ? [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: endPosition(document.text),
+ },
+ text,
+ },
+ ]
+ : [{ text }],
})
- return
+ return next
}
- log.info("workspace/didChangeWatchedFiles", request)
+ logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
- type: 1, // Created
+ type: FILE_CHANGE_CREATED,
},
],
})
- log.info("textDocument/didOpen", request)
- diagnostics.delete(request.path)
+ logger.info("textDocument/didOpen", request)
+ pushDiagnostics.delete(request.path)
+ pullDiagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(request.path).href,
@@ -198,52 +653,42 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
text,
},
})
- files[request.path] = 0
- return
+ files[request.path] = { version: 0, text }
+ return 0
},
},
get diagnostics() {
- return diagnostics
+ const result = new Map()
+ for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
+ result.set(key, mergedDiagnostics(key))
+ }
+ return result
},
- async waitForDiagnostics(request: { path: string }) {
+ async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
- log.info("waiting for diagnostics", { path: normalizedPath })
- let unsub: () => void
- let debounceTimer: ReturnType | undefined
- return await withTimeout(
- new Promise((resolve) => {
- unsub = Bus.subscribe(Event.Diagnostics, (event) => {
- if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
- // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
- if (debounceTimer) clearTimeout(debounceTimer)
- debounceTimer = setTimeout(() => {
- log.info("got diagnostics", { path: normalizedPath })
- unsub?.()
- resolve()
- }, DIAGNOSTICS_DEBOUNCE_MS)
- }
- })
- }),
- 3000,
- )
- .catch(() => {})
- .finally(() => {
- if (debounceTimer) clearTimeout(debounceTimer)
- unsub?.()
- })
+ logger.info("waiting for diagnostics", {
+ path: normalizedPath,
+ mode: request.mode ?? "full",
+ version: request.version,
+ })
+ if (request.mode === "document") {
+ await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
+ return
+ }
+ await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
},
async shutdown() {
- l.info("shutting down")
+ logger.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
- l.info("shutdown")
+ logger.info("shutdown")
},
}
- l.info("initialized")
+ logger.info("initialized")
return result
}
diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts
index 309a9fa84d8..08a8a3e4c32 100644
--- a/packages/opencode/src/lsp/lsp.ts
+++ b/packages/opencode/src/lsp/lsp.ts
@@ -10,10 +10,12 @@ import { Config } from "../config"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { TsClient } from "../kilocode/ts-client" // kilocode_change
+import { withStatics } from "@/util/schema"
+import { zod, ZodOverride } from "@/util/effect-zod"
const log = Log.create({ service: "lsp" })
@@ -21,60 +23,53 @@ export const Event = {
Updated: BusEvent.define("lsp.updated", z.object({})),
}
-export const Range = z
- .object({
- start: z.object({
- line: z.number(),
- character: z.number(),
- }),
- end: z.object({
- line: z.number(),
- character: z.number(),
- }),
- })
- .meta({
- ref: "Range",
- })
-export type Range = z.infer
-
-export const Symbol = z
- .object({
- name: z.string(),
- kind: z.number(),
- location: z.object({
- uri: z.string(),
- range: Range,
- }),
- })
- .meta({
- ref: "Symbol",
- })
-export type Symbol = z.infer
-
-export const DocumentSymbol = z
- .object({
- name: z.string(),
- detail: z.string().optional(),
- kind: z.number(),
+const Position = Schema.Struct({
+ line: Schema.Number,
+ character: Schema.Number,
+})
+
+export const Range = Schema.Struct({
+ start: Position,
+ end: Position,
+})
+ .annotate({ identifier: "Range" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Range = typeof Range.Type
+
+export const Symbol = Schema.Struct({
+ name: Schema.String,
+ kind: Schema.Number,
+ location: Schema.Struct({
+ uri: Schema.String,
range: Range,
- selectionRange: Range,
- })
- .meta({
- ref: "DocumentSymbol",
- })
-export type DocumentSymbol = z.infer
-
-export const Status = z
- .object({
- id: z.string(),
- name: z.string(),
- root: z.string(),
- status: z.union([z.literal("connected"), z.literal("error")]),
- })
- .meta({
- ref: "LSPStatus",
- })
-export type Status = z.infer
+ }),
+})
+ .annotate({ identifier: "Symbol" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Symbol = typeof Symbol.Type
+
+export const DocumentSymbol = Schema.Struct({
+ name: Schema.String,
+ detail: Schema.optional(Schema.String),
+ kind: Schema.Number,
+ range: Range,
+ selectionRange: Range,
+})
+ .annotate({ identifier: "DocumentSymbol" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type DocumentSymbol = typeof DocumentSymbol.Type
+
+export const Status = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ root: Schema.String,
+ status: Schema.Literals(["connected", "error"]).annotate({
+ [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
+ }),
+})
+ .annotate({ identifier: "LSPStatus" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Status = typeof Status.Type
enum SymbolKind {
File = 1,
@@ -142,7 +137,7 @@ export interface Interface {
readonly init: () => Effect.Effect
readonly status: () => Effect.Effect
readonly hasClients: (file: string) => Effect.Effect
- readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect
+ readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect
readonly diagnostics: () => Effect.Effect>
readonly hover: (input: LocInput) => Effect.Effect
readonly definition: (input: LocInput) => Effect.Effect
@@ -379,15 +374,21 @@ export const layer = Layer.effect(
})
})
- const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
+ const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
log.info("touching file", { file: input })
const clients = yield* getClients(input)
yield* Effect.promise(() =>
Promise.all(
clients.map(async (client) => {
- const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
- await client.notify.open({ path: input })
- return wait
+ const after = Date.now()
+ const version = await client.notify.open({ path: input })
+ if (!diagnostics) return
+ return client.waitForDiagnostics({
+ path: input,
+ version,
+ mode: diagnostics,
+ after,
+ })
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index 30ef500aacb..8cf65635c0a 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -488,7 +488,7 @@ export const Pyright: Info = {
const args = []
if (!binary) {
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
- const resolved = await Npm.which("pyright")
+ const resolved = await Npm.which("pyright", "pyright-langserver")
if (!resolved) return
binary = resolved
}
@@ -703,32 +703,32 @@ export const CSharp: Info = {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
- let bin = which("csharp-ls")
+ let bin = which("roslyn-language-server")
if (!bin) {
if (!which("dotnet")) {
- log.error(".NET SDK is required to install csharp-ls")
+ log.error(".NET SDK is required to install roslyn-language-server")
return
}
if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return
- log.info("installing csharp-ls via dotnet tool")
- const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
+ log.info("installing roslyn-language-server via dotnet tool")
+ const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
- log.error("Failed to install csharp-ls")
+ log.error("Failed to install roslyn-language-server")
return
}
- bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : ""))
- log.info(`installed csharp-ls`, { bin })
+ bin = path.join(Global.Path.bin, "roslyn-language-server" + (process.platform === "win32" ? ".exe" : ""))
+ log.info(`installed roslyn-language-server`, { bin })
}
return {
- process: spawn(bin, {
+ process: spawn(bin, ["--stdio", "--autoLoadProjects"], {
cwd: root,
}),
}
diff --git a/packages/opencode/src/npm/config.ts b/packages/opencode/src/npm/config.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts
index 477e99e06af..d6322d5488b 100644
--- a/packages/opencode/src/npm/index.ts
+++ b/packages/opencode/src/npm/index.ts
@@ -1,8 +1,11 @@
export * as Npm from "."
import path from "path"
+import { fileURLToPath } from "url"
import npa from "npm-package-arg"
import semver from "semver"
+import Config from "@npmcli/config"
+import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -34,18 +37,45 @@ export interface Interface {
},
) => Effect.Effect
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect
- readonly which: (pkg: string) => Effect.Effect>
+ readonly which: (pkg: string, bin?: string) => Effect.Effect>
}
export class Service extends Context.Service()("@opencode/Npm") {}
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
+const npmPath = fileURLToPath(new URL("../..", import.meta.url))
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
+const loadOptions = (dir: string) =>
+ Effect.tryPromise({
+ try: async () => {
+ const config = new Config({
+ npmPath,
+ cwd: dir,
+ env: { ...process.env },
+ argv: [process.execPath, process.execPath],
+ execPath: process.execPath,
+ platform: process.platform,
+ definitions,
+ flatten,
+ nerfDarts,
+ shorthands,
+ warn: false,
+ })
+ await config.load()
+ return config.flat
+ },
+ catch: (cause) =>
+ new InstallFailedError({
+ cause,
+ dir,
+ }),
+ })
+
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option
try {
@@ -81,7 +111,10 @@ export const layer = Layer.effect(
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
+ const add = input.add ?? []
+ const npmOptions = yield* loadOptions(input.dir)
const arborist = new Arborist({
+ ...npmOptions,
path: input.dir,
binLinks: true,
progress: false,
@@ -91,14 +124,15 @@ export const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
- add: input?.add || [],
+ ...npmOptions,
+ add,
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
- add: input?.add,
+ add,
dir: input.dir,
}),
}) as Effect.Effect
@@ -207,7 +241,7 @@ export const layer = Layer.effect(
return
}, Effect.scoped)
- const which = Effect.fn("Npm.which")(function* (pkg: string) {
+ const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
@@ -215,6 +249,9 @@ export const layer = Layer.effect(
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none()
+ // Caller picked a specific bin (e.g. pyright exposes both `pyright` and
+ // `pyright-langserver`); trust the hint if the package provides it.
+ if (bin) return files.includes(bin) ? Option.some(bin) : Option.none()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
@@ -223,11 +260,11 @@ export const layer = Layer.effect(
const parsed = pkgJson.value as { bin?: string | Record }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
- const bin = parsed.bin
- if (typeof bin === "string") return Option.some(unscoped)
- const keys = Object.keys(bin)
+ const parsedBin = parsed.bin
+ if (typeof parsedBin === "string") return Option.some(unscoped)
+ const keys = Object.keys(parsedBin)
if (keys.length === 1) return Option.some(keys[0])
- return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
+ return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
diff --git a/packages/opencode/src/npmcli-config.d.ts b/packages/opencode/src/npmcli-config.d.ts
new file mode 100644
index 00000000000..c9b20517ad9
--- /dev/null
+++ b/packages/opencode/src/npmcli-config.d.ts
@@ -0,0 +1,43 @@
+declare module "@npmcli/config" {
+ type Data = Record
+ type Where = "default" | "builtin" | "global" | "user" | "project" | "env" | "cli"
+
+ namespace Config {
+ interface Options {
+ definitions: Data
+ shorthands: Record
+ npmPath: string
+ flatten?: (input: Data, flat?: Data) => Data
+ nerfDarts?: string[]
+ argv?: string[]
+ cwd?: string
+ env?: NodeJS.ProcessEnv
+ execPath?: string
+ platform?: NodeJS.Platform
+ warn?: boolean
+ }
+ }
+
+ class Config {
+ constructor(input: Config.Options)
+
+ readonly data: Map
+ readonly flat: Data
+
+ load(): Promise
+ }
+
+ export = Config
+}
+
+declare module "@npmcli/config/lib/definitions" {
+ export const definitions: Record
+ export const shorthands: Record
+ export const flatten: (input: Record, flat?: Record) => Record
+ export const nerfDarts: string[]
+ export const proxyEnv: string[]
+}
+
+declare module "@npmcli/config/lib/definitions/index.js" {
+ export * from "@npmcli/config/lib/definitions"
+}
diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts
index 2b3e9c20b3c..80dbf03be59 100644
--- a/packages/opencode/src/patch/index.ts
+++ b/packages/opencode/src/patch/index.ts
@@ -3,6 +3,7 @@ import * as path from "path"
import * as fs from "fs/promises"
import { Log } from "../util"
import { Encoding } from "../kilocode/encoding" // kilocode_change
+import * as Bom from "../util/bom"
const log = Log.create({ service: "patch" })
@@ -305,24 +306,26 @@ export function maybeParseApplyPatch(
interface ApplyPatchFileUpdate {
unified_diff: string
content: string
+ bom: boolean
encoding: string // kilocode_change
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
- let originalContent: string
+ let originalContent: ReturnType
let encoding: string // kilocode_change - track detected encoding for round-trip write
try {
- // kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8")
+ // kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8").
+ // Encoding.readSync strips UTF-8 BOMs so the BOM flag is derived from the encoding label.
const result = Encoding.readSync(filePath)
- originalContent = result.text
+ originalContent = { bom: result.encoding === "utf-8-bom", text: result.text }
encoding = result.encoding
// kilocode_change end
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
- let originalLines = originalContent.split("\n")
+ let originalLines = originalContent.text.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
@@ -337,14 +340,16 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile
newLines.push("")
}
- const newContent = newLines.join("\n")
+ const next = Bom.split(newLines.join("\n"))
+ const newContent = next.text
// Generate unified diff
- const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
+ const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent)
return {
unified_diff: unifiedDiff,
content: newContent,
+ bom: originalContent.bom || next.bom,
encoding, // kilocode_change - include detected encoding for round-trip write
}
}
@@ -549,13 +554,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise {
if (hunk.move_path) {
// Handle file move
- await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile
+ await Encoding.write(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
- await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile
+ await Encoding.write(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index f8bde8a8485..1fa06397799 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -433,8 +433,18 @@ function expand(pattern: string): string {
}
export function fromConfig(permission: ConfigPermission.Info) {
+ // Sort top-level keys so wildcard permissions (`*`, `mcp_*`) come before
+ // specific ones. Combined with `findLast` in evaluate(), this gives the
+ // intuitive semantic "specific tool rules override the `*` fallback"
+ // regardless of the user's JSON key order. Sub-pattern order inside a
+ // single permission key is preserved — only top-level keys are sorted.
+ const entries = Object.entries(permission).sort(([a], [b]) => {
+ const aWild = a.includes("*")
+ const bWild = b.includes("*")
+ return aWild === bWild ? 0 : aWild ? -1 : 1
+ })
const ruleset: Ruleset = []
- for (const [key, value] of Object.entries(permission)) {
+ for (const [key, value] of entries) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
@@ -459,7 +469,7 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
-const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
+const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function disabled(tools: string[], ruleset: Ruleset): Set {
const result = new Set()
diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts
index 6ac9389a583..4eddc6a47ad 100644
--- a/packages/opencode/src/permission/schema.ts
+++ b/packages/opencode/src/permission/schema.ts
@@ -1,8 +1,7 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"
export class PermissionID extends Newtype()(
@@ -13,5 +12,5 @@ export class PermissionID extends Newtype()(
return this.make(Identifier.ascending("permission", id))
}
- static readonly zod = Identifier.schema("permission") as unknown as z.ZodType
+ static readonly zod = zod(this)
}
diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts
index 731eb85bdbb..c3e2b70fb81 100644
--- a/packages/opencode/src/plugin/codex.ts
+++ b/packages/opencode/src/plugin/codex.ts
@@ -385,6 +385,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise {
for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue
if (allowedModels.has(model.api.id)) continue
+ const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
+ if (match && parseFloat(match[1]) > 5.4) continue
delete provider.models[modelId]
}
diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts
index efbc400b5ee..2d486114a36 100644
--- a/packages/opencode/src/project/project.sql.ts
+++ b/packages/opencode/src/project/project.sql.ts
@@ -8,6 +8,7 @@ export const ProjectTable = sqliteTable("project", {
vcs: text(),
name: text(),
icon_url: text(),
+ icon_url_override: text(),
icon_color: text(),
...Timestamps,
time_initialized: integer(),
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 3ebf7919254..23e8a32f8ff 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -61,7 +61,13 @@ 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
+ row.icon_url || row.icon_url_override || row.icon_color
+ ? {
+ url: row.icon_url ?? undefined,
+ override: row.icon_url_override ?? undefined,
+ color: row.icon_color ?? undefined,
+ }
+ : undefined
return {
id: row.id,
worktree: row.worktree,
@@ -160,8 +166,8 @@ export const layer: Layer.Layer<
const scope = yield* Scope.Scope
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
- // kilocode change start
- return yield* fs.readFileString(pathSvc.join(dir, "kilo")).pipe(
+ // kilocode change start
+ return yield* fs.readFileString(pathSvc.join(dir, "kilo")).pipe(
// kilocode change end
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
@@ -210,13 +216,13 @@ export const layer: Layer.Layer<
vcs: fakeVcs,
}
}
- const worktree = (() => {
- const common = resolveGitPath(sandbox, commonDir.text.trim())
- return common === sandbox ? sandbox : pathSvc.dirname(common)
- })()
+ const common = resolveGitPath(sandbox, commonDir.text.trim())
+ const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
+ const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
+ const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
if (id == null) {
- id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
+ id = yield* readCachedProjectId(common)
}
if (!id) {
@@ -229,9 +235,7 @@ export const layer: Layer.Layer<
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
- // kilocode_change start
- yield* fs.writeFileString(pathSvc.join(worktree, ".git", "kilo"), id).pipe(Effect.ignore)
- // kilocode_change end
+ yield* fs.writeFileString(pathSvc.join(common, "kilo"), id).pipe(Effect.ignore) // kilocode_change
}
}
@@ -294,6 +298,7 @@ export const layer: Layer.Layer<
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
+ icon_url_override: result.icon?.override,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
@@ -308,6 +313,7 @@ export const layer: Layer.Layer<
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
+ icon_url_override: result.icon?.override,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
@@ -370,6 +376,7 @@ export const layer: Layer.Layer<
.set({
name: input.name,
icon_url: input.icon?.url,
+ icon_url_override: input.icon?.override,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
@@ -496,4 +503,4 @@ export function setInitialized(id: ProjectID) {
const { runPromise } = makeRuntime(Service, defaultLayer)
export const fromDirectory = (directory: string) => runPromise((svc) => svc.fromDirectory(directory))
export const sandboxes = (id: ProjectID) => runPromise((svc) => svc.sandboxes(id))
-// kilocode_change end
\ No newline at end of file
+// kilocode_change end
diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts
index d10c82e2c3d..7708b8de1ee 100644
--- a/packages/opencode/src/project/schema.ts
+++ b/packages/opencode/src/project/schema.ts
@@ -1,6 +1,6 @@
import { Schema } from "effect"
-import z from "zod"
+import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
@@ -10,6 +10,6 @@ export type ProjectID = typeof projectIdSchema.Type
export const ProjectID = projectIdSchema.pipe(
withStatics((schema: typeof projectIdSchema) => ({
global: schema.make("global"),
- zod: z.string().pipe(z.custom()),
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 060c55055df..d858c1bfe37 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -20,6 +20,7 @@ import { zod } from "@/util/effect-zod"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
+import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
@@ -425,6 +426,16 @@ function custom(dep: CustomDep): Record {
},
},
}),
+ nvidia: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "HTTP-Referer": "https://opencode.ai/",
+ "X-Title": "opencode",
+ },
+ },
+ }),
vercel: () =>
Effect.succeed({
autoload: false,
@@ -1529,7 +1540,10 @@ const layer: Layer.Layer<
installedPath = model.api.npm
}
- const mod = await import(installedPath)
+ // `installedPath` is a local entry path or an existing `file://` URL. Normalize
+ // only path inputs so Node on Windows accepts the dynamic import.
+ const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
+ const mod = await import(importSpec)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn({
diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts
index cb664820a12..aa75b0c2198 100644
--- a/packages/opencode/src/provider/schema.ts
+++ b/packages/opencode/src/provider/schema.ts
@@ -1,6 +1,6 @@
import { Schema } from "effect"
-import z from "zod"
+import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
@@ -9,7 +9,7 @@ export type ProviderID = typeof providerIdSchema.Type
export const ProviderID = providerIdSchema.pipe(
withStatics((schema: typeof providerIdSchema) => ({
- zod: z.string().pipe(z.custom()),
+ zod: zod(schema),
// Well-known providers
kilo: schema.make("kilo"), // kilocode_change
opencode: schema.make("opencode"),
@@ -31,7 +31,7 @@ const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID"))
export type ModelID = typeof modelIdSchema.Type
export const ModelID = modelIdSchema.pipe(
- withStatics((_schema: typeof modelIdSchema) => ({
- zod: z.string().pipe(z.custom()),
+ withStatics((schema: typeof modelIdSchema) => ({
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index f3dee7e6ec4..4a394949521 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -428,10 +428,9 @@ export function variants(model: Provider.Model): Record ({
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
- zod: Identifier.schema("pty").pipe(z.custom()),
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts
index 41186161d00..f7a0e096a3c 100644
--- a/packages/opencode/src/question/schema.ts
+++ b/packages/opencode/src/question/schema.ts
@@ -1,8 +1,7 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"
export class QuestionID extends Newtype()(
@@ -13,5 +12,5 @@ export class QuestionID extends Newtype()(
return this.make(Identifier.ascending("question", id))
}
- static readonly zod = Identifier.schema("question") as unknown as z.ZodType
+ static readonly zod = zod(this)
}
diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts
index 2d7268b7174..d10f2cd522f 100644
--- a/packages/opencode/src/server/routes/global.ts
+++ b/packages/opencode/src/server/routes/global.ts
@@ -160,7 +160,7 @@ export const GlobalRoutes = lazy(() =>
description: "Get global config info",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
@@ -181,14 +181,14 @@ export const GlobalRoutes = lazy(() =>
description: "Successfully updated global config",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
...errors(400),
},
}),
- validator("json", Config.Info),
+ validator("json", Config.Info.zod),
async (c) => {
const config = c.req.valid("json")
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts
index e75c1d44cbb..3bc3066a278 100644
--- a/packages/opencode/src/server/routes/instance/config.ts
+++ b/packages/opencode/src/server/routes/instance/config.ts
@@ -26,7 +26,7 @@ export const ConfigRoutes = lazy(() =>
description: "Get config info",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
@@ -49,14 +49,14 @@ export const ConfigRoutes = lazy(() =>
description: "Successfully updated config",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
...errors(400),
},
}),
- validator("json", Config.Info),
+ validator("json", Config.Info.zod),
async (c) =>
jsonRequest("ConfigRoutes.update", c, function* () {
const config = c.req.valid("json")
diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts
index 47502956524..8c8c1a507b9 100644
--- a/packages/opencode/src/server/routes/instance/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/experimental.ts
@@ -342,7 +342,7 @@ export const ExperimentalRoutes = lazy(() =>
description: "File diffs",
content: {
"application/json": {
- schema: resolver(z.array(Snapshot.FileDiff)),
+ schema: resolver(z.array(Snapshot.FileDiff.zod)),
},
},
},
@@ -387,7 +387,7 @@ export const ExperimentalRoutes = lazy(() =>
description: "Diff summary items",
content: {
"application/json": {
- schema: resolver(z.array(WorktreeDiff.Item)),
+ schema: resolver(z.array(WorktreeDiff.Item.zod)),
},
},
},
@@ -420,7 +420,7 @@ export const ExperimentalRoutes = lazy(() =>
description: "Diff detail item",
content: {
"application/json": {
- schema: resolver(WorktreeDiff.Item.nullable()),
+ schema: resolver(WorktreeDiff.Item.zod.nullable()),
},
},
},
diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts
index bbef679a855..f92fe6e7e5f 100644
--- a/packages/opencode/src/server/routes/instance/file.ts
+++ b/packages/opencode/src/server/routes/instance/file.ts
@@ -90,7 +90,7 @@ export const FileRoutes = lazy(() =>
description: "Symbols",
content: {
"application/json": {
- schema: resolver(LSP.Symbol.array()),
+ schema: resolver(LSP.Symbol.zod.array()),
},
},
},
diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts
index 14aa94f9fcf..2dfdec172a5 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/config.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts
@@ -9,6 +9,15 @@ export const ConfigApi = HttpApi.make("config")
.add(
HttpApiGroup.make("config")
.add(
+ HttpApiEndpoint.get("get", root, {
+ success: Config.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "config.get",
+ summary: "Get configuration",
+ description: "Retrieve the current OpenCode configuration settings and preferences.",
+ }),
+ ),
HttpApiEndpoint.get("providers", `${root}/providers`, {
success: Provider.ConfigProvidersResult,
}).annotateMerge(
@@ -36,16 +45,23 @@ export const ConfigApi = HttpApi.make("config")
export const configHandlers = Layer.unwrap(
Effect.gen(function* () {
- const svc = yield* Provider.Service
+ const providerSvc = yield* Provider.Service
+ const configSvc = yield* Config.Service
+
+ const get = Effect.fn("ConfigHttpApi.get")(function* () {
+ return yield* configSvc.get()
+ })
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
- const providers = yield* svc.list()
+ const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
- return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers))
+ return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
+ handlers.handle("get", get).handle("providers", providers),
+ )
}),
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))
diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index 49a1fa5619b..8c4380d0668 100644
--- a/packages/opencode/src/server/routes/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -41,6 +41,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
app.get("/permission", (c) => handler(c.req.raw, context))
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
+ app.get("/config", (c) => handler(c.req.raw, context))
app.get("/config/providers", (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
@@ -261,7 +262,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
description: "LSP server status",
content: {
"application/json": {
- schema: resolver(LSP.Status.array()),
+ schema: resolver(LSP.Status.zod.array()),
},
},
},
diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts
index 8da42c310e5..7404a5f4b08 100644
--- a/packages/opencode/src/server/routes/instance/session.ts
+++ b/packages/opencode/src/server/routes/instance/session.ts
@@ -471,7 +471,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully retrieved diff",
content: {
"application/json": {
- schema: resolver(Snapshot.FileDiff.array()),
+ schema: resolver(Snapshot.FileDiff.zod.array()),
},
},
},
@@ -611,7 +611,7 @@ export const SessionRoutes = lazy(() =>
description: "List of messages",
content: {
"application/json": {
- schema: resolver(MessageV2.WithParts.array()),
+ schema: resolver(MessageV2.WithParts.zod.array()),
},
},
},
@@ -701,8 +701,8 @@ export const SessionRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
- info: MessageV2.Info,
- parts: MessageV2.Part.array(),
+ info: MessageV2.Info.zod,
+ parts: MessageV2.Part.zod.array(),
}),
),
},
@@ -813,7 +813,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully updated part",
content: {
"application/json": {
- schema: resolver(MessageV2.Part),
+ schema: resolver(MessageV2.Part.zod),
},
},
},
@@ -828,7 +828,7 @@ export const SessionRoutes = lazy(() =>
partID: PartID.zod,
}),
),
- validator("json", MessageV2.Part),
+ validator("json", MessageV2.Part.zod),
async (c) => {
const params = c.req.valid("param")
const body = c.req.valid("json")
@@ -856,8 +856,8 @@ export const SessionRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
- info: MessageV2.Assistant,
- parts: MessageV2.Part.array(),
+ info: MessageV2.Assistant.zod,
+ parts: MessageV2.Part.zod.array(),
}),
),
},
@@ -882,7 +882,9 @@ export const SessionRoutes = lazy(() =>
const msg = await runRequest(
"SessionRoutes.prompt",
c,
- SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
+ SessionPrompt.Service.use((svc) =>
+ svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput),
+ ),
)
void stream.write(JSON.stringify(msg))
})
@@ -915,7 +917,9 @@ export const SessionRoutes = lazy(() =>
void runRequest(
"SessionRoutes.prompt_async",
c,
- SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
+ SessionPrompt.Service.use((svc) =>
+ svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput),
+ ),
).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
void Bus.publish(Session.Event.Error, {
@@ -940,8 +944,8 @@ export const SessionRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
- info: MessageV2.Assistant,
- parts: MessageV2.Part.array(),
+ info: MessageV2.Assistant.zod,
+ parts: MessageV2.Part.zod.array(),
}),
),
},
@@ -976,7 +980,7 @@ export const SessionRoutes = lazy(() =>
description: "Created message",
content: {
"application/json": {
- schema: resolver(MessageV2.WithParts),
+ schema: resolver(MessageV2.WithParts.zod),
},
},
},
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 212f5fdbab8..defdb870d7d 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -15,7 +15,9 @@ import { NotFoundError } from "@/storage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
-import { isOverflow as overflow } from "./overflow"
+import { isOverflow as overflow, usable } from "./overflow"
+import { makeRuntime } from "@/effect/run-service"
+import { fn } from "@/util/fn"
const log = Log.create({ service: "session.compaction" })
@@ -30,7 +32,154 @@ export const Event = {
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
+const TOOL_OUTPUT_MAX_CHARS = 2_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
+const DEFAULT_TAIL_TURNS = 2
+const MIN_PRESERVE_RECENT_TOKENS = 2_000
+const MAX_PRESERVE_RECENT_TOKENS = 8_000
+const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged:
+---
+## Goal
+- [single-sentence task summary]
+
+## Constraints & Preferences
+- [user constraints, preferences, specs, or "(none)"]
+
+## Progress
+### Done
+- [completed work or "(none)"]
+
+### In Progress
+- [current work or "(none)"]
+
+### Blocked
+- [blockers or "(none)"]
+
+## Key Decisions
+- [decision and why, or "(none)"]
+
+## Next Steps
+- [ordered next actions or "(none)"]
+
+## Critical Context
+- [important technical facts, errors, open questions, or "(none)"]
+
+## Relevant Files
+- [file or directory path: why it matters, or "(none)"]
+---
+
+Rules:
+- Keep every section, even when empty.
+- Use terse bullets, not prose paragraphs.
+- Preserve exact file paths, commands, error strings, and identifiers when known.
+- Do not mention the summary process or that context was compacted.`
+type Turn = {
+ start: number
+ end: number
+ id: MessageID
+}
+
+type Tail = {
+ start: number
+ id: MessageID
+}
+
+type CompletedCompaction = {
+ userIndex: number
+ assistantIndex: number
+ summary: string | undefined
+}
+
+function summaryText(message: MessageV2.WithParts) {
+ const text = message.parts
+ .filter((part): part is MessageV2.TextPart => part.type === "text")
+ .map((part) => part.text.trim())
+ .filter(Boolean)
+ .join("\n\n")
+ .trim()
+ return text || undefined
+}
+
+function completedCompactions(messages: MessageV2.WithParts[]) {
+ const users = new Map()
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i]
+ if (msg.info.role !== "user") continue
+ if (!msg.parts.some((part) => part.type === "compaction")) continue
+ users.set(msg.info.id, i)
+ }
+
+ return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => {
+ if (msg.info.role !== "assistant") return []
+ if (!msg.info.summary || !msg.info.finish || msg.info.error) return []
+ const userIndex = users.get(msg.info.parentID)
+ if (userIndex === undefined) return []
+ return [{ userIndex, assistantIndex, summary: summaryText(msg) }]
+ })
+}
+
+function buildPrompt(input: { previousSummary?: string; context: string[] }) {
+ const anchor = input.previousSummary
+ ? [
+ "Update the anchored summary below using the conversation history above.",
+ "Preserve still-true details, remove stale details, and merge in the new facts.",
+ "",
+ input.previousSummary,
+ "",
+ ].join("\n")
+ : "Create a new anchored summary from the conversation history above."
+ return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
+}
+
+function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
+ return (
+ input.cfg.compaction?.preserve_recent_tokens ??
+ Math.min(MAX_PRESERVE_RECENT_TOKENS, Math.max(MIN_PRESERVE_RECENT_TOKENS, Math.floor(usable(input) * 0.25)))
+ )
+}
+
+function turns(messages: MessageV2.WithParts[]) {
+ const result: Turn[] = []
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i]
+ if (msg.info.role !== "user") continue
+ if (msg.parts.some((part) => part.type === "compaction")) continue
+ result.push({
+ start: i,
+ end: messages.length,
+ id: msg.info.id,
+ })
+ }
+ for (let i = 0; i < result.length - 1; i++) {
+ result[i].end = result[i + 1].start
+ }
+ return result
+}
+
+function splitTurn(input: {
+ messages: MessageV2.WithParts[]
+ turn: Turn
+ model: Provider.Model
+ budget: number
+ estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect
+}) {
+ return Effect.gen(function* () {
+ if (input.budget <= 0) return undefined
+ if (input.turn.end - input.turn.start <= 1) return undefined
+ for (let start = input.turn.start + 1; start < input.turn.end; start++) {
+ const size = yield* input.estimate({
+ messages: input.messages.slice(start, input.turn.end),
+ model: input.model,
+ })
+ if (size > input.budget) continue
+ return {
+ start,
+ id: input.messages[start]!.info.id,
+ } satisfies Tail
+ }
+ return undefined
+ })
+}
export interface Interface {
readonly isOverflow: (input: {
@@ -84,11 +233,70 @@ export const layer: Layer.Layer<
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
})
+ const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
+ messages: MessageV2.WithParts[]
+ model: Provider.Model
+ }) {
+ const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
+ return Token.estimate(JSON.stringify(msgs))
+ })
+
+ const select = Effect.fn("SessionCompaction.select")(function* (input: {
+ messages: MessageV2.WithParts[]
+ cfg: Config.Info
+ model: Provider.Model
+ }) {
+ const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
+ if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
+ const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
+ const all = turns(input.messages)
+ if (!all.length) return { head: input.messages, tail_start_id: undefined }
+ const recent = all.slice(-limit)
+ const sizes = yield* Effect.forEach(
+ recent,
+ (turn) =>
+ estimate({
+ messages: input.messages.slice(turn.start, turn.end),
+ model: input.model,
+ }),
+ { concurrency: 1 },
+ )
+
+ let total = 0
+ let keep: Tail | undefined
+ for (let i = recent.length - 1; i >= 0; i--) {
+ const turn = recent[i]!
+ const size = sizes[i]
+ if (total + size <= budget) {
+ total += size
+ keep = { start: turn.start, id: turn.id }
+ continue
+ }
+ const remaining = budget - total
+ const split = yield* splitTurn({
+ messages: input.messages,
+ turn,
+ model: input.model,
+ budget: remaining,
+ estimate,
+ })
+ if (split) keep = split
+ else if (!keep) log.info("tail fallback", { budget, size, total })
+ break
+ }
+
+ if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
+ return {
+ head: input.messages.slice(0, keep.start),
+ tail_start_id: keep.id,
+ }
+ })
+
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
- if (cfg.compaction?.prune === false) return
+ if (!cfg.compaction?.prune) return
log.info("pruning")
const msgs = yield* session
@@ -108,17 +316,15 @@ export const layer: Layer.Layer<
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
- if (part.type === "tool")
- if (part.state.status === "completed") {
- if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
- if (part.state.time.compacted) break loop
- const estimate = Token.estimate(part.state.output)
- total += estimate
- if (total > PRUNE_PROTECT) {
- pruned += estimate
- toPrune.push(part)
- }
- }
+ if (part.type !== "tool") continue
+ if (part.state.status !== "completed") continue
+ if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
+ if (part.state.time.compacted) break loop
+ const estimate = Token.estimate(part.state.output)
+ total += estimate
+ if (total <= PRUNE_PROTECT) continue
+ pruned += estimate
+ toPrune.push(part)
}
}
@@ -146,6 +352,7 @@ export const layer: Layer.Layer<
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
}
const userMessage = parent.info
+ const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction")
let messages = input.messages
let replay:
@@ -176,46 +383,29 @@ export const layer: Layer.Layer<
const model = agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
+ const cfg = yield* config.get()
+ const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
+ const prior = completedCompactions(history)
+ const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
+ const previousSummary = prior.at(-1)?.summary
+ const selected = yield* select({
+ messages: history.filter((_, index) => !hidden.has(index)),
+ cfg,
+ model,
+ })
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
- const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
-Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
-The summary that you construct will be used so that another agent can read it and continue the work.
-Do not call any tools. Respond only with the summary text.
-Respond in the same language as the user's messages in the conversation.
-
-When constructing the summary, try to stick to this template:
----
-## Goal
-
-[What goal(s) is the user trying to accomplish?]
-
-## Instructions
-
-- [What important instructions did the user give you that are relevant]
-- [If there is a plan or spec, include information about it so next agent can continue using it]
-
-## Discoveries
-
-[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work]
-
-## Accomplished
-
-[What work has been completed, what work is still in progress, and what work is left?]
-
-## Relevant files / directories
-
-[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
----`
-
- const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
- const msgs = structuredClone(messages)
+ const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
+ const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
- const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
+ const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
+ stripMedia: true,
+ toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
+ })
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
@@ -259,7 +449,7 @@ When constructing the summary, try to stick to this template:
...modelMessages,
{
role: "user",
- content: [{ type: "text", text: prompt }],
+ content: [{ type: "text", text: nextPrompt }],
},
],
model,
@@ -276,6 +466,13 @@ When constructing the summary, try to stick to this template:
return "stop"
}
+ if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
+ yield* session.updatePart({
+ ...compactionPart,
+ tail_start_id: selected.tail_start_id,
+ })
+ }
+
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
@@ -409,4 +606,25 @@ export const defaultLayer = Layer.suspend(() =>
),
)
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
+ return runPromise((svc) => svc.isOverflow(input))
+}
+
+export async function prune(input: { sessionID: SessionID }) {
+ return runPromise((svc) => svc.prune(input))
+}
+
+export const create = fn(
+ z.object({
+ sessionID: SessionID.zod,
+ agent: z.string(),
+ model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
+ auto: z.boolean(),
+ overflow: z.boolean().optional(),
+ }),
+ (input) => runPromise((svc) => svc.create(input)),
+)
+
export * as SessionCompaction from "./compaction"
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index ff12bbda585..0687f166cc8 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -15,8 +15,11 @@ import { isMedia } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
-import { Effect } from "effect"
import { SessionNetwork } from "./network" // kilocode_change
+import { Effect, Schema, Types } from "effect"
+import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
+import { namedSchemaError } from "@/util/named-schema-error"
import { EffectLogger } from "@/effect"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
@@ -29,441 +32,560 @@ interface FetchDecompressionError extends Error {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export { isMedia }
-export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
-export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
-export const StructuredOutputError = NamedError.create(
- "StructuredOutputError",
- z.object({
- message: z.string(),
- retries: z.number(),
- }),
-)
-export const AuthError = NamedError.create(
- "ProviderAuthError",
- z.object({
- providerID: z.string(),
- message: z.string(),
- }),
-)
-export const APIError = NamedError.create(
- "APIError",
- z.object({
- message: z.string(),
- statusCode: z.number().optional(),
- isRetryable: z.boolean(),
- responseHeaders: z.record(z.string(), z.string()).optional(),
- responseBody: z.string().optional(),
- metadata: z.record(z.string(), z.string()).optional(),
- }),
-)
+export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
+export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
+export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
+ message: Schema.String,
+ retries: Schema.Number,
+})
+export const AuthError = namedSchemaError("ProviderAuthError", {
+ providerID: Schema.String,
+ message: Schema.String,
+})
+export const APIError = namedSchemaError("APIError", {
+ message: Schema.String,
+ statusCode: Schema.optional(Schema.Number),
+ isRetryable: Schema.Boolean,
+ responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+ responseBody: Schema.optional(Schema.String),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+})
export type APIError = z.infer
-export const ContextOverflowError = NamedError.create(
- "ContextOverflowError",
- z.object({ message: z.string(), responseBody: z.string().optional() }),
-)
-
-export const OutputFormatText = z
- .object({
- type: z.literal("text"),
- })
- .meta({
- ref: "OutputFormatText",
- })
-
-export const OutputFormatJsonSchema = z
- .object({
- type: z.literal("json_schema"),
- schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
- retryCount: z.number().int().min(0).default(2),
- })
- .meta({
- ref: "OutputFormatJsonSchema",
- })
-
-export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({
- ref: "OutputFormat",
+export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
+ message: Schema.String,
+ responseBody: Schema.optional(Schema.String),
})
-export type OutputFormat = z.infer
-const PartBase = z.object({
- id: PartID.zod,
- sessionID: SessionID.zod,
- messageID: MessageID.zod,
+export class OutputFormatText extends Schema.Class("OutputFormatText")({
+ type: Schema.Literal("text"),
+}) {
+ static readonly zod = zod(this)
+}
+
+export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({
+ type: Schema.Literal("json_schema"),
+ schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
+ retryCount: Schema.Number.check(Schema.isInt())
+ .check(Schema.isGreaterThanOrEqualTo(0))
+ .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
+}) {
+ static readonly zod = zod(this)
+}
+
+const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
+ discriminator: "type",
+ identifier: "OutputFormat",
})
+export const Format = Object.assign(_Format, { zod: zod(_Format) })
+export type OutputFormat = Schema.Schema.Type
-export const SnapshotPart = PartBase.extend({
- type: z.literal("snapshot"),
- snapshot: z.string(),
-}).meta({
- ref: "SnapshotPart",
+const partBase = {
+ id: PartID,
+ sessionID: SessionID,
+ messageID: MessageID,
+}
+
+export const SnapshotPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("snapshot"),
+ snapshot: Schema.String,
})
-export type SnapshotPart = z.infer
-
-export const PatchPart = PartBase.extend({
- type: z.literal("patch"),
- hash: z.string(),
- files: z.string().array(),
-}).meta({
- ref: "PatchPart",
+ .annotate({ identifier: "SnapshotPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type SnapshotPart = Types.DeepMutable>
+
+export const PatchPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("patch"),
+ hash: Schema.String,
+ files: Schema.Array(Schema.String),
})
-export type PatchPart = z.infer
-
-export const TextPart = PartBase.extend({
- type: z.literal("text"),
- text: z.string(),
- synthetic: z.boolean().optional(),
- ignored: z.boolean().optional(),
- time: z
- .object({
- start: z.number(),
- end: z.number().optional(),
- })
- .optional(),
- metadata: z.record(z.string(), z.any()).optional(),
-}).meta({
- ref: "TextPart",
+ .annotate({ identifier: "PatchPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type PatchPart = Types.DeepMutable>
+
+export const TextPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("text"),
+ text: Schema.String,
+ synthetic: Schema.optional(Schema.Boolean),
+ ignored: Schema.optional(Schema.Boolean),
+ time: Schema.optional(
+ Schema.Struct({
+ start: Schema.Number,
+ end: Schema.optional(Schema.Number),
+ }),
+ ),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
})
-export type TextPart = z.infer
-
-export const ReasoningPart = PartBase.extend({
- type: z.literal("reasoning"),
- text: z.string(),
- metadata: z.record(z.string(), z.any()).optional(),
- time: z.object({
- start: z.number(),
- end: z.number().optional(),
+ .annotate({ identifier: "TextPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type TextPart = Types.DeepMutable>
+
+export const ReasoningPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("reasoning"),
+ text: Schema.String,
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ time: Schema.Struct({
+ start: Schema.Number,
+ end: Schema.optional(Schema.Number),
}),
-}).meta({
- ref: "ReasoningPart",
-})
-export type ReasoningPart = z.infer
-
-const FilePartSourceBase = z.object({
- text: z
- .object({
- value: z.string(),
- start: z.number().int(),
- end: z.number().int(),
- })
- .meta({
- ref: "FilePartSourceText",
- }),
})
+ .annotate({ identifier: "ReasoningPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ReasoningPart = Types.DeepMutable>
+
+const filePartSourceBase = {
+ text: Schema.Struct({
+ value: Schema.String,
+ start: Schema.Number.check(Schema.isInt()),
+ end: Schema.Number.check(Schema.isInt()),
+ }).annotate({ identifier: "FilePartSourceText" }),
+}
-export const FileSource = FilePartSourceBase.extend({
- type: z.literal("file"),
- path: z.string(),
-}).meta({
- ref: "FileSource",
+export const FileSource = Schema.Struct({
+ ...filePartSourceBase,
+ type: Schema.Literal("file"),
+ path: Schema.String,
})
+ .annotate({ identifier: "FileSource" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
-export const SymbolSource = FilePartSourceBase.extend({
- type: z.literal("symbol"),
- path: z.string(),
+export const SymbolSource = Schema.Struct({
+ ...filePartSourceBase,
+ type: Schema.Literal("symbol"),
+ path: Schema.String,
range: LSP.Range,
- name: z.string(),
- kind: z.number().int(),
-}).meta({
- ref: "SymbolSource",
+ name: Schema.String,
+ kind: Schema.Number.check(Schema.isInt()),
})
-
-export const ResourceSource = FilePartSourceBase.extend({
- type: z.literal("resource"),
- clientName: z.string(),
- uri: z.string(),
-}).meta({
- ref: "ResourceSource",
+ .annotate({ identifier: "SymbolSource" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+
+export const ResourceSource = Schema.Struct({
+ ...filePartSourceBase,
+ type: Schema.Literal("resource"),
+ clientName: Schema.String,
+ uri: Schema.String,
})
+ .annotate({ identifier: "ResourceSource" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
-export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource, ResourceSource]).meta({
- ref: "FilePartSource",
+const _FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({
+ discriminator: "type",
+ identifier: "FilePartSource",
})
-
-export const FilePart = PartBase.extend({
- type: z.literal("file"),
- mime: z.string(),
- filename: z.string().optional(),
- url: z.string(),
- source: FilePartSource.optional(),
-}).meta({
- ref: "FilePart",
+export const FilePartSource = Object.assign(_FilePartSource, { zod: zod(_FilePartSource) })
+
+export const FilePart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("file"),
+ mime: Schema.String,
+ filename: Schema.optional(Schema.String),
+ url: Schema.String,
+ source: Schema.optional(_FilePartSource),
})
-export type FilePart = z.infer
-
-export const AgentPart = PartBase.extend({
- type: z.literal("agent"),
- name: z.string(),
- source: z
- .object({
- value: z.string(),
- start: z.number().int(),
- end: z.number().int(),
- })
- .optional(),
-}).meta({
- ref: "AgentPart",
+ .annotate({ identifier: "FilePart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FilePart = Types.DeepMutable>
+
+export const AgentPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("agent"),
+ name: Schema.String,
+ source: Schema.optional(
+ Schema.Struct({
+ value: Schema.String,
+ start: Schema.Number.check(Schema.isInt()),
+ end: Schema.Number.check(Schema.isInt()),
+ }),
+ ),
})
-export type AgentPart = z.infer
-
-export const CompactionPart = PartBase.extend({
- type: z.literal("compaction"),
- auto: z.boolean(),
- overflow: z.boolean().optional(),
-}).meta({
- ref: "CompactionPart",
+ .annotate({ identifier: "AgentPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type AgentPart = Types.DeepMutable>
+
+export const CompactionPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("compaction"),
+ auto: Schema.Boolean,
+ overflow: Schema.optional(Schema.Boolean),
+ tail_start_id: Schema.optional(MessageID),
})
-export type CompactionPart = z.infer
-
-export const SubtaskPart = PartBase.extend({
- type: z.literal("subtask"),
- prompt: z.string(),
- description: z.string(),
- agent: z.string(),
- model: z
- .object({
- providerID: ProviderID.zod,
- modelID: ModelID.zod,
- })
- .optional(),
- command: z.string().optional(),
-}).meta({
- ref: "SubtaskPart",
+ .annotate({ identifier: "CompactionPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type CompactionPart = Types.DeepMutable>
+
+export const SubtaskPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("subtask"),
+ prompt: Schema.String,
+ description: Schema.String,
+ agent: Schema.String,
+ model: Schema.optional(
+ Schema.Struct({
+ providerID: ProviderID,
+ modelID: ModelID,
+ }),
+ ),
+ command: Schema.optional(Schema.String),
})
-export type SubtaskPart = z.infer
-
-export const RetryPart = PartBase.extend({
- type: z.literal("retry"),
- attempt: z.number(),
- error: APIError.Schema,
- time: z.object({
- created: z.number(),
+ .annotate({ identifier: "SubtaskPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type SubtaskPart = Types.DeepMutable>
+
+export const RetryPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("retry"),
+ attempt: Schema.Number,
+ // APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate.
+ error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }),
+ time: Schema.Struct({
+ created: Schema.Number,
}),
-}).meta({
- ref: "RetryPart",
})
-export type RetryPart = z.infer
+ .annotate({ identifier: "RetryPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type RetryPart = Omit>, "error"> & {
+ error: APIError
+}
-export const StepStartPart = PartBase.extend({
- type: z.literal("step-start"),
- snapshot: z.string().optional(),
-}).meta({
- ref: "StepStartPart",
+export const StepStartPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("step-start"),
+ snapshot: Schema.optional(Schema.String),
})
-export type StepStartPart = z.infer
-
-export const StepFinishPart = PartBase.extend({
- type: z.literal("step-finish"),
- reason: z.string(),
- snapshot: z.string().optional(),
- cost: z.number(),
- tokens: z.object({
- total: z.number().optional(),
- input: z.number(),
- output: z.number(),
- reasoning: z.number(),
- cache: z.object({
- read: z.number(),
- write: z.number(),
+ .annotate({ identifier: "StepStartPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type StepStartPart = Types.DeepMutable>
+
+export const StepFinishPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("step-finish"),
+ reason: Schema.String,
+ snapshot: Schema.optional(Schema.String),
+ cost: Schema.Number,
+ tokens: Schema.Struct({
+ total: Schema.optional(Schema.Number),
+ input: Schema.Number,
+ output: Schema.Number,
+ reasoning: Schema.Number,
+ cache: Schema.Struct({
+ read: Schema.Number,
+ write: Schema.Number,
}),
}),
-}).meta({
- ref: "StepFinishPart",
})
-export type StepFinishPart = z.infer
-
-export const ToolStatePending = z
- .object({
- status: z.literal("pending"),
- input: z.record(z.string(), z.any()),
- raw: z.string(),
- })
- .meta({
- ref: "ToolStatePending",
- })
-
-export type ToolStatePending = z.infer
-
-export const ToolStateRunning = z
- .object({
- status: z.literal("running"),
- input: z.record(z.string(), z.any()),
- title: z.string().optional(),
- metadata: z.record(z.string(), z.any()).optional(),
- time: z.object({
- start: z.number(),
- }),
- })
- .meta({
- ref: "ToolStateRunning",
- })
-export type ToolStateRunning = z.infer
-
-export const ToolStateCompleted = z
- .object({
- status: z.literal("completed"),
- input: z.record(z.string(), z.any()),
- output: z.string(),
- title: z.string(),
- metadata: z.record(z.string(), z.any()),
- time: z.object({
- start: z.number(),
- end: z.number(),
- compacted: z.number().optional(),
- }),
- attachments: FilePart.array().optional(),
- })
- .meta({
- ref: "ToolStateCompleted",
- })
-export type ToolStateCompleted = z.infer
-
-export const ToolStateError = z
- .object({
- status: z.literal("error"),
- input: z.record(z.string(), z.any()),
- error: z.string(),
- metadata: z.record(z.string(), z.any()).optional(),
- time: z.object({
- start: z.number(),
- end: z.number(),
- }),
- })
- .meta({
- ref: "ToolStateError",
- })
-export type ToolStateError = z.infer
-
-export const ToolState = z
- .discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
- .meta({
- ref: "ToolState",
- })
-
-export const ToolPart = PartBase.extend({
- type: z.literal("tool"),
- callID: z.string(),
- tool: z.string(),
- state: ToolState,
- metadata: z.record(z.string(), z.any()).optional(),
-}).meta({
- ref: "ToolPart",
+ .annotate({ identifier: "StepFinishPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type StepFinishPart = Types.DeepMutable>
+
+export const ToolStatePending = Schema.Struct({
+ status: Schema.Literal("pending"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ raw: Schema.String,
+})
+ .annotate({ identifier: "ToolStatePending" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStatePending = Types.DeepMutable>
+
+export const ToolStateRunning = Schema.Struct({
+ status: Schema.Literal("running"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ title: Schema.optional(Schema.String),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ time: Schema.Struct({
+ start: Schema.Number,
+ }),
+})
+ .annotate({ identifier: "ToolStateRunning" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStateRunning = Types.DeepMutable>
+
+export const ToolStateCompleted = Schema.Struct({
+ status: Schema.Literal("completed"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ output: Schema.String,
+ title: Schema.String,
+ metadata: Schema.Record(Schema.String, Schema.Any),
+ time: Schema.Struct({
+ start: Schema.Number,
+ end: Schema.Number,
+ compacted: Schema.optional(Schema.Number),
+ }),
+ attachments: Schema.optional(Schema.Array(FilePart)),
+})
+ .annotate({ identifier: "ToolStateCompleted" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStateCompleted = Types.DeepMutable>
+
+function truncateToolOutput(text: string, maxChars?: number) {
+ if (!maxChars || text.length <= maxChars) return text
+ const omitted = text.length - maxChars
+ return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]`
+}
+
+export const ToolStateError = Schema.Struct({
+ status: Schema.Literal("error"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ error: Schema.String,
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ time: Schema.Struct({
+ start: Schema.Number,
+ end: Schema.Number,
+ }),
})
-export type ToolPart = z.infer
+ .annotate({ identifier: "ToolStateError" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStateError = Types.DeepMutable>
-const Base = z.object({
- id: MessageID.zod,
- sessionID: SessionID.zod,
+const _ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).annotate({
+ discriminator: "status",
+ identifier: "ToolState",
})
+// Cast the derived zod so downstream z.infer sees the same mutable shape that
+// our exported TS types expose (the pre-migration Zod inferences were mutable).
+export const ToolState = Object.assign(_ToolState, {
+ zod: zod(_ToolState) as unknown as z.ZodType<
+ ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
+ >,
+})
+export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
+
+export const ToolPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("tool"),
+ callID: Schema.String,
+ tool: Schema.String,
+ state: _ToolState,
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+})
+ .annotate({ identifier: "ToolPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolPart = Omit>, "state"> & {
+ state: ToolState
+}
+
+const messageBase = {
+ id: MessageID,
+ sessionID: SessionID,
+}
-export const User = Base.extend({
- role: z.literal("user"),
- time: z.object({
- created: z.number(),
+export const User = Schema.Struct({
+ ...messageBase,
+ role: Schema.Literal("user"),
+ time: Schema.Struct({
+ created: Schema.Number,
}),
- format: Format.optional(),
- summary: z
- .object({
- title: z.string().optional(),
- body: z.string().optional(),
- diffs: Snapshot.FileDiff.array(),
- })
- .optional(),
- agent: z.string(),
- model: z.object({
- providerID: ProviderID.zod,
- modelID: ModelID.zod,
- variant: z.string().optional(),
+ format: Schema.optional(_Format),
+ summary: Schema.optional(
+ Schema.Struct({
+ title: Schema.optional(Schema.String),
+ body: Schema.optional(Schema.String),
+ diffs: Schema.Array(Snapshot.FileDiff),
+ }),
+ ),
+ agent: Schema.String,
+ model: Schema.Struct({
+ providerID: ProviderID,
+ modelID: ModelID,
+ variant: Schema.optional(Schema.String),
}),
- system: z.string().optional(),
- tools: z.record(z.string(), z.boolean()).optional(),
+ system: Schema.optional(Schema.String),
+ tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
// kilocode_change start
- editorContext: z
- .object({
- visibleFiles: z.array(z.string()).optional(),
- openTabs: z.array(z.string()).optional(),
- activeFile: z.string().optional(),
- shell: z.string().optional(),
- })
- .optional(),
+ editorContext: Schema.optional(
+ Schema.Struct({
+ visibleFiles: Schema.optional(Schema.Array(Schema.String)),
+ openTabs: Schema.optional(Schema.Array(Schema.String)),
+ activeFile: Schema.optional(Schema.String),
+ shell: Schema.optional(Schema.String),
+ }),
+ ),
// kilocode_change end
-}).meta({
- ref: "UserMessage",
})
-export type User = z.infer
-
-export const Part = z
- .discriminatedUnion("type", [
- TextPart,
- SubtaskPart,
- ReasoningPart,
- FilePart,
- ToolPart,
- StepStartPart,
- StepFinishPart,
- SnapshotPart,
- PatchPart,
- AgentPart,
- RetryPart,
- CompactionPart,
- ])
- .meta({
- ref: "Part",
- })
-export type Part = z.infer
-
-export const Assistant = Base.extend({
- role: z.literal("assistant"),
- time: z.object({
- created: z.number(),
- completed: z.number().optional(),
+ .annotate({ identifier: "UserMessage" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type User = Types.DeepMutable>
+
+const _Part = Schema.Union([
+ TextPart,
+ SubtaskPart,
+ ReasoningPart,
+ FilePart,
+ ToolPart,
+ StepStartPart,
+ StepFinishPart,
+ SnapshotPart,
+ PatchPart,
+ AgentPart,
+ RetryPart,
+ CompactionPart,
+]).annotate({ discriminator: "type", identifier: "Part" })
+export const Part = Object.assign(_Part, {
+ zod: zod(_Part) as unknown as z.ZodType<
+ | TextPart
+ | SubtaskPart
+ | ReasoningPart
+ | FilePart
+ | ToolPart
+ | StepStartPart
+ | StepFinishPart
+ | SnapshotPart
+ | PatchPart
+ | AgentPart
+ | RetryPart
+ | CompactionPart
+ >,
+})
+export type Part =
+ | TextPart
+ | SubtaskPart
+ | ReasoningPart
+ | FilePart
+ | ToolPart
+ | StepStartPart
+ | StepFinishPart
+ | SnapshotPart
+ | PatchPart
+ | AgentPart
+ | RetryPart
+ | CompactionPart
+
+// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived
+// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the
+// error classes to Schema.TaggedErrorClass is a separate slice.
+const AssistantErrorZod = z.discriminatedUnion("name", [
+ AuthError.Schema,
+ NamedError.Unknown.Schema,
+ OutputLengthError.Schema,
+ AbortedError.Schema,
+ StructuredOutputError.Schema,
+ ContextOverflowError.Schema,
+ APIError.Schema,
+])
+type AssistantError = z.infer
+
+// ── Prompt input schemas ─────────────────────────────────────────────────────
+//
+// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
+// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may
+// omit `id` to let the server allocate one. These Schema-Struct variants
+// carry that shape, and `SessionPrompt.PromptInput` just references the
+// derived `.zod` (no omit/partial gymnastics needed at the call site).
+
+export const TextPartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("text"),
+ text: Schema.String,
+ synthetic: Schema.optional(Schema.Boolean),
+ ignored: Schema.optional(Schema.Boolean),
+ time: Schema.optional(
+ Schema.Struct({
+ start: Schema.Number,
+ end: Schema.optional(Schema.Number),
+ }),
+ ),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+})
+ .annotate({ identifier: "TextPartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type TextPartInput = Types.DeepMutable>
+
+export const FilePartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("file"),
+ mime: Schema.String,
+ filename: Schema.optional(Schema.String),
+ url: Schema.String,
+ source: Schema.optional(_FilePartSource),
+})
+ .annotate({ identifier: "FilePartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FilePartInput = Types.DeepMutable>
+
+export const AgentPartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("agent"),
+ name: Schema.String,
+ source: Schema.optional(
+ Schema.Struct({
+ value: Schema.String,
+ start: Schema.Number.check(Schema.isInt()),
+ end: Schema.Number.check(Schema.isInt()),
+ }),
+ ),
+})
+ .annotate({ identifier: "AgentPartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type AgentPartInput = Types.DeepMutable>
+
+export const SubtaskPartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("subtask"),
+ prompt: Schema.String,
+ description: Schema.String,
+ agent: Schema.String,
+ model: Schema.optional(
+ Schema.Struct({
+ providerID: ProviderID,
+ modelID: ModelID,
+ }),
+ ),
+ command: Schema.optional(Schema.String),
+})
+ .annotate({ identifier: "SubtaskPartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type SubtaskPartInput = Types.DeepMutable>
+
+export const Assistant = Schema.Struct({
+ ...messageBase,
+ role: Schema.Literal("assistant"),
+ time: Schema.Struct({
+ created: Schema.Number,
+ completed: Schema.optional(Schema.Number),
}),
- error: z
- .discriminatedUnion("name", [
- AuthError.Schema,
- NamedError.Unknown.Schema,
- OutputLengthError.Schema,
- AbortedError.Schema,
- StructuredOutputError.Schema,
- ContextOverflowError.Schema,
- APIError.Schema,
- ])
- .optional(),
- parentID: MessageID.zod,
- modelID: ModelID.zod,
- providerID: ProviderID.zod,
+ error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })),
+ parentID: MessageID,
+ modelID: ModelID,
+ providerID: ProviderID,
/**
* @deprecated
*/
- mode: z.string(),
- agent: z.string(),
- path: z.object({
- cwd: z.string(),
- root: z.string(),
+ mode: Schema.String,
+ agent: Schema.String,
+ path: Schema.Struct({
+ cwd: Schema.String,
+ root: Schema.String,
}),
- summary: z.boolean().optional(),
- cost: z.number(),
- tokens: z.object({
- total: z.number().optional(),
- input: z.number(),
- output: z.number(),
- reasoning: z.number(),
- cache: z.object({
- read: z.number(),
- write: z.number(),
+ summary: Schema.optional(Schema.Boolean),
+ cost: Schema.Number,
+ tokens: Schema.Struct({
+ total: Schema.optional(Schema.Number),
+ input: Schema.Number,
+ output: Schema.Number,
+ reasoning: Schema.Number,
+ cache: Schema.Struct({
+ read: Schema.Number,
+ write: Schema.Number,
}),
}),
- structured: z.any().optional(),
- variant: z.string().optional(),
- finish: z.string().optional(),
-}).meta({
- ref: "AssistantMessage",
+ structured: Schema.optional(Schema.Any),
+ variant: Schema.optional(Schema.String),
+ finish: Schema.optional(Schema.String),
})
-export type Assistant = z.infer
+ .annotate({ identifier: "AssistantMessage" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Assistant = Omit>, "error"> & {
+ error?: AssistantError
+}
-export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({
- ref: "Message",
+const _Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
+export const Info = Object.assign(_Info, {
+ zod: zod(_Info) as unknown as z.ZodType,
})
-export type Info = z.infer
+export type Info = User | Assistant
export const Event = {
Updated: SyncEvent.define({
@@ -472,7 +594,7 @@ export const Event = {
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
- info: Info,
+ info: Info.zod,
}),
}),
Removed: SyncEvent.define({
@@ -490,7 +612,7 @@ export const Event = {
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
- part: Part,
+ part: Part.zod,
time: z.number(),
}),
}),
@@ -516,11 +638,31 @@ export const Event = {
}),
}
-export const WithParts = z.object({
- info: Info,
- parts: z.array(Part),
+export const WithParts = Schema.Struct({
+ info: _Info,
+ parts: Schema.Array(_Part),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type WithParts = {
+ info: Info
+ parts: Part[]
+}
+
+const Cursor = Schema.Struct({
+ id: MessageID,
+ time: Schema.Number,
})
-export type WithParts = z.infer
+type Cursor = typeof Cursor.Type
+
+const decodeCursor = Schema.decodeUnknownSync(Cursor)
+
+export const cursor = {
+ encode(input: Cursor) {
+ return Buffer.from(JSON.stringify(input)).toString("base64url")
+ },
+ decode(input: string) {
+ return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
+ },
+}
// kilocode_change start - strip bloated metadata fields from stored parts to prevent multi-MB payloads
// This handles both legacy data that was stored with full file contents and keeps the API response lean.
@@ -577,21 +719,6 @@ export function stripMessageMetadata(info: Info): Info {
}
// kilocode_change end
-const Cursor = z.object({
- id: MessageID.zod,
- time: z.number(),
-})
-type Cursor = z.infer