Compare commits

..

1 Commits

Author SHA1 Message Date
Adam
871a0e11b9 fix(app): more startup perf 2026-03-25 11:28:59 -05:00
211 changed files with 4120 additions and 7912 deletions

1
.github/VOUCHED.td vendored
View File

@@ -25,4 +25,3 @@ r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-OpenCodeEngineer bot that spams issues

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -79,7 +79,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -113,7 +113,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -140,7 +140,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -164,7 +164,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -188,7 +188,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -221,7 +221,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -252,7 +252,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -281,7 +281,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -297,7 +297,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.3",
"version": "1.3.2",
"bin": {
"opencode": "./bin/opencode",
},
@@ -329,7 +329,7 @@
"@effect/platform-node": "catalog:",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -358,7 +358,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.3",
"gitlab-ai-provider": "5.3.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -422,7 +422,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -446,7 +446,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.3",
"version": "1.3.2",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -457,7 +457,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -492,7 +492,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -538,7 +538,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"zod": "catalog:",
},
@@ -549,7 +549,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.3",
"version": "1.3.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1325,7 +1325,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -2889,7 +2889,7 @@
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
@@ -3037,7 +3037,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5129,8 +5129,6 @@
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
@@ -6313,8 +6311,6 @@
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],

View File

@@ -496,6 +496,7 @@ async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
todoread: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-0VwVhbOtK1r16cVSZcHaI/8fUPc6aYQiUnh7Q3bSHqs=",
"aarch64-linux": "sha256-z5b234MIS0QqDYLopyaT2hd9CAtEbcSo28y0eMfPsBs=",
"aarch64-darwin": "sha256-sn16mtZIhF9OSBrfAHpDCJO6Nt19mdoxvYAOnwWgwDk=",
"x86_64-darwin": "sha256-FaZpwGuWzfypA28ct86xAnW2RuFFUiXjPkr5wVTLN/o="
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
}
}

View File

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

View File

@@ -133,7 +133,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return (
<MetaProvider>
<Font />
<Font preloadMono={false} />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })

View File

@@ -17,6 +17,7 @@ import {
type JSXElement,
type ParentProps,
} from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
@@ -201,6 +202,7 @@ export default function FileTree(props: {
modified?: readonly string[]
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
synthetic?: boolean
onFileClick?: (file: FileNode) => void
_filter?: Filter
@@ -208,10 +210,15 @@ export default function FileTree(props: {
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
_open?: Record<string, boolean>
_setOpen?: SetStoreFunction<Record<string, boolean>>
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const local = createStore<Record<string, boolean>>({})
const open = props._open ?? local[0]
const setOpen = props._setOpen ?? local[1]
const key = (p: string) =>
file
@@ -258,6 +265,7 @@ export default function FileTree(props: {
const deeps = createMemo(() => {
if (props._deeps) return props._deeps
if (props.synthetic) return new Map<string, number>()
const out = new Map<string, number>()
@@ -304,6 +312,7 @@ export default function FileTree(props: {
})
createEffect(() => {
if (props.synthetic) return
const current = filter()
const dirs = dirsToExpand({
level,
@@ -317,6 +326,7 @@ export default function FileTree(props: {
on(
() => props.path,
(path) => {
if (props.synthetic) return
const dir = untrack(() => file.tree.state(path))
if (!shouldListRoot({ level, dir })) return
void file.tree.list(path)
@@ -388,7 +398,8 @@ export default function FileTree(props: {
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const expanded = () =>
props.synthetic ? (open[node.path] ?? true) : (file.tree.state(node.path)?.expanded ?? false)
const deep = () => deeps().get(node.path) ?? -1
const kind = () => visibleKind(node, kinds(), marks())
const active = () => !!kind() && !node.ignored
@@ -402,7 +413,13 @@ export default function FileTree(props: {
data-scope="filetree"
forceMount={false}
open={expanded()}
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
onOpenChange={(open) => {
if (props.synthetic) {
setOpen(node.path, open)
return
}
open ? file.tree.expand(node.path) : file.tree.collapse(node.path)
}}
>
<Collapsible.Trigger>
<FileTreeNode
@@ -435,6 +452,7 @@ export default function FileTree(props: {
<FileTree
path={node.path}
level={level + 1}
synthetic={props.synthetic}
allowed={props.allowed}
modified={props.modified}
kinds={props.kinds}
@@ -446,6 +464,8 @@ export default function FileTree(props: {
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
_open={open}
_setOpen={setOpen}
/>
</Show>
</Collapsible.Content>

View File

@@ -27,7 +27,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
@@ -1494,7 +1493,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) =>
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />),
)
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon

View File

@@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { cmp, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
type GlobalStore = {
@@ -174,7 +174,7 @@ export async function bootstrapDirectory(input: {
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
retry(() =>
@@ -243,12 +243,6 @@ export async function bootstrapDirectory(input: {
]
const slow = [
() =>
retry(() =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),

View File

@@ -56,6 +56,22 @@ function cleanupSessionCaches(
)
}
function keep(next: Session, prev?: Session) {
const diffs = prev?.summary?.diffs
const files = prev?.summary?.files
if (!diffs?.length) return next
if (!next.summary || next.summary.diffs?.length) return next
if (next.summary.files <= 0) return next
if (next.summary.files !== files) return next
return {
...next,
summary: {
...next.summary,
diffs,
},
}
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
@@ -105,7 +121,7 @@ export function applyDirectoryEvent(input: {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
input.setStore("session", result.index, reconcile(keep(info, input.store.session[result.index])))
break
}
const next = input.store.session.slice()
@@ -134,7 +150,7 @@ export function applyDirectoryEvent(input: {
break
}
if (result.found) {
input.setStore("session", result.index, reconcile(info))
input.setStore("session", result.index, reconcile(keep(info, input.store.session[result.index])))
break
}
const next = input.store.session.slice()

View File

@@ -1,35 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
name,
mode: "primary",
permission: {},
options: {},
}) as Agent
describe("normalizeAgentList", () => {
test("keeps array payloads", () => {
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
})
test("wraps a single agent payload", () => {
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
})
test("extracts agents from keyed objects", () => {
expect(
normalizeAgentList({
build: agent("build"),
docs: agent("docs"),
}),
).toEqual([agent("build"), agent("docs")])
})
test("drops invalid payloads", () => {
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})

View File

@@ -1,21 +1,7 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function isAgent(input: unknown): input is Agent {
if (!input || typeof input !== "object") return false
const item = input as { name?: unknown; mode?: unknown }
if (typeof item.name !== "string") return false
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
}
export function normalizeAgentList(input: unknown): Agent[] {
if (Array.isArray(input)) return input.filter(isAgent)
if (isAgent(input)) return [input]
if (!input || typeof input !== "object") return []
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,

View File

@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
@@ -120,7 +120,16 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
if (typeof document === "undefined") return
const id = store.appearance?.font ?? defaultSettings.appearance.font
if (id !== defaultSettings.appearance.font) {
void loadFont().then((x) => x.ensureMonoFont(id))
const run = () => {
void loadFont().then((x) => x.ensureMonoFont(id))
}
if (typeof requestIdleCallback === "function") {
const idle = requestIdleCallback(run, { timeout: 2000 })
onCleanup(() => cancelIdleCallback(idle))
} else {
const timeout = window.setTimeout(run, 2000)
onCleanup(() => window.clearTimeout(timeout))
}
}
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
})

View File

@@ -180,8 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const initialMessagePageSize = 40
const historyMessagePageSize = 80
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -460,13 +460,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const hit = Binary.search(store.session, sessionID, (s) => s.id)
const session = hit.found ? store.session[hit.index] : undefined
const hasSession = hit.found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const needs = !!session?.summary?.files && !session.summary?.diffs
if (cached && hasSession && !opts?.force && !needs) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
hasSession && !opts?.force && !needs
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return

View File

@@ -21,7 +21,7 @@ export function useProviders() {
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
const [projectStore] = globalSync.peek(dir(), { bootstrap: false })
if (projectStore.provider.all.length > 0) return projectStore.provider
}
return globalSync.data.provider

View File

@@ -722,6 +722,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
"settings.permissions.tool.todoread.title": "قراءة المهام",
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
"settings.permissions.tool.todowrite.title": "كتابة المهام",
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
"settings.permissions.tool.webfetch.title": "جلب الويب",

View File

@@ -732,6 +732,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
"settings.permissions.tool.todoread.title": "Ler Tarefas",
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
"settings.permissions.tool.webfetch.title": "Buscar Web",

View File

@@ -806,6 +806,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
"settings.permissions.tool.webfetch.title": "Web preuzimanje",

View File

@@ -800,6 +800,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
"settings.permissions.tool.todoread.title": "Læs To-do",
"settings.permissions.tool.todoread.description": "Læs to-do listen",
"settings.permissions.tool.todowrite.title": "Skriv To-do",
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
"settings.permissions.tool.webfetch.title": "Webhentning",

View File

@@ -743,6 +743,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
"settings.permissions.tool.todoread.title": "Todo lesen",
"settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
"settings.permissions.tool.todowrite.title": "Todo schreiben",
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
"settings.permissions.tool.webfetch.title": "Web-Abruf",

View File

@@ -900,6 +900,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Load a skill by name",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Run language server queries",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.todoread.description": "Read the todo list",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Update the todo list",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -813,6 +813,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
"settings.permissions.tool.todoread.title": "Leer Todo",
"settings.permissions.tool.todoread.description": "Leer la lista de tareas",
"settings.permissions.tool.todowrite.title": "Escribir Todo",
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -741,6 +741,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
"settings.permissions.tool.todoread.title": "Lire Todo",
"settings.permissions.tool.todoread.description": "Lire la liste de tâches",
"settings.permissions.tool.todowrite.title": "Écrire Todo",
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
"settings.permissions.tool.webfetch.title": "Récupération Web",

View File

@@ -727,6 +727,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
"settings.permissions.tool.todoread.title": "Todo読み込み",
"settings.permissions.tool.todoread.description": "Todoリストの読み込み",
"settings.permissions.tool.todowrite.title": "Todo書き込み",
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
"settings.permissions.tool.webfetch.title": "Web取得",

View File

@@ -726,6 +726,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
"settings.permissions.tool.todoread.title": "할 일 읽기",
"settings.permissions.tool.todoread.description": "할 일 목록 읽기",
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
"settings.permissions.tool.webfetch.title": "웹 가져오기",

View File

@@ -807,6 +807,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
"settings.permissions.tool.todoread.title": "Les gjøremål",
"settings.permissions.tool.todoread.description": "Les gjøremålslisten",
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
"settings.permissions.tool.webfetch.title": "Webhenting",

View File

@@ -729,6 +729,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
"settings.permissions.tool.todoread.title": "Odczyt Todo",
"settings.permissions.tool.todoread.description": "Odczyt listy zadań",
"settings.permissions.tool.todowrite.title": "Zapis Todo",
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",

View File

@@ -808,6 +808,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.todoread.description": "Чтение списка задач",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -796,6 +796,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
"settings.permissions.tool.todoread.title": "อ่านรายการงาน",
"settings.permissions.tool.todoread.description": "อ่านรายการงาน",
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",

View File

@@ -816,6 +816,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
"settings.permissions.tool.todoread.title": "Görev Oku",
"settings.permissions.tool.todoread.description": "Görev listesini oku",
"settings.permissions.tool.todowrite.title": "Görev Yaz",
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
"settings.permissions.tool.webfetch.title": "Web Getir",

View File

@@ -795,6 +795,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名称加载技能",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
"settings.permissions.tool.todoread.title": "读取待办",
"settings.permissions.tool.todoread.description": "读取待办列表",
"settings.permissions.tool.todowrite.title": "更新待办",
"settings.permissions.tool.todowrite.description": "更新待办列表",
"settings.permissions.tool.webfetch.title": "网页获取",

View File

@@ -790,6 +790,8 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名稱載入技能",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
"settings.permissions.tool.todoread.title": "讀取待辦",
"settings.permissions.tool.todoread.description": "讀取待辦清單",
"settings.permissions.tool.todowrite.title": "更新待辦",
"settings.permissions.tool.todowrite.description": "更新待辦清單",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -158,6 +158,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
preserveScroll(() => setTurnStart(nextStart))
}
const reveal = () => {
const start = turnStart()
if (start <= 0) return false
backfillTurns()
return true
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
@@ -303,6 +310,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return {
turnStart,
setTurnStart,
reveal,
renderedUserMessages,
loadAndReveal,
onScrollerScroll,
@@ -877,6 +885,7 @@ export default function Page() {
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsDiff = createMemo(() => (isDesktop() ? desktopReviewOpen() && activeTab() === "review" : mobileChanges()))
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -1074,6 +1083,7 @@ export default function Page() {
}
const focusReviewDiff = (path: string) => {
void tabs().open("review")
openReviewPanel()
view().review.openPath(path)
setTree({ activeDiff: path, pendingDiff: path })
@@ -1124,10 +1134,7 @@ export default function Page() {
const id = params.id
if (!id) return
const wants = isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (!wantsDiff()) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
@@ -1136,13 +1143,7 @@ export default function Page() {
createEffect(
on(
() =>
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
() => [sessionKey(), wantsDiff()] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1167,19 +1168,6 @@ export default function Page() {
),
)
let treeDir: string | undefined
createEffect(() => {
const dir = sdk.directory
if (!isDesktop()) return
if (!layout.fileTree.opened()) return
if (sync.status === "loading") return
fileTreeTab()
const refresh = treeDir !== dir
treeDir = dir
void (refresh ? file.tree.refresh("") : file.tree.list(""))
})
createEffect(
on(
() => sdk.directory,
@@ -1296,9 +1284,9 @@ export default function Page() {
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return
if (historyWindow.turnStart() <= 0) return
void historyWindow.loadAndReveal()
historyWindow.reveal()
})
}
@@ -1309,14 +1297,13 @@ export default function Page() {
params.id,
messagesReady(),
historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, start, more, loading, scrolled]) => {
([id, ready, start, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return
if (start <= 0) return
fill()
},
{ defer: true },

View File

@@ -49,7 +49,6 @@ export const createSessionTabs = (input: TabsInput) => {
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (review() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo(() => {

View File

@@ -13,7 +13,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
@@ -56,14 +55,15 @@ export function SessionSidePanel(props: {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const changes = createMemo(() => {
const id = params.id
if (!id) return []
const full = sync.data.session_diff[id]
if (full !== undefined) return full
return info()?.summary?.diffs ?? []
})
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
@@ -71,7 +71,7 @@ export function SessionSidePanel(props: {
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const diffFiles = createMemo(() => changes().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -82,7 +82,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
for (const diff of changes()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -293,9 +293,11 @@ export function SessionSidePanel(props: {
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() =>
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}
onClick={() => {
void import("@/components/dialog-select-file").then((x) =>
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />),
)
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
@@ -386,26 +388,17 @@ export function SessionSidePanel(props: {
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
<Match when={hasReview() && diffFiles().length > 0}>
<FileTree
synthetic
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Match>
<Match when={true}>
{empty(

View File

@@ -11,10 +11,6 @@ import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -257,7 +253,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
onSelect: () => {
void import("@/components/dialog-select-file").then((x) =>
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
)
},
}),
fileCommand({
id: "tab.close",
@@ -351,7 +351,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
onSelect: () => {
void import("@/components/dialog-select-model").then((x) =>
dialog.show(() => <x.DialogSelectModel model={local.model} />),
)
},
}),
mcpCommand({
id: "mcp.toggle",
@@ -359,7 +363,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
onSelect: () => {
void import("@/components/dialog-select-mcp").then((x) => dialog.show(() => <x.DialogSelectMcp />))
},
}),
agentCommand({
id: "agent.cycle",
@@ -487,7 +493,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
onSelect: () => {
void import("@/components/dialog-fork").then((x) => dialog.show(() => <x.DialogFork />))
},
}),
...share,
]

View File

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

View File

@@ -132,7 +132,7 @@ export async function handler(
retry,
stickyProvider,
)
validateModelSettings(billingSource, authInfo)
validateModelSettings(authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({ provider: providerInfo.id })
@@ -768,10 +768,9 @@ export async function handler(
return "balance"
}
function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) {
if (billingSource === "lite") return
if (billingSource === "anonymous") return
if (authInfo!.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
function validateModelSettings(authInfo: AuthInfo) {
if (!authInfo) return
if (authInfo.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
}
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.3",
"version": "1.3.2",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -14,7 +14,6 @@ import { KeyTable } from "../src/schema/key.sql.js"
import { BlackData } from "../src/black.js"
import { centsToMicroCents } from "../src/util/price.js"
import { getWeekBounds } from "../src/util/date.js"
import { ModelTable } from "../src/schema/model.sql.js"
// get input from command line
const identifier = process.argv[2]
@@ -179,8 +178,9 @@ async function printWorkspace(workspaceID: string) {
balance: `$${(row.balance / 100000000).toFixed(2)}`,
reload: row.reload ? "yes" : "no",
customerID: row.customerID,
GO: row.liteSubscriptionID,
Black: row.blackSubscriptionID
liteSubscriptionID: row.liteSubscriptionID,
blackSubscriptionID: row.blackSubscriptionID,
blackSubscription: row.blackSubscriptionID
? [
`Black ${row.blackSubscription.enrichment!.plan}`,
row.blackSubscription.enrichment!.seats > 1
@@ -223,50 +223,6 @@ async function printWorkspace(workspaceID: string) {
),
)
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
/*
await printTable("Usage", (tx) =>
tx
@@ -292,22 +248,6 @@ async function printWorkspace(workspaceID: string) {
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
*/
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.3",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,14 +31,12 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
## Runtime vs InstanceState
## Runtime vs Instances
- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
- If two open directories should not share one copy of the service, it needs `InstanceState`.
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
- If two open directories should not share one copy of the service, it belongs in `Instances`.
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
## Preferred Effect services
@@ -53,7 +51,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.

View File

@@ -1,13 +0,0 @@
CREATE TABLE `event_sequence` (
`aggregate_id` text PRIMARY KEY,
`seq` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `event` (
`id` text PRIMARY KEY,
`aggregate_id` text NOT NULL,
`seq` integer NOT NULL,
`type` text NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.3",
"version": "1.3.2",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -92,7 +92,7 @@
"@effect/platform-node": "catalog:",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -121,7 +121,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.3",
"gitlab-ai-provider": "5.3.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -133,9 +133,9 @@
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",

View File

@@ -63,26 +63,6 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const createEmbeddedWebUIBundle = async()=>{
console.log(`Building Web UI to embed in the binary`);
const appDir = path.join(import.meta.dirname, "../../app")
await $`bun run --cwd ${appDir} build`;
const allFiles = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: path.join(appDir, "dist")}));
const fileMap = `
// Import all files as file_$i with type: "file"
${allFiles.map((filePath, i) => `import file_${i} from "${path.join(appDir, "dist", filePath)}" with { type: "file" };`).join("\n")}
// Export with original mappings
export default {
${allFiles.map((filePath, i)=>`"${filePath}": file_${i},`).join("\n")}
}
`.trim()
return fileMap;
}
const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle();
const allTargets: {
os: string
@@ -212,10 +192,7 @@ for (const item of targets) {
execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
files: {
...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}),
},
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
entrypoints: ["./src/index.ts", parserWorker, workerPath],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MIGRATIONS: JSON.stringify(migrations),

View File

@@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
- Global services (no per-directory state): Account, Auth, Installation, Truncate
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
@@ -46,7 +46,7 @@ export namespace Foo {
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
@@ -79,24 +79,22 @@ See `Auth.ZodInfo` for the canonical example.
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
```ts
const bus = yield * Bus.Service
const cache =
yield *
InstanceState.make<State>(
Effect.fn("Foo.state")(function* (ctx) {
// ... load state ...
yield* bus.subscribeAll().pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribeAll((event) => {
/* handle */
}),
),
Effect.forkScoped,
(unsub) => Effect.sync(unsub),
)
return {
@@ -106,16 +104,6 @@ const cache =
)
```
- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
```ts
yield *
Effect.acquireRelease(
Effect.sync(() => nativeAddon.watch(dir)),
(watcher) => Effect.sync(() => watcher.close()),
)
```
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
@@ -177,7 +165,7 @@ Still open and likely worth migrating:
- [x] `ToolRegistry`
- [ ] `Pty`
- [x] `Worktree`
- [x] `Bus`
- [ ] `Bus`
- [x] `Command`
- [ ] `Config`
- [ ] `Session`
@@ -187,4 +175,4 @@ Still open and likely worth migrating:
- [ ] `Provider`
- [x] `Project`
- [ ] `LSP`
- [x] `MCP`
- [ ] `MCP`

View File

@@ -1,7 +1,7 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
@@ -379,7 +379,7 @@ export namespace Account {
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const { runPromise } = makeRuntime(Service, defaultLayer)
export const runPromise = makeRunPromise(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))

View File

@@ -8,7 +8,6 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f
export type AccountRow = (typeof AccountTable)["$inferSelect"]
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
const ACCOUNT_STATE_ID = 1
@@ -43,13 +42,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: DbTransactionCallback<A>) =>
const query = <A>(f: (db: DbClient) => A) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const tx = <A>(f: DbTransactionCallback<A>) =>
const tx = <A>(f: (db: DbClient) => A) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),

View File

@@ -21,7 +21,7 @@ import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
export namespace Agent {
export const Info = z
@@ -148,6 +148,7 @@ export namespace Agent {
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
@@ -393,7 +394,7 @@ export namespace Agent {
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function get(agent: string) {
return runPromise((svc) => svc.get(agent))

View File

@@ -1,6 +1,6 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
@@ -95,7 +95,7 @@ export namespace Auth {
}),
)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))

View File

@@ -1,7 +1,10 @@
import z from "zod"
import type { ZodType } from "zod"
import { Log } from "../util/log"
export namespace BusEvent {
const log = Log.create({ service: "event" })
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>()

View File

@@ -1,14 +1,12 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
@@ -17,168 +15,91 @@ export namespace Bus {
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
const state = Instance.state(
() => {
const subscriptions = new Map<any, Subscription[]>()
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cache = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
return {
subscriptions,
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = state.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(state.wildcard, payload)
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
})
},
async (entry) => {
const wildcard = entry.subscriptions.get("*")
if (!wildcard) return
const event = {
type: InstanceDisposed.type,
properties: {
directory: Instance.directory,
},
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
const ps = yield* getOrCreate(state, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
for (const sub of [...wildcard]) {
sub(event)
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
return Stream.fromPubSub(state.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
Effect.runFork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const state = yield* InstanceState.get(cache)
const ps = yield* getOrCreate(state, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const state = yield* InstanceState.get(cache)
return yield* on(state.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
},
)
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
const payload = {
type: def.type,
properties,
}
log.info("publishing", {
type: def.type,
})
const pending = []
for (const key of [def.type, "*"]) {
const match = [...(state().subscriptions.get(key) ?? [])]
for (const sub of match) {
pending.push(sub(payload))
}
}
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
export function subscribe<Definition extends BusEvent.Definition>(
def: Definition,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
}
export function once<Definition extends BusEvent.Definition>(
def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => "done" | undefined,
) {
const unsub = subscribe(def, (event) => {
if (callback(event)) unsub()
})
}
export function subscribeAll(callback: (event: any) => void) {
return raw("*", callback)
}
function raw(type: string, callback: (event: any) => void) {
log.info("subscribing", { type })
const subscriptions = state().subscriptions
let match = subscriptions.get(type) ?? []
match.push(callback)
subscriptions.set(type, match)
return () => {
log.info("unsubscribing", { type })
const match = subscriptions.get(type)
if (!match) return
const index = match.indexOf(callback)
if (index === -1) return
match.splice(index, 1)
}
}
}

View File

@@ -14,7 +14,19 @@ import type { Argv } from "yargs"
type AgentMode = "all" | "primary" | "subagent"
const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
const AVAILABLE_TOOLS = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const AgentCreateCommand = cmd({
command: "create",

View File

@@ -869,6 +869,7 @@ export const GithubRunCommand = cmd({
function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
@@ -889,7 +890,7 @@ export const GithubRunCommand = cmd({
}
let text = ""
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.part.sessionID !== session.id) return
//if (evt.properties.part.messageID === messageID) return
const part = evt.properties.part

View File

@@ -186,7 +186,7 @@ export function tui(input: {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: { events: process.platform === "win32" },
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
@@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
})
sdk.event.on("session.deleted", (evt) => {
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
@@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
})
sdk.event.on("session.error", (evt) => {
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {

View File

@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
@@ -356,20 +356,6 @@ export function Prompt(props: PromptProps) {
]
})
// Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are
// enabled, but still reports the kitty key-release event. Probe on release.
if (process.platform === "win32") {
useKeyboard(
(evt) => {
if (!input.focused) return
if (evt.name === "v" && evt.ctrl && evt.eventType === "release") {
command.trigger("prompt.paste")
}
},
{ release: true },
)
}
const ref: PromptRef = {
get focused() {
return input.focused
@@ -864,9 +850,10 @@ export function Prompt(props: PromptProps) {
e.preventDefault()
return
}
// Check clipboard for images before terminal-handled paste runs.
// This helps terminals that forward Ctrl+V to the app; Windows
// Terminal 1.25+ usually handles Ctrl+V before this path.
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
// This is needed because Windows terminal doesn't properly send image data
// through bracketed paste, so we need to intercept the keypress and
// directly read from clipboard before the terminal handles it
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
@@ -949,9 +936,6 @@ export function Prompt(props: PromptProps) {
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
command.trigger("prompt.paste")
return

View File

@@ -28,14 +28,6 @@ export namespace Clipboard {
mime: string
}
// Checks clipboard for images first, then falls back to text.
//
// On Windows prompt/ can call this from multiple paste signals because
// terminals surface image paste differently:
// 1. A forwarded Ctrl+V keypress
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
// Terminal <1.25
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
export async function read(): Promise<Content | undefined> {
const os = platform()
@@ -66,8 +58,6 @@ export namespace Clipboard {
}
}
// Windows/WSL: probe clipboard for images via PowerShell.
// Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"

View File

@@ -6,14 +6,11 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import type { Event } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceID } from "@/control-plane/schema"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -53,55 +50,39 @@ const startEventStream = (input: { directory: string; workspaceID?: string }) =>
eventStream.abort = abort
const signal = abort.signal
const workspaceID = input.workspaceID ? WorkspaceID.make(input.workspaceID) : undefined
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const auth = getAuthorizationHeader()
if (auth) request.headers.set("Authorization", auth)
return Server.Default().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
directory: input.directory,
experimental_workspaceID: input.workspaceID,
fetch: fetchFn,
signal,
})
;(async () => {
while (!signal.aborted) {
const shouldReconnect = await WorkspaceContext.provide({
workspaceID,
fn: () =>
Instance.provide({
directory: input.directory,
init: InstanceBootstrap,
fn: () =>
new Promise<boolean>((resolve) => {
Rpc.emit("event", {
type: "server.connected",
properties: {},
} satisfies Event)
const events = await Promise.resolve(
sdk.event.subscribe(
{},
{
signal,
},
),
).catch(() => undefined)
let settled = false
const settle = (value: boolean) => {
if (settled) return
settled = true
signal.removeEventListener("abort", onAbort)
unsub()
resolve(value)
}
if (!events) {
await sleep(250)
continue
}
const unsub = Bus.subscribeAll((event) => {
Rpc.emit("event", event as Event)
if (event.type === Bus.InstanceDisposed.type) {
settle(true)
}
})
const onAbort = () => {
settle(false)
}
signal.addEventListener("abort", onAbort, { once: true })
}),
}),
}).catch((error) => {
Log.Default.error("event stream subscribe error", {
error: error instanceof Error ? error.message : error,
})
return false
})
if (!shouldReconnect || signal.aborted) {
break
for await (const event of events.stream) {
Rpc.emit("event", event as Event)
}
if (!signal.aborted) {

View File

@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod"
@@ -173,7 +173,7 @@ export namespace Command {
}),
)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export async function get(name: string) {
return runPromise((svc) => svc.get(name))

View File

@@ -673,6 +673,7 @@ export namespace Config {
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
question: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),

View File

@@ -0,0 +1,14 @@
import { ServiceMap } from "effect"
import type { Project } from "@/project/project"
export declare namespace InstanceContext {
export interface Shape {
readonly directory: string
readonly worktree: string
readonly project: Project.Info
}
}
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
"opencode/InstanceContext",
) {}

View File

@@ -3,15 +3,11 @@ import * as ServiceMap from "effect/ServiceMap"
export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromise(service.use(fn), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
rt ??= ManagedRuntime.make(layer, { memoMap })
return rt.runPromise(service.use(fn), options)
}
}

View File

@@ -1,8 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
@@ -323,6 +323,7 @@ export namespace File {
interface State {
cache: Entry
fiber: Fiber.Fiber<void> | undefined
}
export interface Interface {
@@ -347,6 +348,7 @@ export namespace File {
Effect.fn("File.state")(() =>
Effect.succeed({
cache: { files: [], dirs: [] } as Entry,
fiber: undefined as Fiber.Fiber<void> | undefined,
}),
),
)
@@ -404,11 +406,21 @@ export namespace File {
s.cache = next
})
let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
const scope = yield* Scope.Scope
const ensure = Effect.fn("File.ensure")(function* () {
yield* cachedScan
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
const s = yield* InstanceState.get(state)
if (!s.fiber)
s.fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.ensuring(
Effect.sync(() => {
s.fiber = undefined
}),
),
Effect.forkIn(scope),
)
yield* Fiber.join(s.fiber)
})
const init = Effect.fn("File.init")(function* () {
@@ -676,7 +688,7 @@ export namespace File {
}),
)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())

View File

@@ -1,6 +1,6 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
@@ -108,7 +108,7 @@ export namespace FileTime {
}),
).pipe(Layer.orDie)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file))

View File

@@ -8,7 +8,7 @@ import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance"
import { git } from "@/util/git"
@@ -159,7 +159,7 @@ export namespace FileWatcher {
}),
)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())

View File

@@ -70,7 +70,6 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")

View File

@@ -1,10 +1,12 @@
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
@@ -27,7 +29,6 @@ export namespace Format {
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly file: (filepath: string) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
@@ -96,46 +97,53 @@ export namespace Format {
return checks.filter((x) => x.enabled).map((x) => x.item)
}
async function formatFile(filepath: string) {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", filepath)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file: filepath,
})
}
}
}
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribe(
File.Event.Edited,
Instance.bind(async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init")
return {
formatters,
isEnabled,
formatFile,
}
}),
)
@@ -158,16 +166,11 @@ export namespace Format {
return result
})
const file = Effect.fn("Format.file")(function* (filepath: string) {
const { formatFile } = yield* InstanceState.get(state)
yield* Effect.promise(() => formatFile(filepath))
})
return Service.of({ init, status, file })
return Service.of({ init, status })
}),
)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export async function init() {
return runPromise((s) => s.init())
@@ -176,8 +179,4 @@ export namespace Format {
export async function status() {
return runPromise((s) => s.status())
}
export async function file(filepath: string) {
return runPromise((s) => s.file(filepath))
}
}

View File

@@ -3,7 +3,6 @@ import { randomBytes } from "crypto"
export namespace Identifier {
const prefixes = {
event: "evt",
session: "ses",
message: "msg",
permission: "per",

View File

@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
@@ -346,7 +346,7 @@ export namespace Installation {
Layer.provide(NodePath.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function info(): Promise<Info> {
return runPromise((svc) => svc.info())

View File

@@ -1,9 +1,7 @@
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Effect, Layer, ServiceMap } from "effect"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
import { Filesystem } from "../util/filesystem"
export namespace McpAuth {
export const Tokens = z.object({
@@ -27,155 +25,106 @@ export namespace McpAuth {
clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(),
oauthState: z.string().optional(),
serverUrl: z.string().optional(),
serverUrl: z.string().optional(), // Track the URL these credentials are for
})
export type Entry = z.infer<typeof Entry>
const filepath = path.join(Global.Path.data, "mcp-auth.json")
export interface Interface {
readonly all: () => Effect.Effect<Record<string, Entry>>
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
readonly remove: (mcpName: string) => Effect.Effect<void>
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
export async function get(mcpName: string): Promise<Entry | undefined> {
const data = await all()
return data[mcpName]
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
/**
* Get auth entry and validate it's for the correct URL.
* Returns undefined if URL has changed (credentials are invalid).
*/
export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
const entry = await get(mcpName)
if (!entry) return undefined
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
// If no serverUrl is stored, this is from an old version - consider it invalid
if (!entry.serverUrl) return undefined
const all = Effect.fn("McpAuth.all")(function* () {
return yield* fs.readJson(filepath).pipe(
Effect.map((data) => data as Record<string, Entry>),
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
)
})
// If URL has changed, credentials are invalid
if (entry.serverUrl !== serverUrl) return undefined
const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
const data = yield* all()
return data[mcpName]
})
return entry
}
const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
const entry = yield* get(mcpName)
if (!entry) return undefined
if (!entry.serverUrl) return undefined
if (entry.serverUrl !== serverUrl) return undefined
return entry
})
export async function all(): Promise<Record<string, Entry>> {
return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
}
const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
const data = yield* all()
if (serverUrl) entry.serverUrl = serverUrl
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
})
export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
const data = await all()
// Always update serverUrl if provided
if (serverUrl) {
entry.serverUrl = serverUrl
}
await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
}
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
const data = yield* all()
delete data[mcpName]
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
})
export async function remove(mcpName: string): Promise<void> {
const data = await all()
delete data[mcpName]
await Filesystem.writeJson(filepath, data, 0o600)
}
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
const entry = (yield* get(mcpName)) ?? {}
entry[field] = value
yield* set(mcpName, entry, serverUrl)
})
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.tokens = tokens
await set(mcpName, entry, serverUrl)
}
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
const entry = yield* get(mcpName)
if (entry) {
delete entry[field]
yield* set(mcpName, entry)
}
})
export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.clientInfo = clientInfo
await set(mcpName, entry, serverUrl)
}
const updateTokens = updateField("tokens", "updateTokens")
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
const updateOAuthState = updateField("oauthState", "updateOAuthState")
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
const clearOAuthState = clearField("oauthState", "clearOAuthState")
export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.codeVerifier = codeVerifier
await set(mcpName, entry)
}
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
const entry = yield* get(mcpName)
return entry?.oauthState
})
export async function clearCodeVerifier(mcpName: string): Promise<void> {
const entry = await get(mcpName)
if (entry) {
delete entry.codeVerifier
await set(mcpName, entry)
}
}
const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
const entry = yield* get(mcpName)
if (!entry?.tokens) return null
if (!entry.tokens.expiresAt) return false
return entry.tokens.expiresAt < Date.now() / 1000
})
export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.oauthState = oauthState
await set(mcpName, entry)
}
return Service.of({
all,
get,
getForUrl,
set,
remove,
updateTokens,
updateClientInfo,
updateCodeVerifier,
clearCodeVerifier,
updateOAuthState,
getOAuthState,
clearOAuthState,
isTokenExpired,
})
}),
)
export async function getOAuthState(mcpName: string): Promise<string | undefined> {
const entry = await get(mcpName)
return entry?.oauthState
}
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export async function clearOAuthState(mcpName: string): Promise<void> {
const entry = await get(mcpName)
if (entry) {
delete entry.oauthState
await set(mcpName, entry)
}
}
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facades for backward compat (used by McpOAuthProvider, CLI)
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
export const getForUrl = async (mcpName: string, serverUrl: string) =>
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
export const all = async () => runPromise((svc) => svc.all())
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
/**
* Check if stored tokens are expired.
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
*/
export async function isTokenExpired(mcpName: string): Promise<boolean | null> {
const entry = await get(mcpName)
if (!entry?.tokens) return null
if (!entry.tokens.expiresAt) return false
return entry.tokens.expiresAt < Date.now() / 1000
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -54,9 +54,6 @@ interface PendingAuth {
export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined
const pendingAuths = new Map<string, PendingAuth>()
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// find the right entry in pendingAuths (which is keyed by oauthState).
const mcpNameToState = new Map<string, string>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
@@ -101,12 +98,6 @@ export namespace McpOAuthCallback {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
for (const [name, s] of mcpNameToState) {
if (s === state) {
mcpNameToState.delete(name)
break
}
}
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
@@ -135,13 +126,6 @@ export namespace McpOAuthCallback {
clearTimeout(pending.timeout)
pendingAuths.delete(state)
// Clean up reverse index
for (const [name, s] of mcpNameToState) {
if (s === state) {
mcpNameToState.delete(name)
break
}
}
pending.resolve(code)
return new Response(HTML_SUCCESS, {
@@ -153,13 +137,11 @@ export namespace McpOAuthCallback {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
export function waitForCallback(oauthState: string): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) {
pendingAuths.delete(oauthState)
if (mcpName) mcpNameToState.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long"))
}
}, CALLBACK_TIMEOUT_MS)
@@ -169,14 +151,10 @@ export namespace McpOAuthCallback {
}
export function cancelPending(mcpName: string): void {
// Look up the oauthState for this mcpName via the reverse index
const oauthState = mcpNameToState.get(mcpName)
const key = oauthState ?? mcpName
const pending = pendingAuths.get(key)
const pending = pendingAuths.get(mcpName)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(key)
mcpNameToState.delete(mcpName)
pendingAuths.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
}
}
@@ -206,7 +184,6 @@ export namespace McpOAuthCallback {
pending.reject(new Error("OAuth callback server stopped"))
}
pendingAuths.clear()
mcpNameToState.clear()
}
export function isRunning(): boolean {

View File

@@ -2,7 +2,7 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema"
@@ -306,7 +306,7 @@ export namespace Permission {
return result
}
export const { runPromise } = makeRuntime(Service, layer)
export const runPromise = makeRunPromise(Service, layer)
export async function ask(input: z.infer<typeof AskInput>) {
return runPromise((s) => s.ask(input))

View File

@@ -3,6 +3,7 @@ import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
@@ -11,9 +12,9 @@ import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -52,15 +53,11 @@ export namespace Plugin {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const cache = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = []
yield* Effect.promise(async () => {
const { Server } = await import("../server/server")
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: ctx.directory,
@@ -148,16 +145,16 @@ export namespace Plugin {
}
})
// Subscribe to bus events, fiber interrupted when scope closes
yield* bus.subscribeAll().pipe(
Stream.runForEach((input) =>
Effect.sync(() => {
// Subscribe to bus events, clean up when scope is closed
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribeAll(async (input) => {
for (const hook of hooks) {
hook["event"]?.({ event: input as any })
hook["event"]?.({ event: input })
}
}),
),
Effect.forkScoped,
(unsub) => Effect.sync(unsub),
)
return { hooks }
@@ -194,8 +191,7 @@ export namespace Plugin {
}),
)
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, layer)
export async function trigger<
Name extends TriggerName,

View File

@@ -11,7 +11,7 @@ import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -462,7 +462,7 @@ export namespace Project {
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)

View File

@@ -1,8 +1,8 @@
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git"
@@ -23,7 +23,7 @@ export namespace Vcs {
export const Info = z
.object({
branch: z.string().optional(),
branch: z.string(),
})
.meta({
ref: "VcsInfo",
@@ -41,10 +41,9 @@ export namespace Vcs {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) =>
Effect.gen(function* () {
@@ -52,7 +51,7 @@ export namespace Vcs {
return { current: undefined }
}
const get = async () => {
const getCurrentBranch = async () => {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: ctx.worktree,
})
@@ -62,23 +61,26 @@ export namespace Vcs {
}
const value = {
current: yield* Effect.promise(() => get()),
current: yield* Effect.promise(() => getCurrentBranch()),
}
log.info("initialized", { branch: value.current })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach(() =>
Effect.gen(function* () {
const next = yield* Effect.promise(() => get())
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next })
}
}),
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribe(
FileWatcher.Event.Updated,
Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return
const next = await getCurrentBranch()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
Bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
),
Effect.forkScoped,
(unsubscribe) => Effect.sync(unsubscribe),
)
return value
@@ -97,9 +99,7 @@ export namespace Vcs {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())

View File

@@ -2,7 +2,7 @@ import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
@@ -215,13 +215,12 @@ export namespace ProviderAuth {
}
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extra } = result
yield* auth.set(input.providerID, {
type: "oauth",
access,
refresh,
expires,
...extra,
access: result.access,
refresh: result.refresh,
expires: result.expires,
...(result.accountId ? { accountId: result.accountId } : {}),
})
}
})
@@ -232,7 +231,7 @@ export namespace ProviderAuth {
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function methods() {
return runPromise((svc) => svc.methods())

View File

@@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { Instance } from "@/project/instance"
import { type IPty } from "bun-pty"
import z from "zod"
@@ -361,7 +361,7 @@ export namespace Pty {
}),
)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export async function list() {
return runPromise((svc) => svc.list())

View File

@@ -2,7 +2,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
@@ -197,7 +197,7 @@ export namespace Question {
}),
)
const { runPromise } = makeRuntime(Service, layer)
const runPromise = makeRunPromise(Service, layer)
export async function ask(input: {
sessionID: SessionID

View File

@@ -1,28 +0,0 @@
import z from "zod"
import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session"
import { SessionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
export function initProjectors() {
SyncEvent.init({
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: id,
info: Session.fromRow(row),
}
}
return data
},
})
}
initProjectors()

View File

@@ -6,6 +6,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { lazy } from "../../util/lazy"
import { AsyncQueue } from "../../util/queue"
import { Instance } from "@/project/instance"
const log = Log.create({ service: "server" })
@@ -52,6 +53,13 @@ export const EventRoutes = lazy(() =>
)
}, 10_000)
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
const stop = () => {
if (done) return
done = true
@@ -61,13 +69,6 @@ export const EventRoutes = lazy(() =>
log.info("event disconnected")
}
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
stream.onAbort(stop)
try {

View File

@@ -1,9 +1,9 @@
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import z from "zod"
import { Bus } from "../../bus"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
@@ -17,56 +17,6 @@ const log = Log.create({ service: "server" })
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
unsub()
q.push(null)
log.info("global event disconnected")
}
const unsub = subscribe(q)
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
})
}
export const GlobalRoutes = lazy(() =>
new Hono()
.get(
@@ -120,58 +70,55 @@ export const GlobalRoutes = lazy(() =>
log.info("global event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
return streamEvents(c, (q) => {
async function handler(event: any) {
q.push(JSON.stringify(event))
}
GlobalBus.on("event", handler)
return () => GlobalBus.off("event", handler)
})
},
)
.get(
"/sync-event",
describeRoute({
summary: "Subscribe to global sync events",
description: "Get global sync events",
operationId: "global.sync-event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(
z
.object({
payload: SyncEvent.payloads(),
})
.meta({
ref: "SyncEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global sync event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamEvents(c, (q) => {
return SyncEvent.subscribeAll(({ def, event }) => {
// TODO: don't pass def, just pass the type (and it should
// be versioned)
q.push(
JSON.stringify({
payload: {
...event,
type: SyncEvent.versionedType(def.type, def.version),
},
}),
)
})
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
GlobalBus.off("event", handler)
q.push(null)
log.info("event disconnected")
}
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
})
},
)

View File

@@ -123,6 +123,15 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID
log.info("SEARCH", { url: c.req.url })
const session = await Session.get(sessionID)
if (session.summary?.files) {
const diffs = await SessionSummary.list(sessionID)
if (diffs.length > 0) {
session.summary = {
...session.summary,
diffs,
}
}
}
return c.json(session)
},
)
@@ -281,14 +290,14 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
let session = await Session.get(sessionID)
if (updates.title !== undefined) {
await Session.setTitle({ sessionID, title: updates.title })
session = await Session.setTitle({ sessionID, title: updates.title })
}
if (updates.time?.archived !== undefined) {
await Session.setArchived({ sessionID, time: updates.time.archived })
session = await Session.setArchived({ sessionID, time: updates.time.archived })
}
const session = await Session.get(sessionID)
return c.json(session)
},
)

View File

@@ -44,7 +44,6 @@ import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { initProjectors } from "./projectors"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -52,16 +51,60 @@ globalThis.AI_SDK_LOG_WARNINGS = false
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
initProjectors()
const api = (path: string) =>
path === "/agent" ||
path === "/command" ||
path === "/formatter" ||
path === "/log" ||
path === "/lsp" ||
path === "/path" ||
path === "/skill" ||
path === "/vcs" ||
path.startsWith("/auth/") ||
path.startsWith("/config") ||
path.startsWith("/experimental") ||
path.startsWith("/global") ||
path.startsWith("/mcp") ||
path.startsWith("/permission") ||
path.startsWith("/project") ||
path.startsWith("/provider") ||
path.startsWith("/question") ||
path.startsWith("/session")
const json = (value: string | null) => {
const type = value?.split(";")[0]?.trim()
return type === "application/json" || type?.endsWith("+json")
}
const gzip = (value?: string) => {
if (!value) return false
let star = false
for (const item of value.split(",")) {
const [name, ...params] = item.trim().toLowerCase().split(";")
const q = params.find((part) => part.trim().startsWith("q="))
const score = q ? Number(q.trim().slice(2)) : 1
const ok = !Number.isNaN(score) && score > 0
if (name === "gzip") return ok
if (name === "*") star = ok
}
return star
}
const vary = (headers: Headers, value: string) => {
const current = headers.get("Vary")
if (!current) {
headers.set("Vary", value)
return
}
if (current.split(",").some((item) => item.trim().toLowerCase() === value.toLowerCase())) return
headers.set("Vary", `${current}, ${value}`)
}
export namespace Server {
const log = Log.create({ service: "server" })
const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
export const Default = lazy(() => createApp({}))
@@ -113,6 +156,26 @@ export namespace Server {
timer.stop()
}
})
.use(async (c, next) => {
await next()
if (!api(c.req.path)) return
if (c.req.method === "HEAD") return
if (c.res.headers.has("Content-Encoding")) return
if (c.res.headers.has("Transfer-Encoding")) return
if (!json(c.res.headers.get("Content-Type"))) return
if (!gzip(c.req.header("Accept-Encoding"))) return
const size = Number(c.res.headers.get("Content-Length") ?? "")
if (Number.isFinite(size) && size > 0 && size < 1024) return
if (!c.res.body) return
c.res = new Response(c.res.body.pipeThrough(new CompressionStream("gzip")), c.res)
c.res.headers.delete("Content-Length")
c.res.headers.set("Content-Encoding", "gzip")
vary(c.res.headers, "Accept-Encoding")
})
.use(
cors({
origin(input) {
@@ -510,40 +573,24 @@ export namespace Server {
},
)
.all("/*", async (c) => {
const embeddedWebUI = await embeddedUIPromise
const path = c.req.path
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404)
const file = Bun.file(match)
if (await file.exists()) {
c.header("Content-Type", file.type)
if (file.type.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(await file.arrayBuffer())
} else {
return c.json({ error: "Not Found" }, 404)
}
} else {
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
}
}) as unknown as Hono
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
})
}
export async function openapi() {

View File

@@ -13,7 +13,6 @@ import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -61,11 +60,7 @@ export namespace SessionCompaction {
const config = await Config.get()
if (config.compaction?.prune === false) return
log.info("pruning")
const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
if (NotFoundError.isInstance(err)) return undefined
throw err
})
if (!msgs) return
const msgs = await Session.messages({ sessionID: input.sessionID })
let total = 0
let pruned = 0
const toPrune = []

View File

@@ -9,14 +9,12 @@ import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import { SyncEvent } from "../sync"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import type { SQL } from "../storage/db"
import { SessionTable } from "./session.sql"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { SessionPrompt } from "./prompt"
@@ -184,40 +182,24 @@ export namespace Session {
export type GlobalInfo = z.output<typeof GlobalInfo>
export const Event = {
Created: SyncEvent.define({
type: "session.created",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
Created: BusEvent.define(
"session.created",
z.object({
info: Info,
}),
}),
Updated: SyncEvent.define({
type: "session.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: updateSchema(Info).extend({
share: updateSchema(Info.shape.share.unwrap()).optional(),
time: updateSchema(Info.shape.time).optional(),
}),
}),
busSchema: z.object({
sessionID: SessionID.zod,
),
Updated: BusEvent.define(
"session.updated",
z.object({
info: Info,
}),
}),
Deleted: SyncEvent.define({
type: "session.deleted",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
),
Deleted: BusEvent.define(
"session.deleted",
z.object({
info: Info,
}),
}),
),
Diff: BusEvent.define(
"session.diff",
z.object({
@@ -298,8 +280,18 @@ export namespace Session {
)
export const touch = fn(SessionID.zod, async (sessionID) => {
const time = Date.now()
SyncEvent.run(Event.Updated, { sessionID, info: { time: { updated: time } } })
const now = Date.now()
Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_updated: now })
.where(eq(SessionTable.id, sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
})
})
export async function createNext(input: {
@@ -326,25 +318,22 @@ export namespace Session {
},
}
log.info("created", result)
SyncEvent.run(Event.Created, { sessionID: result.id, info: result })
Database.use((db) => {
db.insert(SessionTable).values(toRow(result)).run()
Database.effect(() =>
Bus.publish(Event.Created, {
info: result,
}),
)
})
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
share(result.id).catch(() => {
// Silently ignore sharing errors during session creation
})
}
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
Bus.publish(Event.Updated, {
sessionID: result.id,
info: result,
})
}
Bus.publish(Event.Updated, {
info: result,
})
return result
}
@@ -368,9 +357,12 @@ export namespace Session {
}
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: share.url } } })
Database.use((db) => {
const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
})
return share
})
@@ -378,8 +370,12 @@ export namespace Session {
// Use ShareNext to remove the share (same as share function uses ShareNext to create)
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })
Database.use((db) => {
const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
})
})
export const setTitle = fn(
@@ -388,7 +384,18 @@ export namespace Session {
title: z.string(),
}),
async (input) => {
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { title: input.title } })
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ title: input.title })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
@@ -398,7 +405,18 @@ export namespace Session {
time: z.number().optional(),
}),
async (input) => {
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { time: { archived: input.time } } })
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_archived: input.time })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
@@ -408,9 +426,17 @@ export namespace Session {
permission: Permission.Ruleset,
}),
async (input) => {
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: { permission: input.permission, time: { updated: Date.now() } },
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ permission: input.permission, time_updated: Date.now() })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
@@ -422,24 +448,42 @@ export namespace Session {
summary: Info.shape.summary,
}),
async (input) => {
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: {
summary: input.summary,
time: { updated: Date.now() },
revert: input.revert,
},
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
revert: input.revert ?? null,
summary_additions: input.summary?.additions,
summary_deletions: input.summary?.deletions,
summary_files: input.summary?.files,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
export const clearRevert = fn(SessionID.zod, async (sessionID) => {
SyncEvent.run(Event.Updated, {
sessionID,
info: {
time: { updated: Date.now() },
revert: null,
},
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
revert: null,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
})
@@ -449,12 +493,22 @@ export namespace Session {
summary: Info.shape.summary,
}),
async (input) => {
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: {
time: { updated: Date.now() },
summary: input.summary,
},
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({
summary_additions: input.summary?.additions,
summary_deletions: input.summary?.deletions,
summary_files: input.summary?.files,
time_updated: Date.now(),
})
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
const info = fromRow(row)
Database.effect(() => Bus.publish(Event.Updated, { info }))
return info
})
},
)
@@ -608,28 +662,46 @@ export namespace Session {
})
export const remove = fn(SessionID.zod, async (sessionID) => {
const project = Instance.project
try {
const session = await get(sessionID)
for (const child of await children(sessionID)) {
await remove(child.id)
}
await unshare(sessionID).catch(() => {})
SyncEvent.run(Event.Deleted, { sessionID, info: session })
// Eagerly remove event sourcing data to free up space
SyncEvent.remove(sessionID)
// CASCADE delete handles messages and parts automatically
Database.use((db) => {
db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
Database.effect(() =>
Bus.publish(Event.Deleted, {
info: session,
}),
)
})
} catch (e) {
log.error(e)
}
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
SyncEvent.run(MessageV2.Event.Updated, {
sessionID: msg.sessionID,
info: msg,
const time_created = msg.time.created
const { id, sessionID, ...data } = msg
Database.use((db) => {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.Updated, {
info: msg,
}),
)
})
return msg
})
@@ -639,9 +711,17 @@ export namespace Session {
messageID: MessageID.zod,
}),
async (input) => {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
// CASCADE delete handles parts automatically
Database.use((db) => {
db.delete(MessageTable)
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
}),
)
})
return input.messageID
},
@@ -654,10 +734,17 @@ export namespace Session {
partID: PartID.zod,
}),
async (input) => {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
Database.use((db) => {
db.delete(PartTable)
.where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID)))
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
}),
)
})
return input.partID
},
@@ -666,10 +753,24 @@ export namespace Session {
const UpdatePartInput = MessageV2.Part
export const updatePart = fn(UpdatePartInput, async (part) => {
SyncEvent.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
const { id, messageID, sessionID, ...data } = part
const time = Date.now()
Database.use((db) => {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: time,
data,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartUpdated, {
part: structuredClone(part),
}),
)
})
return part
})

View File

@@ -6,22 +6,17 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { SyncEvent } from "../sync"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
code: "ZlibError"
errno: number
path: string
}
export namespace MessageV2 {
export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf"
@@ -454,34 +449,25 @@ export namespace MessageV2 {
export type Info = z.infer<typeof Info>
export const Event = {
Updated: SyncEvent.define({
type: "message.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
Updated: BusEvent.define(
"message.updated",
z.object({
info: Info,
}),
}),
Removed: SyncEvent.define({
type: "message.removed",
version: 1,
aggregate: "sessionID",
schema: z.object({
),
Removed: BusEvent.define(
"message.removed",
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
}),
PartUpdated: SyncEvent.define({
type: "message.part.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
),
PartUpdated: BusEvent.define(
"message.part.updated",
z.object({
part: Part,
time: z.number(),
}),
}),
),
PartDelta: BusEvent.define(
"message.part.delta",
z.object({
@@ -492,16 +478,14 @@ export namespace MessageV2 {
delta: z.string(),
}),
),
PartRemoved: SyncEvent.define({
type: "message.part.removed",
version: 1,
aggregate: "sessionID",
schema: z.object({
PartRemoved: BusEvent.define(
"message.part.removed",
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
}),
),
}
export const WithParts = z.object({
@@ -913,10 +897,7 @@ export namespace MessageV2 {
return result
}
export function fromError(
e: unknown,
ctx: { providerID: ProviderID; aborted?: boolean },
): NonNullable<Assistant["error"]> {
export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
return new MessageV2.AbortedError(
@@ -948,21 +929,6 @@ export namespace MessageV2 {
},
{ cause: e },
).toObject()
case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
if (ctx.aborted) {
return new MessageV2.AbortedError({ message: e.message }, { cause: e }).toObject()
}
return new MessageV2.APIError(
{
message: "Response decompression failed",
isRetryable: true,
metadata: {
code: (e as FetchDecompressionError).code,
message: e.message,
},
},
{ cause: e },
).toObject()
case APICallError.isInstance(e):
const parsed = ProviderError.parseAPICallError({
providerID: ctx.providerID,

View File

@@ -356,7 +356,7 @@ export namespace SessionProcessor {
error: e,
stack: JSON.stringify(e.stack),
})
const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
if (MessageV2.ContextOverflowError.isInstance(error)) {
needsCompaction = true
Bus.publish(Session.Event.Error, {

View File

@@ -1,135 +0,0 @@
import { NotFoundError, eq, and } from "../storage/db"
import { SyncEvent } from "@/sync"
import { Session } from "./index"
import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Log } from "../util/log"
const log = Log.create({ service: "session.projector" })
function foreign(err: unknown) {
if (typeof err !== "object" || err === null) return false
if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
}
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
function grab<T extends object, K1 extends keyof T, X>(
obj: T,
field1: K1,
cb?: (val: NonNullable<T[K1]>) => X,
): X | undefined {
if (obj == undefined || !(field1 in obj)) return undefined
const val = obj[field1]
if (val && typeof val === "object" && cb) {
return cb(val)
}
if (val === undefined) {
throw new Error(
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
)
}
return val as X | undefined
}
export function toPartialRow(info: DeepPartial<Session.Info>) {
const obj = {
id: grab(info, "id"),
project_id: grab(info, "projectID"),
workspace_id: grab(info, "workspaceID"),
parent_id: grab(info, "parentID"),
slug: grab(info, "slug"),
directory: grab(info, "directory"),
title: grab(info, "title"),
version: grab(info, "version"),
share_url: grab(info, "share", (v) => grab(v, "url")),
summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
summary_files: grab(info, "summary", (v) => grab(v, "files")),
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
revert: grab(info, "revert"),
permission: grab(info, "permission"),
time_created: grab(info, "time", (v) => grab(v, "created")),
time_updated: grab(info, "time", (v) => grab(v, "updated")),
time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
time_archived: grab(info, "time", (v) => grab(v, "archived")),
}
return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
}
export default [
SyncEvent.project(Session.Event.Created, (db, data) => {
db.insert(SessionTable).values(Session.toRow(data.info)).run()
}),
SyncEvent.project(Session.Event.Updated, (db, data) => {
const info = data.info
const row = db
.update(SessionTable)
.set(toPartialRow(info))
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
}),
SyncEvent.project(Session.Event.Deleted, (db, data) => {
db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
}),
SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
const time_created = data.info.time.created
const { id, sessionID, ...rest } = data.info
try {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data: rest,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
.run()
} catch (err) {
if (!foreign(err)) throw err
log.warn("ignored late message update", { messageID: id, sessionID })
}
}),
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
db.delete(MessageTable)
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
db.delete(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
const { id, messageID, sessionID, ...rest } = data.part
try {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: data.time,
data: rest,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
.run()
} catch (err) {
if (!foreign(err)) throw err
log.warn("ignored late part update", { partID: id, messageID, sessionID })
}
}),
]

View File

@@ -4,7 +4,8 @@ import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2"
import { Session } from "."
import { Log } from "../util/log"
import { SyncEvent } from "../sync"
import { Database, eq } from "../storage/db"
import { MessageTable, PartTable } from "./session.sql"
import { Storage } from "@/storage/storage"
import { Bus } from "../bus"
import { SessionPrompt } from "./prompt"
@@ -112,10 +113,8 @@ export namespace SessionRevert {
remove.push(msg)
}
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: sessionID,
messageID: msg.info.id,
})
Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
}
if (session.revert.partID && target) {
const partID = session.revert.partID
@@ -125,7 +124,8 @@ export namespace SessionRevert {
const removeParts = target.parts.slice(removeStart)
target.parts = preserveParts
for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, {
Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
await Bus.publish(MessageV2.Event.PartRemoved, {
sessionID: sessionID,
messageID: target.info.id,
partID: part.id,

View File

@@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod"
@@ -55,8 +55,6 @@ export namespace SessionStatus {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
)
@@ -72,9 +70,9 @@ export namespace SessionStatus {
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
const data = yield* InstanceState.get(state)
yield* bus.publish(Event.Status, { sessionID, status })
yield* Effect.promise(() => Bus.publish(Event.Status, { sessionID, status }))
if (status.type === "idle") {
yield* bus.publish(Event.Idle, { sessionID })
yield* Effect.promise(() => Bus.publish(Event.Idle, { sessionID }))
data.delete(sessionID)
return
}
@@ -85,8 +83,7 @@ export namespace SessionStatus {
}),
)
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
const runPromise = makeRunPromise(Service, layer)
export async function get(sessionID: SessionID) {
return runPromise((svc) => svc.get(sessionID))

View File

@@ -12,6 +12,14 @@ import { Bus } from "@/bus"
import { NotFoundError } from "@/storage/db"
export namespace SessionSummary {
function shape(diffs: Snapshot.FileDiff[]) {
return diffs.map((item) => ({
...item,
before: "",
after: "",
}))
}
function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
@@ -141,6 +149,8 @@ export namespace SessionSummary {
},
)
export const list = fn(SessionID.zod, async (sessionID) => shape(await diff({ sessionID })))
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
let from: string | undefined
let to: string | undefined

View File

@@ -66,28 +66,29 @@ export namespace ShareNext {
export async function init() {
if (disabled) return
Bus.subscribe(Session.Event.Updated, async (evt) => {
const session = await Session.get(evt.properties.sessionID)
await sync(session.id, [
await sync(evt.properties.info.id, [
{
type: "session",
data: session,
data: evt.properties.info,
},
])
})
Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
const info = evt.properties.info
await sync(info.sessionID, [
await sync(evt.properties.info.sessionID, [
{
type: "message",
data: evt.properties.info,
},
])
if (info.role === "user") {
await sync(info.sessionID, [
if (evt.properties.info.role === "user") {
await sync(evt.properties.info.sessionID, [
{
type: "model",
data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)],
data: [
await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then(
(m) => m,
),
],
},
])
}

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