Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton e6bb88bc1d refactor(core): specialize session run coordination 2026-06-06 23:13:52 -04:00
Kit Langton fc0cf2a710 refactor(core): extract session run coordinator machine 2026-06-06 22:50:29 -04:00
499 changed files with 7342 additions and 12446 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
# web + desktop packages
packages/app/ @Hona @Brendonovich
packages/desktop/ @Hona @Brendonovich
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
+9 -50
View File
@@ -94,12 +94,8 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
@@ -526,7 +522,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -535,7 +531,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -557,6 +553,7 @@
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",
"diff": "catalog:",
@@ -790,34 +787,6 @@
"vite": "catalog:",
},
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/scheduled": "1.5.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.16.2",
@@ -1645,7 +1614,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "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-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@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=="],
"@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=="],
@@ -1807,8 +1776,6 @@
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
@@ -3369,7 +3336,7 @@
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -5673,8 +5640,6 @@
"@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=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
@@ -5757,8 +5722,6 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5767,6 +5730,8 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@@ -5955,8 +5920,6 @@
"dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@@ -6015,12 +5978,10 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
@@ -6427,8 +6388,6 @@
"@jsx-email/cli/vite/rollup": ["rollup@3.30.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA=="],
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-OttgLsPJSYW1b9RGqiYSi7qPdDC3GuD2FE7k5tPe6Iw=",
"aarch64-linux": "sha256-VsmW8tTiT3VkTxp5oiYc/maJLSsBMxK6VVUSVshWVT8=",
"aarch64-darwin": "sha256-/MaQBX2zoJMtLA8jHt8+uKpIFve/eec3RfcKT3FvXC8=",
"x86_64-darwin": "sha256-3ggap9rgR2TvVbYgnXsgkg6Rj1TqNZu32QgTVRdN0f0="
"x86_64-linux": "sha256-5DhbOm/gs2mfjmNYdZHkr0ZopgSC2HcGN9/r1noGqhc=",
"aarch64-linux": "sha256-0dIKcqKmhrPhRpabnfM20wnqt/AkoCWDMgG9cQZ8P3o=",
"aarch64-darwin": "sha256-Sx3G63vORj69u2AOMDfpk6NU7fMgxsbYBh3jnGohNXI=",
"x86_64-darwin": "sha256-4g2ydNayqNqWlBeQt90rUI6bY5dMvXP4OmQ6QWaJbuI="
}
}
-1
View File
@@ -6,7 +6,6 @@
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
+1
View File
@@ -70,6 +70,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
}
api?: {
@@ -1,10 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
const statusLabels = {
connected: "mcp.status.connected",
@@ -16,7 +19,10 @@ const statusLabels = {
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
@@ -24,7 +30,21 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = useMcpToggle()
const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
}))
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
@@ -509,16 +509,11 @@ export function useServerManagementController(options: { onSelect?: () => void;
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
}
}
@@ -1,7 +0,0 @@
import { ServerConnection } from "@/context/server"
import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
return "server" as const
}
@@ -1,21 +0,0 @@
import { describe, expect, test } from "bun:test"
import { directoryPickerKind } from "./directory-picker-policy"
const local = {
type: "sidecar",
variant: "base",
http: { url: "http://localhost:4096" },
} as const
const remote = {
type: "ssh",
host: "example.test",
http: { url: "http://localhost:4096" },
} as const
describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -1,29 +0,0 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy"
type DirectoryPickerInput = {
server: ServerConnection.Any
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
}
export function useDirectoryPicker() {
const platform = usePlatform()
const dialog = useDialog()
return (input: DirectoryPickerInput) => {
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
return
}
dialog.show(
() => <DialogSelectDirectory {...input} />,
() => input.onSelect(null),
)
}
}
+13 -22
View File
@@ -57,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
@@ -73,8 +73,6 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { useDirectoryPicker } from "./directory-picker"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
@@ -142,7 +140,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
@@ -471,18 +468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pick = () => {
if (server.isLocal()) {
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk.directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
fileInputRef?.click()
return
}
void import("@/components/dialog-select-file").then((module) =>
@@ -1108,7 +1094,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1399,7 +1385,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = () => {
const addProject = async () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => {
@@ -1407,10 +1393,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!directory) return
selectProject(directory)
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
onSelect: select,
if (platform.openDirectoryPickerDialog && server.isLocal()) {
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
return
}
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
() => select(null),
)
})
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime, pickAttachmentFiles } from "./files"
import { attachmentMime } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
@@ -24,70 +24,6 @@ describe("attachmentMime", () => {
})
})
describe("pickAttachmentFiles", () => {
test("reads the current project directory for every native picker invocation", async () => {
const paths: string[] = []
const files: File[] = []
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
let directory = "C:\\Projects\\LoremIpsum"
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
paths.push(options?.defaultPath ?? "")
await onFile?.(file)
}
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
directory = "C:\\Projects\\DolorSit"
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
expect(files).toEqual([file, file])
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
})
test("uses the browser file input when no native picker exists", async () => {
let fallback = 0
pickAttachmentFiles({
directory: () => "/projects/consectetur-adipiscing",
fallback: () => {
fallback += 1
},
onFile: async () => undefined,
onError: () => undefined,
})
expect(fallback).toBe(1)
})
test("reports native picker failures without rejecting", async () => {
const error = new Error("picker unavailable")
const errors: unknown[] = []
const handled = Promise.withResolvers<void>()
pickAttachmentFiles({
picker: async () => Promise.reject(error),
directory: () => "C:\\Projects\\LoremIpsum",
fallback: () => undefined,
onFile: async () => undefined,
onError: (cause) => {
errors.push(cause)
handled.resolve()
},
})
await handled.promise
expect(errors).toEqual([error])
})
})
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
@@ -2,38 +2,6 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
export { ACCEPTED_FILE_TYPES }
type AttachmentPicker = (
options: {
defaultPath?: string
multiple?: boolean
accept?: string[]
},
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
export function pickAttachmentFiles(input: {
picker?: AttachmentPicker
directory: () => string
fallback: () => void
onFile: (file: File) => Promise<unknown>
onError: (error: unknown) => void
}) {
if (!input.picker) {
input.fallback()
return
}
void input
.picker(
{
defaultPath: input.directory(),
multiple: true,
accept: ACCEPTED_FILE_TYPES,
},
input.onFile,
)
.catch(input.onError)
}
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
@@ -1,4 +1,5 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
@@ -7,13 +8,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
monoFontFamily,
@@ -90,7 +91,9 @@ export const SettingsGeneral: Component = () => {
const params = useParams()
const settings = useSettings()
const updater = useUpdaterAction()
const [store, setStore] = createStore({
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
@@ -120,6 +123,58 @@ export const SettingsGeneral: Component = () => {
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
void platform
.checkUpdate()
.then((result) => {
if (!result.updateAvailable) {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
return
}
const actions = platform.updateAndRestart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: result.version ?? "" }),
actions,
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
@@ -668,6 +723,19 @@ export const SettingsGeneral: Component = () => {
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
@@ -684,8 +752,10 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</SettingsList>
@@ -1,4 +1,5 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
@@ -7,13 +8,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
monoFontFamily,
@@ -92,7 +93,9 @@ export const SettingsGeneralV2: Component = () => {
const params = useParams()
const settings = useSettings()
const updater = useUpdaterAction()
const [store, setStore] = createStore({
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
@@ -122,6 +125,58 @@ export const SettingsGeneralV2: Component = () => {
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
void platform
.checkUpdate()
.then((result) => {
if (!result.updateAvailable) {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
return
}
const actions = platform.updateAndRestart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: result.version ?? "" }),
actions,
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
@@ -685,6 +740,19 @@ export const SettingsGeneralV2: Component = () => {
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
@@ -701,8 +769,10 @@ export const SettingsGeneralV2: Component = () => {
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -10,12 +11,14 @@ import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -96,6 +99,37 @@ const useDefaultServerKey = (
}
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
},
}))
}
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
@@ -277,7 +311,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggle()
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -1,26 +0,0 @@
import { describe, expect, test } from "bun:test"
import { updaterAction } from "./updater-action"
describe("updaterAction", () => {
test("disables update actions when the platform has no updater", () => {
expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
})
test("projects updater transitions into one settings action", () => {
expect(updaterAction({ status: "idle" })).toEqual({
label: "settings.updates.action.checkNow",
run: "check",
})
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
label: "settings.updates.action.downloading",
})
expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
label: "toast.update.action.installRestart",
run: "install",
})
expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
label: "settings.updates.action.installing",
})
})
})
@@ -1,51 +0,0 @@
import { createMemo } from "solid-js"
import type { UpdaterState } from "@/updater"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
export function updaterAction(state: UpdaterState | undefined) {
if (!state) return { label: "settings.updates.action.checkNow" as const }
switch (state.status) {
case "checking":
return { label: "settings.updates.action.checking" as const }
case "downloading":
return { label: "settings.updates.action.downloading" as const }
case "ready":
return { label: "toast.update.action.installRestart" as const, run: "install" as const }
case "installing":
return { label: "settings.updates.action.installing" as const }
case "disabled":
return { label: "settings.updates.action.checkNow" as const }
default:
return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
}
}
export function useUpdaterAction() {
const platform = usePlatform()
const language = useLanguage()
const action = createMemo(() => updaterAction(platform.updater?.state()))
return {
action,
async run() {
const run = action().run
if (run === "install") return platform.updater?.install()
if (run !== "check") return
const state = await platform.updater?.check()
if (state?.status === "up-to-date") {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
}
if (state?.status === "error") {
showToast({ title: language.t("common.requestFailed"), description: state.message })
}
},
}
}
@@ -609,9 +609,6 @@ export const createDirSyncContext = (
)
},
},
mcp: {
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
},
absolute,
get directory() {
return current()[0].path.directory
@@ -1,34 +0,0 @@
import { describe, expect, test } from "bun:test"
import { toggleMcp } from "./mcp"
describe("toggleMcp", () => {
test("runs the status action before refreshing the owning query", async () => {
const calls: string[] = []
const input = (status: "connected" | "needs_auth" | "disabled") => ({
status,
connect: async () => {
calls.push("connect")
},
disconnect: async () => {
calls.push("disconnect")
},
authenticate: async () => {
calls.push("authenticate")
},
refresh: async () => {
calls.push("refresh")
},
})
await toggleMcp(input("connected"))
expect(calls).toEqual(["disconnect", "refresh"])
calls.length = 0
await toggleMcp(input("needs_auth"))
expect(calls).toEqual(["authenticate", "refresh"])
calls.length = 0
await toggleMcp(input("disabled"))
expect(calls).toEqual(["connect", "refresh"])
})
})
@@ -1,18 +0,0 @@
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
export async function toggleMcp(input: {
status: McpStatus["status"]
connect: () => Promise<void>
disconnect: () => Promise<void>
authenticate: () => Promise<void>
refresh: () => Promise<void>
}) {
await {
connected: input.disconnect,
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
-19
View File
@@ -1,19 +0,0 @@
import { useMutation } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language"
import { useSync } from "@/context/sync"
import { showToast } from "@/utils/toast"
export function useMcpToggle() {
const sync = useSync()
const language = useLanguage()
return useMutation(() => ({
mutationFn: sync.mcp.toggle,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
}))
}
+20 -27
View File
@@ -4,18 +4,12 @@ import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenAttachmentPickerOptions = {
title?: string
multiple?: boolean
accept?: string[]
extensions?: string[]
defaultPath?: string
}
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
@@ -27,7 +21,13 @@ export type FatalRendererErrorLog = {
os?: DesktopOS
}
type PlatformBase = {
export type Platform = {
/** Platform discriminator */
platform: PlatformName
/** Desktop OS (Tauri only) */
os?: DesktopOS
/** App version */
version?: string
@@ -49,20 +49,23 @@ type PlatformBase = {
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Open a native attachment picker and read selected files sequentially (desktop only) */
openAttachmentPickerDialog?(
opts: OpenAttachmentPickerOptions,
onFile: (file: File) => Promise<unknown>,
): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
/** Open a native save file picker dialog (desktop only) */
/** Open native file picker dialog (Tauri only) */
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
/** Save file picker dialog (Tauri only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Application-global desktop updater */
updater?: UpdaterPlatform
/** Check for a downloadable desktop update */
checkUpdate?(): Promise<UpdateInfo>
/** Install the downloaded update using the platform restart flow */
updateAndRestart?(): Promise<void>
/** Fetch override */
fetch?: typeof fetch
@@ -110,16 +113,6 @@ type PlatformBase = {
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
}
export type Platform = PlatformBase &
(
| { platform: "web"; os?: never }
| {
platform: "desktop"
os?: DesktopOS
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
}
)
export type DisplayBackend = "auto" | "wayland"
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
-23
View File
@@ -36,7 +36,6 @@ import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
type GlobalStore = {
ready: boolean
@@ -482,28 +481,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
todo: {
set: setSessionTodo,
},
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
await toggleMcp({
status,
connect: async () => {
await sdk.mcp.connect({ name })
},
disconnect: async () => {
await sdk.mcp.disconnect({ name })
},
authenticate: async () => {
await sdk.mcp.auth.authenticate({ name })
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
},
})
},
},
}
}
+12
View File
@@ -35,6 +35,9 @@ export interface Settings {
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
}
appearance: {
fontSize: number
mono: string
@@ -119,6 +122,9 @@ const defaultSettings: Settings = {
showSessionProgressBar: true,
showCustomAgents: false,
},
updates: {
startup: true,
},
appearance: {
fontSize: 14,
mono: "",
@@ -244,6 +250,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
setStartup(value: boolean) {
setStore("updates", "startup", value)
},
},
appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
-2
View File
@@ -866,8 +866,6 @@ export const dict = {
"settings.updates.row.check.description": "Manually check for updates and install if available",
"settings.updates.action.checkNow": "Check now",
"settings.updates.action.checking": "Checking...",
"settings.updates.action.downloading": "Downloading...",
"settings.updates.action.installing": "Installing...",
"settings.updates.toast.latest.title": "You're up to date",
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
"sound.option.none": "None",
-1
View File
@@ -4,7 +4,6 @@ export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { useWslServers } from "./wsl/context"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { type UpdaterPlatform, type UpdaterState } from "./updater"
export {
type WslDistroProbe,
type WslInstalledDistro,
+26 -24
View File
@@ -225,6 +225,8 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
const formattedError = () => formatError(props.error, language.t)
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
checking: false,
version: undefined as string | undefined,
actionError: undefined as string | undefined,
})
@@ -245,25 +247,32 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
})
async function checkForUpdates() {
const state = await platform.updater?.check()
setStore("actionError", state?.status === "error" ? state.message : undefined)
if (!platform.checkUpdate) return
setStore("checking", true)
await platform
.checkUpdate()
.then((result) => {
setStore("actionError", undefined)
if (result.updateAvailable && result.version) setStore("version", result.version)
})
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
.finally(() => {
setStore("checking", false)
})
}
async function installUpdate() {
await platform.updater
?.install()
if (!platform.updateAndRestart) return
await platform
.updateAndRestart()
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
}
const updateVersion = () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
}
async function exportDebugLogs() {
const exportLogs = platform.exportDebugLogs
if (!exportLogs) return
@@ -318,27 +327,20 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
)
}}
</Show>
<Show when={platform.updater}>
<Show when={platform.checkUpdate}>
<Show
when={updateVersion()}
when={store.version}
fallback={
<Button
size="large"
variant="ghost"
onClick={checkForUpdates}
disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
>
{platform.updater?.state().status === "checking"
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
{store.checking
? language.t("error.page.action.checking")
: language.t("error.page.action.checkUpdates")}
</Button>
}
>
{(version) => (
<Button size="large" onClick={installUpdate}>
{language.t("error.page.action.updateTo", { version: version() })}
</Button>
)}
<Button size="large" onClick={installUpdate}>
{language.t("error.page.action.updateTo", { version: store.version ?? "" })}
</Button>
</Show>
</Show>
</div>
+30 -20
View File
@@ -6,7 +6,6 @@ import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -19,7 +18,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, useServer } from "@/context/server"
@@ -126,7 +125,6 @@ function HomeDesign() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
@@ -316,19 +314,26 @@ function HomeDesign() {
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
}
function chooseProject(conn: ServerConnection.Any) {
async function chooseProject(conn: ServerConnection.Any) {
function resolve(result: string | string[] | null) {
addProjects(conn, homeProjectDirectories(result))
}
const server = global.createServerCtx(conn)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
if (platform.openDirectoryPickerDialog && server.isLocal) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
return
}
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
}
function openSettings() {
@@ -385,7 +390,7 @@ function HomeDesign() {
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView class="mt-3 min-h-0 flex-1">
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<div class="pt-3 flex flex-col gap-6">
<Show
when={!sessionLoad.isLoading}
@@ -427,7 +432,7 @@ function HomeDesign() {
</Show>
</Show>
</div>
</ScrollView>
</div>
</section>
</div>
</div>
@@ -1096,7 +1101,6 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
function LegacyHome() {
const sync = useServerSync()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
@@ -1124,7 +1128,7 @@ function LegacyHome() {
navigate(`/${base64Encode(directory)}`)
}
function chooseProject() {
async function chooseProject() {
const s = server.current
if (!s) return
@@ -1138,12 +1142,18 @@ function LegacyHome() {
}
}
pickDirectory({
server: s,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
() => resolve(null),
)
}
}
return (
+37 -14
View File
@@ -14,6 +14,7 @@ import {
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -64,7 +65,6 @@ import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
@@ -90,6 +90,7 @@ import {
} from "./layout/sidebar-workspace"
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
import { runUpdateAndRestart } from "./layout/update"
export default function Layout(props: ParentProps) {
const serverSDK = useServerSDK()
@@ -118,7 +119,6 @@ export default function Layout(props: ParentProps) {
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const server = useServer()
const notification = useNotification()
@@ -168,15 +168,28 @@ export default function Layout(props: ParentProps) {
peeked: false,
})
const [update, setUpdate] = createStore({
installing: false,
})
const updateQuery = useQuery(() => ({
queryKey: ["desktop", "update"] as const,
enabled: () =>
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
}))
const updateVersion = () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
if (!settings.ready()) return
if (!settings.updates.startup()) return
if (!updateQuery.data?.updateAvailable) return
return updateQuery.data.version ?? ""
}
const installUpdate = () => {
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
}
const installUpdate = () => void platform.updater?.install()
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
installing: () => platform.updater?.state().status === "installing",
installing: () => update.installing,
install: installUpdate,
}
@@ -1459,7 +1472,7 @@ export default function Layout(props: ParentProps) {
})
}
function chooseProject() {
async function chooseProject() {
const conn = server.current
if (!conn) return
function resolve(result: string | string[] | null) {
@@ -1473,12 +1486,22 @@ export default function Layout(props: ParentProps) {
}
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
} else {
const run = ++dialogRun
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
})
}
}
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test"
import { runUpdateAndRestart } from "./update"
describe("runUpdateAndRestart", () => {
test("clears the installing state when restart resolves without exiting", async () => {
const states: boolean[] = []
await new Promise<void>((resolve) => {
runUpdateAndRestart(
async () => {},
(installing) => {
states.push(installing)
if (states.length === 2) resolve()
},
)
})
expect(states).toEqual([true, false])
})
})
+10
View File
@@ -0,0 +1,10 @@
export function runUpdateAndRestart(
updateAndRestart: (() => Promise<void>) | undefined,
setInstalling: (installing: boolean) => void,
) {
if (!updateAndRestart) return
setInstalling(true)
void updateAndRestart()
.catch(() => undefined)
.finally(() => setInstalling(false))
}
@@ -547,7 +547,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
disabled: desktopV2() && !settings.general.showCustomAgents(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -555,7 +554,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
disabled: desktopV2() && !settings.general.showCustomAgents(),
onSelect: () => local.agent.move(-1),
}),
]
-17
View File
@@ -1,17 +0,0 @@
import type { Accessor } from "solid-js"
export type UpdaterState =
| { status: "disabled" }
| { status: "idle" }
| { status: "checking" }
| { status: "downloading"; version: string; percent?: number }
| { status: "ready"; version: string }
| { status: "up-to-date" }
| { status: "installing"; version: string }
| { status: "error"; message: string }
export type UpdaterPlatform = {
state: Accessor<UpdaterState>
check(): Promise<UpdaterState>
install(): Promise<void>
}
+11 -11
View File
@@ -71,17 +71,6 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
@@ -117,6 +106,17 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
+5 -1
View File
@@ -76,7 +76,11 @@ export function WslServerSettings(props: {
}))
const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key))
if (!api) return
request.mutate(async () => {
await api.removeServer(key)
await props.controller.handleRemove(key)
})
}
return (
-1
View File
@@ -1 +0,0 @@
preload = ["@opentui/solid/preload"]
+1 -5
View File
@@ -20,12 +20,8 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"solid-js": "catalog:"
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
+1 -19
View File
@@ -1,12 +1,8 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
@@ -17,9 +13,7 @@ await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const allTargets: {
os: string
@@ -49,12 +43,6 @@ const targets = singleFlag
})
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
for (const item of targets) {
const target = [
binary,
@@ -68,9 +56,8 @@ for (const item of targets) {
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts", parserWorker],
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
format: "esm",
minify: true,
@@ -92,11 +79,6 @@ for (const item of targets) {
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
'"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
},
})
@@ -1,13 +0,0 @@
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect } from "effect"
import { Daemon } from "../../services/daemon"
export default Runtime.handler(Commands, () =>
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport)
}),
)
@@ -1,5 +1,4 @@
import { NodeHttpServer } from "@effect/platform-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
@@ -35,7 +34,6 @@ function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(PermissionSaved.defaultLayer),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
+10 -10
View File
@@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
}
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
const handler = handlers.find((handler) => handler.spec === node.spec)
const spec = handler
? node.spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
const spec: Command.Command.Any = Object.keys(node.commands).length
? (node.spec as Command.Command<string, unknown>).pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
)
: node.spec
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
const handler = handlers.find((handler) => handler.spec === node.spec)
if (!handler) return spec as ProvidedCommand
return spec.pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
) as ProvidedCommand
}
-1
View File
@@ -8,7 +8,6 @@ import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
debug: {
agents: () => import("./commands/handlers/debug/agents"),
},
+10 -22
View File
@@ -5,12 +5,10 @@ import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes, randomUUID } from "crypto"
import { spawn } from "node:child_process"
import path from "path"
export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
@@ -110,21 +108,16 @@ export const layer = Layer.effect(
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url
if (found?.version === InstallationVersion) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined)
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
yield* Effect.try({
try: () => {
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
detached: true,
stdio: "ignore",
}).unref()
},
catch: (cause) => new Error("Failed to start server", { cause }),
yield* Effect.sync(() => {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}).unref()
})
return yield* compatible().pipe(
@@ -134,13 +127,8 @@ export const layer = Layer.effect(
)
})
const transport = Effect.fn("cli.daemon.transport")(function* () {
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
})
const client = Effect.fn("cli.daemon.client")(function* () {
const connection = yield* transport()
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
return yield* createClient(yield* start())
})
const status = Effect.fn("cli.daemon.status")(function* () {
@@ -185,7 +173,7 @@ export const layer = Layer.effect(
)
})
return Service.of({ client, transport, start, status, stop, password, register })
return Service.of({ client, start, status, stop, password, register })
}),
)
-36
View File
@@ -1,36 +0,0 @@
import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
}).pipe(Effect.provide(Global.defaultLayer))
}
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
-2
View File
@@ -2,8 +2,6 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
@@ -501,7 +501,7 @@ export default function PrivacyPolicy() {
otherwise use the Services or send us any Personal Data. If we learn we have collected Personal Data
from a child under 18 years of age, we will delete that information as quickly as possible. If you
believe that a child under 18 years of age may have provided Personal Data to us, please contact us at{" "}
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="california-resident-rights">California Resident Rights</h2>
@@ -520,7 +520,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a California resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access</h3>
@@ -605,7 +605,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Colorado resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -676,7 +676,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Connecticut resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -745,7 +745,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Delaware resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -818,7 +818,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Iowa resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -864,7 +864,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Montana resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -937,7 +937,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Nebraska resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1007,7 +1007,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Hampshire resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1078,7 +1078,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Jersey resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1151,7 +1151,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Oregon resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1225,7 +1225,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Texas resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1293,7 +1293,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Utah resident, the portion that is more protective of Personal Data shall control to the extent of
such conflict. If you have any questions about this section or whether any of the following rights apply
to you, please contact us at help@anoma.ly.
to you, please contact us at contact@anoma.ly.
</p>
<h3>Access and Portability</h3>
@@ -1339,7 +1339,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Virginia resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1418,7 +1418,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1430,7 +1430,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1457,7 +1457,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1474,8 +1474,8 @@ export default function PrivacyPolicy() {
<p>
Under California Civil Code Sections 1798.83-1798.84, California residents are entitled to contact us to
prevent disclosure of Personal Data to third parties for such third parties' direct marketing purposes;
in order to submit such a request, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
.
in order to submit such a request, please contact us at{" "}
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<p>
@@ -1500,7 +1500,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Phone: +1 415 794-0209</li>
<li>Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States</li>
@@ -30,7 +30,7 @@ export default function TermsOfService() {
</p>
<p>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</p>
<p>
@@ -114,7 +114,7 @@ export default function TermsOfService() {
attempt to register for or otherwise use the Services or send us any personal information. If we learn
we have collected personal information from a child under 13 years of age, we will delete that
information as quickly as possible. If you believe that a child under 13 years of age may have provided
us personal information, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
us personal information, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="what-are-the-basics">What are the basics of using OpenCode?</h2>
@@ -315,7 +315,7 @@ export default function TermsOfService() {
specified time of the trial. You must stop using a Paid Service before the end of the trial period in
order to avoid being charged for that Paid Service. If you cancel prior to the end of the trial period
and are inadvertently charged for a Paid Service, please contact us at{" "}
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="what-if-i-want-to-stop">What if I want to stop using the Services?</h2>
@@ -226,7 +226,7 @@ export async function POST(input: APIEvent) {
expand: ["discounts", "payments"],
})
const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string
const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string
const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) throw new Error("Payment ID not found")
@@ -143,7 +143,7 @@ export function BillingSection() {
<h2>{i18n.t("workspace.billing.title")}</h2>
<p>
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
<a href="mailto:help@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
<a href="mailto:contact@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
{i18n.t("workspace.billing.subtitle.afterLink")}
</p>
</div>
+5 -2
View File
@@ -63,7 +63,7 @@ export type Editor = {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -113,7 +113,10 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
update: state.update,
update: Effect.fn("AgentV2.update")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
}),
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
+1 -1
View File
@@ -203,7 +203,7 @@ export const layer = Layer.effect(
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
-2
View File
@@ -27,7 +27,6 @@ export type Editor = {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -55,7 +54,6 @@ export const layer = Layer.effect(
})
return Service.of({
update: state.update,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
@@ -6,7 +6,6 @@ import { Git } from "../git"
import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionEvent } from "../session/event"
import { SessionSchema } from "../session/schema"
import { AbsolutePath, RelativePath } from "../schema"
@@ -125,6 +124,5 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionV2.defaultLayer),
)
+1 -1
View File
@@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
return files.filter((file): file is File => file !== undefined)
})
yield* registry.register({
yield* registry.contribute({
key,
load: observe().pipe(
Effect.map((files) =>
+18 -8
View File
@@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { logFailure } from "./session/logging"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
@@ -178,7 +177,10 @@ export const layer = Layer.effect(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: logFailure("Failed to wake Session", admitted.sessionID, cause),
: Effect.logError("Failed to wake Session").pipe(
Effect.annotateLogs("sessionID", admitted.sessionID),
Effect.annotateLogs("cause", cause),
),
),
Effect.ignore,
Effect.forkIn(scope, { startImmediately: true }),
@@ -425,12 +427,20 @@ export const layer = Layer.effect(
}),
)
const DefaultDatabase = Database.defaultLayer
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
export const defaultLayer = layer.pipe(
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(
Layer.mergeAll(
DefaultDatabase,
DefaultEvents,
DefaultProjector,
DefaultStore,
SessionExecution.noopLayer,
ProjectV2.defaultLayer,
),
),
Layer.orDie,
)
+5 -4
View File
@@ -5,7 +5,6 @@ import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionExecution } from "../execution"
import { logFailure } from "../logging"
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
export const layer = Layer.effect(
@@ -21,7 +20,11 @@ export const layer = Layer.effect(
Effect.provide(locations.get(session.location)),
)
}),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
onFailure: (sessionID, cause) =>
Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
})
return SessionExecution.Service.of({
@@ -31,5 +34,3 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
-8
View File
@@ -1,8 +0,0 @@
import { Cause, Effect } from "effect"
import { SessionSchema } from "./schema"
export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID))
+338 -219
View File
@@ -1,269 +1,384 @@
export * as SessionRunCoordinator from "./run-coordinator"
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
import {
Cause,
Context,
Data,
Deferred,
Effect,
Equal,
Exit,
Fiber,
FiberSet,
Layer,
Scope,
SynchronizedRef,
} from "effect"
import { SessionRunner } from "./runner"
import { logFailure } from "./logging"
import { SessionSchema } from "./schema"
export type Mode = "run" | "wake"
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
/**
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
*
* For each key:
*
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
*
* `run` is an explicit drain request. It starts a chain or joins the current chain and
* upgrades a pending follow-up so the caller receives explicit-run semantics.
*
* `wake` reports that durable work may now be available. It starts a chain while idle or
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
*
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
*/
export interface Coordinator<Key, A, E> {
/** Starts or joins one explicit drain generation. */
readonly run: (key: Key) => Effect.Effect<A, E>
/** Coalesces one wake-up after durable work is recorded. */
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
/** Waits until the current ownership chain settles. */
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
/** Interrupts the active ownership chain without automatically draining pending wakes. */
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
}
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
type Entry<A, E> = {
readonly done: Deferred.Deferred<A, E>
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
current: Demand
pending?: Demand
explicitWaiter?: Deferred.Deferred<A, E>
interruptSeq?: number
owner?: Fiber.Fiber<void, never>
stopping: boolean
/** @internal */
export class Demand extends Data.Class<{
readonly explicit: boolean
readonly wakeSeq?: number
readonly unsequencedWake: boolean
}> {
static readonly empty = new Demand({ explicit: false, wakeSeq: undefined, unsequencedWake: false })
static readonly run = nonEmpty(new Demand({ explicit: true, wakeSeq: undefined, unsequencedWake: false }))
static wake(seq?: number) {
return nonEmpty(new Demand({ explicit: false, wakeSeq: seq, unsequencedWake: seq === undefined }))
}
combine(other: Demand) {
return new Demand({
explicit: this.explicit || other.explicit,
wakeSeq:
this.wakeSeq === undefined
? other.wakeSeq
: other.wakeSeq === undefined
? this.wakeSeq
: Math.max(this.wakeSeq, other.wakeSeq),
unsequencedWake: this.unsequencedWake || other.unsequencedWake,
})
}
afterBoundary(boundary?: number) {
return new Demand({
explicit: false,
wakeSeq:
boundary !== undefined && this.wakeSeq !== undefined && this.wakeSeq > boundary ? this.wakeSeq : undefined,
unsequencedWake: false,
})
}
isNonEmpty(): this is NonEmptyDemand {
return this.explicit || this.wakeSeq !== undefined || this.unsequencedWake
}
get mode(): Mode {
return this.explicit ? "run" : "wake"
}
}
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
type NonEmptyDemand = Demand &
({ readonly explicit: true } | { readonly wakeSeq: number } | { readonly unsequencedWake: true })
function nonEmpty(demand: Demand): NonEmptyDemand {
if (!demand.isNonEmpty()) throw new Error("Session run demand must not be empty")
return demand
}
const maxSeq = (left: number | undefined, right: number | undefined) => {
if (left === undefined) return right
if (right === undefined) return left
return Math.max(left, right)
type Lifecycle =
| { readonly _tag: "Running"; readonly token: object; readonly owner: Deferred.Deferred<Fiber.Fiber<void>> }
| {
readonly _tag: "Stopping"
readonly token: object
readonly owner: Deferred.Deferred<Fiber.Fiber<void>>
readonly boundary?: number
}
type Lane<A, E> = {
readonly current: NonEmptyDemand
readonly pending: Demand
readonly lifecycle: Lifecycle
readonly terminal: Deferred.Deferred<Exit.Exit<A, E>>
readonly waiter?: Deferred.Deferred<Exit.Exit<A, E>>
}
type State<Key, A, E> = {
readonly closed: boolean
readonly lanes: ReadonlyMap<Key, Lane<A, E>>
readonly interruptSeq: ReadonlyMap<Key, number>
}
type Start<Key, A, E> = {
readonly key: Key
readonly demand: NonEmptyDemand
readonly successor: boolean
readonly token: object
readonly owner: Deferred.Deferred<Fiber.Fiber<void>>
readonly ready: Deferred.Deferred<void>
readonly terminal: Deferred.Deferred<Exit.Exit<A, E>>
}
type RunRequest<Key, A, E> =
| { readonly _tag: "Closed" }
| { readonly _tag: "Await"; readonly terminal: Deferred.Deferred<Exit.Exit<A, E>> }
| { readonly _tag: "Retry"; readonly terminal: Deferred.Deferred<Exit.Exit<A, E>> }
| { readonly _tag: "Start"; readonly start: Start<Key, A, E>; readonly terminal: Deferred.Deferred<Exit.Exit<A, E>> }
type Completion<Key, A, E> = {
readonly start?: Start<Key, A, E>
readonly terminal?: Deferred.Deferred<Exit.Exit<A, E>>
readonly waiter?: Deferred.Deferred<Exit.Exit<A, E>>
readonly report?: Cause.Cause<E>
}
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
export const make = <Key, A, E>(options: {
readonly drain: (key: Key, mode: Mode) => Effect.Effect<A, E>
readonly onFailure?: (key: Key, cause: Cause.Cause<E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<A, E>>()
const interruptSeq = new Map<Key, number>()
const report = yield* FiberSet.makeRuntime<never, void, never>()
const state = yield* SynchronizedRef.make<State<Key, A, E>>({
closed: false,
lanes: new Map(),
interruptSeq: new Map(),
})
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const shutdown = Deferred.makeUnsafe<void>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
Deferred.doneUnsafe(shutdown, Effect.void)
active.clear()
interruptSeq.clear()
}),
)
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
done: Deferred.makeUnsafe<A, E>(),
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
current,
explicitWaiter,
stopping: false,
})
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
const ready = Deferred.makeUnsafe<void>()
const drain = Effect.suspend(() => options.drain(key, demand._tag))
// Initial work retains immediate-start behavior but cannot run before ownership is published.
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
const owner = fork(
(successor
? Effect.yieldNow.pipe(Effect.andThen(drain))
: Deferred.await(ready).pipe(Effect.andThen(drain))
).pipe(
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
Effect.exit,
Effect.asVoid,
),
)
entry.owner = owner
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
const updateLane = (current: State<Key, A, E>, key: Key, lane?: Lane<A, E>): State<Key, A, E> => {
const lanes = new Map(current.lanes)
if (lane === undefined) lanes.delete(key)
else lanes.set(key, lane)
return { ...current, lanes }
}
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
if (closed) {
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
const start = (input: {
readonly state: State<Key, A, E>
readonly key: Key
readonly demand: NonEmptyDemand
readonly terminal?: Deferred.Deferred<Exit.Exit<A, E>>
readonly waiter?: Deferred.Deferred<Exit.Exit<A, E>>
readonly successor?: boolean
}) => {
const instruction: Start<Key, A, E> = {
key: input.key,
demand: input.demand,
successor: input.successor ?? false,
token: {},
owner: Deferred.makeUnsafe<Fiber.Fiber<void>>(),
ready: Deferred.makeUnsafe<void>(),
terminal: input.terminal ?? Deferred.makeUnsafe<Exit.Exit<A, E>>(),
}
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
return {
state: updateLane(input.state, input.key, {
current: input.demand,
pending: Demand.empty,
lifecycle: { _tag: "Running", token: instruction.token, owner: instruction.owner },
terminal: instruction.terminal,
waiter: input.waiter,
}),
start: instruction,
result: instruction.terminal,
}
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
}
if (active.get(key) !== entry) {
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
if (exit._tag === "Success" && !entry.stopping) {
if (entry.pending !== undefined) {
const pending = entry.pending
entry.pending = undefined
entry.current = pending
start(key, entry, pending, true)
return
}
const launch = (instruction: Start<Key, A, E>) =>
Effect.gen(function* () {
const fiber = fork(
Deferred.await(instruction.ready).pipe(
Effect.andThen(instruction.successor ? Effect.yieldNow : Effect.void),
Effect.andThen(Effect.suspend(() => options.drain(instruction.key, instruction.demand.mode))),
Effect.onExit((exit) => complete(instruction.key, instruction.token, exit)),
Effect.exit,
Effect.asVoid,
),
)
yield* Deferred.succeed(instruction.owner, fiber)
yield* Deferred.succeed(instruction.ready, undefined)
})
const complete = (key: Key, token: object, exit: Exit.Exit<A, E>): Effect.Effect<void> => {
return SynchronizedRef.modify(state, (current): readonly [Completion<Key, A, E>, State<Key, A, E>] => {
const lane = current.lanes.get(key)
if (lane === undefined || lane.lifecycle.token !== token) return [{}, current]
const deliberateInterrupt =
lane.lifecycle._tag === "Stopping" && exit._tag === "Failure" && Cause.hasInterruptsOnly(exit.cause)
const report =
exit._tag === "Failure" && !deliberateInterrupt && !lane.current.explicit ? exit.cause : undefined
const completesWaiter = lane.current.explicit || (lane.lifecycle._tag === "Stopping" && !lane.current.explicit)
const waiter = completesWaiter ? undefined : lane.waiter
if (exit._tag === "Success" && lane.lifecycle._tag === "Running" && lane.pending.isNonEmpty()) {
const next = start({
state: current,
key,
demand: lane.pending,
terminal: lane.terminal,
waiter,
successor: true,
})
return [{ start: next.start, waiter: completesWaiter ? lane.waiter : undefined, report }, next.state]
}
active.delete(key)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
if (successor === undefined) active.delete(key)
else active.set(key, successor)
if (successor !== undefined) start(key, successor, successor.current, true)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
if (
exit._tag === "Failure" &&
!(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) &&
demand._tag === "wake" &&
options.onFailure !== undefined
) {
report(Effect.suspend(() => options.onFailure!(key, exit.cause)))
}
const next = lane.pending.isNonEmpty()
? start({ state: current, key, demand: lane.pending, waiter, successor: true })
: { state: updateLane(current, key) }
return [
{
start: "start" in next ? next.start : undefined,
terminal: lane.terminal,
waiter: completesWaiter ? lane.waiter : undefined,
report,
},
next.state,
]
}).pipe(Effect.flatMap((instruction) => executeCompletion(key, exit, instruction)))
}
const executeCompletion = (key: Key, exit: Exit.Exit<A, E>, instruction: Completion<Key, A, E>) =>
Effect.gen(function* () {
if (instruction.start !== undefined) yield* launch(instruction.start)
if (instruction.waiter !== undefined) yield* Deferred.succeed(instruction.waiter, exit)
if (instruction.terminal !== undefined) yield* Deferred.succeed(instruction.terminal, exit)
if (instruction.report !== undefined && options.onFailure !== undefined) {
const onFailure = options.onFailure
const cause = instruction.report
fork(Effect.suspend(() => onFailure(key, cause)).pipe(Effect.exit, Effect.asVoid))
}
})
const awaitTerminal = (terminal: Deferred.Deferred<Exit.Exit<A, E>>) =>
Effect.raceFirst(
Deferred.await(terminal).pipe(
Effect.flatMap(
Exit.match({
onSuccess: Effect.succeed,
onFailure: Effect.failCause,
}),
),
),
Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)),
)
const run = (key: Key): Effect.Effect<A, E> =>
Effect.suspend(() =>
Effect.uninterruptibleMask((restore) => {
return SynchronizedRef.modify(state, (current): readonly [RunRequest<Key, A, E>, State<Key, A, E>] => {
if (current.closed) return [{ _tag: "Closed" }, current]
const lane = current.lanes.get(key)
if (lane?.lifecycle._tag === "Stopping") return [{ _tag: "Retry", terminal: lane.terminal }, current]
if (lane?.current.explicit) return [{ _tag: "Await", terminal: lane.terminal }, current]
if (lane !== undefined) {
const terminal = lane.waiter ?? Deferred.makeUnsafe<Exit.Exit<A, E>>()
const pending = lane.pending.combine(Demand.run)
if (Equal.equals(pending, lane.pending) && lane.waiter !== undefined)
return [{ _tag: "Await", terminal }, current]
return [{ _tag: "Await", terminal }, updateLane(current, key, { ...lane, pending, waiter: terminal })]
}
const next = start({ state: current, key, demand: Demand.run })
return [{ _tag: "Start", start: next.start, terminal: next.result }, next.state]
}).pipe(
Effect.flatMap((request) => {
if (request._tag === "Closed") return Effect.interrupt
if (request._tag === "Start")
return launch(request.start).pipe(Effect.andThen(awaitTerminal(request.terminal)))
if (request._tag === "Await") return awaitTerminal(request.terminal)
return Effect.raceFirst(
Deferred.await(request.terminal).pipe(Effect.as(true)),
Deferred.await(shutdown).pipe(Effect.as(false)),
).pipe(Effect.flatMap((retry) => (retry ? run(key) : Effect.interrupt)))
}),
restore,
)
}),
)
const wake = (key: Key, seq?: number) =>
Effect.sync(() => {
if (closed) return
if (!isAfterInterrupt(key, seq)) return
const entry = active.get(key)
if (entry !== undefined) {
if (!acceptsWake(entry, seq)) return
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
return
}
Effect.uninterruptible(
Effect.suspend(() => {
return SynchronizedRef.modify(state, (current): readonly [Start<Key, A, E> | undefined, State<Key, A, E>] => {
if (current.closed) return [undefined, current]
const boundary = current.interruptSeq.get(key)
if (boundary !== undefined && (seq === undefined || seq <= boundary)) return [undefined, current]
const lane = current.lanes.get(key)
if (lane === undefined) {
const next = start({ state: current, key, demand: Demand.wake(seq) })
return [next.start, next.state]
}
if (
lane.lifecycle._tag === "Stopping" &&
(lane.lifecycle.boundary === undefined || seq === undefined || seq <= lane.lifecycle.boundary)
)
return [undefined, current]
const pending = lane.pending.combine(Demand.wake(seq))
if (Equal.equals(pending, lane.pending)) return [undefined, current]
return [undefined, updateLane(current, key, { ...lane, pending })]
}).pipe(Effect.flatMap((instruction) => (instruction === undefined ? Effect.void : launch(instruction))))
}),
)
const next = makeEntry({ _tag: "wake", seq })
active.set(key, next)
start(key, next, next.current)
})
const interrupt = (key: Key, seq?: number) =>
Effect.uninterruptible(
SynchronizedRef.modify(state, (current) => {
if (current.closed) return [undefined, current] as const
const latest = current.interruptSeq.get(key)
const lane = current.lanes.get(key)
if (seq !== undefined && latest !== undefined && seq <= latest)
return [lane?.lifecycle._tag === "Stopping" ? lane.lifecycle.owner : undefined, current] as const
const bounded = (() => {
if (seq === undefined) return current
const interruptSeq = new Map(current.interruptSeq)
interruptSeq.set(key, seq)
return { ...current, interruptSeq }
})()
if (lane === undefined) return [undefined, bounded] as const
if (
!lane.current.explicit &&
seq !== undefined &&
lane.current.wakeSeq !== undefined &&
lane.current.wakeSeq > seq
)
return [undefined, bounded] as const
const pending = lane.current.afterBoundary(seq).combine(lane.pending.afterBoundary(seq))
const boundary =
lane.lifecycle._tag === "Stopping" && lane.lifecycle.boundary !== undefined && seq !== undefined
? Math.max(lane.lifecycle.boundary, seq)
: lane.lifecycle._tag === "Stopping" && seq === undefined
? lane.lifecycle.boundary
: seq
return [
lane.lifecycle.owner,
updateLane(bounded, key, {
...lane,
pending,
lifecycle: { _tag: "Stopping", token: lane.lifecycle.token, owner: lane.lifecycle.owner, boundary },
}),
] as const
}).pipe(
Effect.flatMap((owner) =>
owner === undefined ? Effect.void : Deferred.await(owner).pipe(Effect.flatMap(Fiber.interrupt)),
),
),
)
const awaitIdle = (key: Key): Effect.Effect<void, E> =>
Effect.gen(function* () {
let firstFailure: Cause.Cause<E> | undefined
while (!closed) {
const entry = active.get(key)
if (entry === undefined) break
let failure: Cause.Cause<E> | undefined
while (true) {
const terminal = (yield* SynchronizedRef.get(state)).lanes.get(key)?.terminal
if (terminal === undefined) break
const exit = yield* Effect.raceFirst(
Deferred.await(entry.settled),
Deferred.await(terminal),
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
)
if (closed) break
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
if (exit._tag === "Failure" && failure === undefined) failure = exit.cause
}
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
if (failure !== undefined) return yield* Effect.failCause(failure)
})
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
Effect.suspend(() => {
const entry = active.get(key)
const latest = interruptSeq.get(key)
if (seq !== undefined && latest !== undefined && seq <= latest)
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
if (seq !== undefined) interruptSeq.set(key, seq)
if (entry?.owner === undefined) return Effect.void
if (
seq !== undefined &&
entry.current._tag === "wake" &&
entry.current.seq !== undefined &&
entry.current.seq > seq
)
return Effect.void
if (entry.stopping) {
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner)
}
entry.stopping = true
entry.interruptSeq = seq
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner)
})
yield* Effect.addFinalizer(() =>
SynchronizedRef.modify(state, (_current) => [
undefined,
{ closed: true, lanes: new Map(), interruptSeq: new Map() } satisfies State<Key, A, E>,
]).pipe(Effect.andThen(Deferred.succeed(shutdown, undefined))),
)
return { run, wake, awaitIdle, interrupt }
function run(key: Key): Effect.Effect<A, E> {
return Effect.uninterruptibleMask((restore) => {
if (closed) return Effect.interrupt
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) {
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
}
if (entry.current._tag === "wake") {
entry.pending = coalesce(entry.pending, { _tag: "run" })
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
return restore(awaitRun(entry.explicitWaiter))
}
return restore(awaitRun(entry.done))
}
const next = makeEntry({ _tag: "run" })
active.set(key, next)
start(key, next, next.current)
return restore(awaitRun(next.done))
})
}
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
}
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
}
function isAfterInterrupt(key: Key, seq: number | undefined) {
const latest = interruptSeq.get(key)
return latest === undefined || (seq !== undefined && seq > latest)
}
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
if (
entry.pending?._tag === "wake" &&
seq !== undefined &&
entry.pending.seq !== undefined &&
entry.pending.seq > seq
)
return
entry.pending = undefined
}
return { run, wake, interrupt, awaitIdle }
})
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
@@ -276,7 +391,11 @@ export const layer = Layer.effect(
Effect.flatMap((runner) =>
make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
onFailure: (sessionID, cause) =>
Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
}),
),
Effect.map(Service.of),
-2
View File
@@ -58,5 +58,3 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+6 -13
View File
@@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
import type { Draft, Objectish } from "immer"
/**
* A replayable transform applied to an editor during rebuild.
* A replayable contribution applied to an editor during rebuild.
*
* Transforms are intentionally synchronous and mutation-shaped: domain editors
* hide the draft representation while preserving concise plugin/config code.
@@ -39,17 +39,15 @@ export interface Interface<State extends Objectish, Editor> {
* registration order. Closing the owning Scope removes the slot and rebuilds.
*/
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
/** Registers and applies a replayable transform in the current Scope. */
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
/**
* Mutates the current materialized state directly, once.
* Mutates the current materialized state directly.
*
* This is not replayable transform state: a later rebuild starts again
* This is not replayable contribution state: a later rebuild starts again
* from `initial()` plus active transforms, so direct edits must be reserved
* for current-state adjustments that are intentionally outside the transform
* fold.
*/
readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
}
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
@@ -71,7 +69,7 @@ export function create<State extends Objectish, Editor>(options: Options<State,
yield* commit(next)
})
const result: Interface<State, Editor> = {
return {
get: () => state,
transform: Effect.fn("State.transform")(function* () {
const scope = yield* Scope.Scope
@@ -98,15 +96,10 @@ export function create<State extends Objectish, Editor>(options: Options<State,
}),
)
}),
update: Effect.fn("State.update")(function* (update) {
const transform = yield* result.transform()
yield* transform(update)
}),
mutate: Effect.fn("State.mutate")(function* (update, reason) {
update: Effect.fn("State.update")(function* (update, reason) {
const api = options.editor(state as Draft<State>)
yield* update(api)
if (options.finalize) yield* options.finalize(api, reason)
}, semaphore.withPermit),
}
return result
}
+1 -1
View File
@@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
}),
])
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
}),
)
+12 -12
View File
@@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
import { Context, Effect, Layer, Ref, Scope } from "effect"
import { SystemContext } from "./index"
export interface Entry {
export interface Contribution {
readonly key: SystemContext.Key
readonly load: Effect.Effect<SystemContext.SystemContext>
}
export interface Interface {
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
@@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
return Service.of({
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
yield* Effect.acquireRelease(
Ref.modify(entries, (current) => {
if (current.some((item) => item.key === entry.key)) return [false, current]
return [true, [...current, entry]]
Ref.modify(contributions, (current) => {
if (current.some((item) => item.key === contribution.key)) return [false, current]
return [true, [...current, contribution]]
}).pipe(
Effect.flatMap((added) =>
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
),
Effect.as(entry),
Effect.as(contribution),
),
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
)
}),
load: Effect.fn("SystemContextRegistry.load")(function* () {
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
return SystemContext.combine(
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
)
}),
})
+2 -2
View File
@@ -15,7 +15,7 @@ import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
/**
* Composes only the shipped Location-scoped built-in tool transforms.
* Composes only the shipped Location-scoped built-in tool contributions.
* Each tool retains its implementation and focused tests independently. Dynamic
* MCP and plugin tools later use separate scoped canonical registrations, while
* provider/model filtering belongs to a future materialization phase rather
@@ -25,7 +25,7 @@ import { WriteTool } from "./write"
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
* parity, task, LSP,
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
* transforms separate from this static built-in list.
* contributions separate from this static built-in list.
*/
export const locationLayer = Layer.mergeAll(
ApplyPatchTool.layer,
+1 -1
View File
@@ -59,7 +59,7 @@ describe("AgentV2", () => {
}),
)
it.effect("removes a transform when its scope closes", () =>
it.effect("removes a transform contribution when its scope closes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped")
+1 -1
View File
@@ -45,7 +45,7 @@ describe("PluginV2", () => {
}),
)
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
it.effect("serializes same-ID additions and leaves one removable contribution", () =>
Effect.gen(function* () {
const values = state()
const layerScope = yield* Scope.fork(yield* Scope.Scope)
@@ -1,30 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Logger } from "effect"
import { logFailure } from "@opencode-ai/core/session/logging"
import { SessionSchema } from "@opencode-ai/core/session/schema"
describe("Session logging", () => {
for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) {
test(`renders the cause for ${message}`, async () => {
const entries: Array<ReturnType<typeof Logger.formatStructured.log>> = []
const logger = Logger.formatStructured.pipe(
Logger.map((entry): void => {
entries.push(entry)
}),
)
await logFailure(
message,
SessionSchema.ID.make("session-123"),
Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }),
).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise)
expect(entries).toHaveLength(1)
expect(entries[0]?.message).toBe(message)
expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" })
expect(entries[0]?.cause).toContain("SessionFailure")
expect(entries[0]?.cause).toContain("nested-code")
expect(entries[0]?.cause).not.toContain("[Object")
})
}
})
@@ -1,10 +1,34 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { describe, expect, test } from "bun:test"
import { Cause, Deferred, Effect, Equal, Exit, Fiber, Layer, Scope } from "effect"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("SessionRunCoordinator.Demand", () => {
const Demand = SessionRunCoordinator.Demand
test("combines associatively with an identity", () => {
const left = Demand.run.combine(Demand.wake(1))
const right = Demand.wake().combine(Demand.wake(3))
expect(Equal.equals(Demand.empty.combine(left), left)).toBeTrue()
expect(Equal.equals(left.combine(Demand.empty), left)).toBeTrue()
expect(Equal.equals(left.combine(right), right.combine(left))).toBeTrue()
expect(
Equal.equals(left.combine(right).combine(Demand.wake(2)), left.combine(right.combine(Demand.wake(2)))),
).toBeTrue()
})
test("keeps only sequenced wakes newer than an interrupt boundary", () => {
const demand = Demand.run.combine(Demand.wake()).combine(Demand.wake(3))
expect(Equal.equals(demand.afterBoundary(2), Demand.wake(3))).toBeTrue()
expect(Equal.equals(demand.afterBoundary(3), Demand.empty)).toBeTrue()
expect(Equal.equals(demand.afterBoundary(), Demand.empty)).toBeTrue()
})
})
describe("SessionRunCoordinator", () => {
it.effect("joins concurrent resumes for one key", () =>
Effect.scoped(
@@ -29,6 +53,51 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("allocates fresh ownership when one run effect is reused", () =>
Effect.scoped(
Effect.gen(function* () {
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) })
const run = coordinator.run("session")
yield* run
yield* run
expect(runs).toBe(2)
}),
),
)
it.effect("captures awaitIdle chains safely while settlement races", () =>
Effect.scoped(
Effect.gen(function* () {
const iterations = 500
const gates = Array.from({ length: iterations }, () => Deferred.makeUnsafe<void>())
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.suspend(() => {
const gate = gates[runs++]
return gate === undefined ? Effect.die("Missing test gate") : Deferred.await(gate)
}),
})
for (let index = 0; index < iterations; index++) {
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild({ startImmediately: true }))
const gate = gates[index]
if (gate === undefined) yield* Effect.die("Missing test gate")
yield* Deferred.succeed(gate, undefined)
yield* Effect.all([Fiber.join(run), Fiber.join(idle)])
}
expect(runs).toBe(iterations)
return undefined
}),
),
)
it.effect("starts a drain when woken while idle", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -122,6 +191,106 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("preserves a newer wake coalesced behind a pending explicit run", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (_key, mode) =>
Effect.sync(() => modes.push(mode)).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session", 1)
yield* Deferred.await(firstStarted)
const run = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", 3)
yield* coordinator.interrupt("session", 2)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
const runExit = yield* Fiber.join(run)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(modes).toEqual(["wake", "wake"])
}),
),
)
it.effect("preserves a newer wake from an interrupted active combined demand", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const thirdStarted = yield* Deferred.make<void>()
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (_key, mode) =>
Effect.sync(() => modes.push(mode)).pipe(
Effect.flatMap((run) => {
if (run === 1) return Deferred.await(firstGate)
if (run === 2) return Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Effect.never))
return Deferred.succeed(thirdStarted, undefined)
}),
),
})
yield* coordinator.wake("session", 1)
const run = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", 3)
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
yield* coordinator.interrupt("session", 2)
yield* Deferred.await(thirdStarted)
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
const runExit = yield* Fiber.join(run)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(modes).toEqual(["wake", "run", "wake"])
}),
),
)
it.effect("suppresses an older wake from an interrupted active combined demand", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (_key, mode) =>
Effect.sync(() => modes.push(mode)).pipe(
Effect.flatMap((run) => {
if (run === 1) return Deferred.await(firstGate)
return Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Effect.never))
}),
),
})
yield* coordinator.wake("session", 1)
const run = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", 2)
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
yield* coordinator.interrupt("session", 2)
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
const runExit = yield* Fiber.join(run)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(modes).toEqual(["wake", "run"])
}),
),
)
it.effect("interrupts only the requested key", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -316,6 +485,53 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("does not let an interrupted attempt completion settle its successor", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const idleSettled = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))),
),
),
})
yield* coordinator.wake("session", 1)
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
yield* coordinator.wake("session", 3)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Deferred.await(secondStarted)
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.ensuring(Deferred.succeed(idleSettled, undefined)), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(idleSettled)).toBeFalse()
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(idle)
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts an explicit run queued before the interruption request", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -847,6 +1063,37 @@ describe("SessionRunCoordinator", () => {
}),
)
it.effect("settles a post-stop run waiter when its owning scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
}).pipe(Scope.provide(scope))
yield* coordinator.wake("session")
yield* Deferred.await(started)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
const close = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild)
const runExit = yield* Fiber.await(run)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Fiber.join(close)
}),
)
it.effect("does not start work after its owning scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
+1 -1
View File
@@ -173,7 +173,7 @@ const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
registry.register({
registry.contribute({
key: systemContextKey,
load: Effect.sync(() =>
SystemContext.combine(
@@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { testEffect } from "../lib/effect"
const entry = (key: string, text: string, sourceKey = key) => ({
const contribution = (key: string, text: string, sourceKey = key) => ({
key: SystemContext.Key.make(key),
load: Effect.succeed(
SystemContext.make({
@@ -20,7 +20,7 @@ const entry = (key: string, text: string, sourceKey = key) => ({
const it = testEffect(SystemContextRegistry.layer)
describe("SystemContextRegistry", () => {
it.effect("loads empty system context when there are no entries", () =>
it.effect("loads empty system context when there are no contributions", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
@@ -28,21 +28,21 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("loads scoped entries in stable key order", () =>
it.effect("loads scoped contributions in stable key order", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.register(entry("test/second", "second"))
yield* registry.register(entry("test/first", "first"))
yield* registry.contribute(contribution("test/second", "second"))
yield* registry.contribute(contribution("test/first", "first"))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
}),
)
it.effect("re-evaluates entry producers on each load", () =>
it.effect("re-evaluates contribution producers on each load", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
let loads = 0
yield* registry.register({
yield* registry.contribute({
key: SystemContext.Key.make("test/dynamic"),
load: Effect.sync(() => {
loads++
@@ -57,11 +57,11 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("propagates entry producer failures", () =>
it.effect("propagates contribution producer failures", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const failure = new Error("entry failed")
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const failure = new Error("contribution failed")
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const exit = yield* registry.load().pipe(Effect.exit)
@@ -70,11 +70,11 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("rejects duplicate source keys from separate entries", () =>
it.effect("rejects duplicate source keys from separate contributions", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.register(entry("test/first", "first", "test/duplicate"))
yield* registry.register(entry("test/second", "second", "test/duplicate"))
yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
const exit = yield* registry.load().pipe(Effect.exit)
@@ -86,23 +86,23 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("rejects duplicate entry keys", () =>
it.effect("rejects duplicate contribution keys", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.register(entry("test/duplicate", "first"))
yield* registry.contribute(contribution("test/duplicate", "first"))
const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key")
}),
)
it.effect("removes an entry when its owning scope closes", () =>
it.effect("removes a contribution when its owning scope closes", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const scope = yield* Scope.make()
yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
-4
View File
@@ -1,6 +1,5 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -101,9 +100,6 @@ describe("SkillTool", () => {
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
})
expect(SkillTool.toModelOutput(info, [reference])).toContain(
`Base directory for this skill: ${pathToFileURL(directory).href}`,
)
expect(
yield* settleTool(registry, {
sessionID,
@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
assertAttachmentBudget,
createPickedFileAuthorizations,
MAX_ATTACHMENT_BYTES,
readAttachment,
} from "./attachment-picker"
describe("assertAttachmentBudget", () => {
test("accepts selections within the media ingest limit", () => {
expect(() =>
assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES / 2 }, { size: MAX_ATTACHMENT_BYTES / 2 }]),
).not.toThrow()
})
test("rejects the selection before files are read when its total exceeds the limit", () => {
expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES }, { size: 1 }])).toThrow("20 MB limit")
})
test("reads an approved file through a bounded buffer", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
const file = join(directory, "example.txt")
try {
await writeFile(file, "lorem ipsum")
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects an oversized file before allocating its contents", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
const file = join(directory, "oversized.txt")
try {
await writeFile(file, "")
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
})
describe("picked file authorizations", () => {
const read = async (path: string) => new TextEncoder().encode(path).buffer
test("keeps concurrent picker selections isolated", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt", "b.txt"])
const second = authorizations.add(1, ["c.txt"])
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
})
test("releases unread files for one picker without affecting another", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt"])
const second = authorizations.add(1, ["b.txt"])
authorizations.release(1, first)
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
})
test("keeps picker tokens scoped to their renderer", async () => {
const authorizations = createPickedFileAuthorizations(read)
const token = authorizations.add(1, ["a.txt"])
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
})
test("charges actual reads against the selection budget", async () => {
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
if (6 > maxBytes) throw new Error("budget exceeded")
return new ArrayBuffer(6)
}, 10)
const token = authorizations.add(1, ["a.txt", "b.txt"])
await authorizations.read(1, token, "a.txt")
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
})
})
@@ -1,56 +0,0 @@
import { randomUUID } from "node:crypto"
import { open } from "node:fs/promises"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
export function createPickedFileAuthorizations(
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
budget = MAX_ATTACHMENT_BYTES,
) {
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
return {
add(sender: number, paths: string[]) {
const token = randomUUID()
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
return token
},
async read(sender: number, token: string, path: string) {
const selection = selections.get(token)
if (selection?.sender !== sender || !selection.paths.delete(path))
throw new Error("File was not selected by the picker")
const bytes = await read(path, selection.remaining)
selection.remaining -= bytes.byteLength
if (selection.paths.size === 0) selections.delete(token)
return bytes
},
release(sender: number, token: string) {
if (selections.get(token)?.sender === sender) selections.delete(token)
},
}
}
export function assertAttachmentBudget(files: { size: number }[]) {
const total = files.reduce((sum, file) => sum + file.size, 0)
if (total <= MAX_ATTACHMENT_BYTES) return
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
}
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
const file = await open(filePath, "r")
try {
const info = await file.stat()
if (info.size > maxBytes)
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
const bytes = Buffer.allocUnsafe(info.size)
let offset = 0
while (offset < info.size) {
const result = await file.read(bytes, offset, info.size - offset, offset)
if (result.bytesRead === 0) break
offset += result.bytesRead
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
} finally {
await file.close()
}
}
+4
View File
@@ -4,4 +4,8 @@ type Channel = "dev" | "beta" | "prod"
const raw = import.meta.env.OPENCODE_CHANNEL
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
+18 -23
View File
@@ -13,7 +13,7 @@ import contextMenu from "electron-context-menu"
import type { ServerReadyData } from "../preload/types"
import { checkAppExists, resolveAppPath } from "./apps"
import { CHANNEL } from "./constants"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
@@ -26,7 +26,7 @@ import {
spawnLocalServer,
type SidecarListener,
} from "./server"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
import {
createMainWindow,
registerRendererProtocol,
@@ -145,10 +145,8 @@ const main = Effect.gen(function* () {
})
},
{
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
)
const stopSidecars = async () => {
@@ -234,13 +232,6 @@ const main = Effect.gen(function* () {
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
yield* Effect.promise(() => app.whenReady())
if (!TEST_ONBOARDING) migrate()
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(stopSidecars)
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch,
@@ -253,6 +244,7 @@ const main = Effect.gen(function* () {
},
(e) => Effect.runPromise(e),
),
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
@@ -261,17 +253,22 @@ const main = Effect.gen(function* () {
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
updater,
showUpdater: () => showUpdaterDialog(updater, true),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(stopSidecars),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
})
registerWslIpcHandlers(wslServers)
void updater.start()
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
updateTimer.unref()
app.once("will-quit", () => clearInterval(updateTimer))
yield* Effect.promise(() => app.whenReady())
if (!TEST_ONBOARDING) migrate()
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
setupAutoUpdater()
yield* Effect.promise(() => startNetLog()).pipe(
Effect.catch((error) =>
Effect.sync(() => {
@@ -329,9 +326,7 @@ const main = Effect.gen(function* () {
password,
})
if (process.platform === "win32") {
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
}
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
yield* Effect.promise(() => health.wait).pipe(
Effect.timeout("30 seconds"),
@@ -355,7 +350,7 @@ const main = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => {
void showUpdaterDialog(updater, true)
void checkForUpdates(true, stopSidecars)
},
relaunch: () => {
relaunch()
+14 -52
View File
@@ -1,29 +1,23 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import { BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import type { FatalRendererError, ServerReadyData, TitlebarTheme, WindowConfig } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore } from "./store"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
const pickedFiles = createPickedFileAuthorizations()
type Deps = {
killSidecar: () => Promise<void> | void
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
@@ -32,19 +26,18 @@ type Deps = {
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
updater: UpdaterController
showUpdater: () => Promise<void> | void
runUpdater: (alertOnFail: boolean) => Promise<void> | void
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
}
export function registerIpcHandlers(deps: Deps) {
const updaterSubscriptions = createUpdaterSubscriptions()
app.once("will-quit", updaterSubscriptions.clear)
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
@@ -57,20 +50,9 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
updaterSubscriptions.set(
id,
deps.updater.subscribe((state) => {
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
})
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
ipcMain.handle("updater-check", () => deps.updater.check())
ipcMain.handle("updater-install", () => deps.updater.install())
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
@@ -120,8 +102,8 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle(
"open-file-picker",
async (
event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
_event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
@@ -130,27 +112,10 @@ export function registerIpcHandlers(deps: Deps) {
filters: pickerFilters(opts?.extensions),
})
if (result.canceled) return null
const files = await Promise.all(
result.filePaths.map(async (filePath) => ({
path: filePath,
name: basename(filePath),
size: (await stat(filePath)).size,
})),
)
assertAttachmentBudget(files)
const token = pickedFiles.add(event.sender.id, result.filePaths)
return { token, files }
return opts?.multiple ? result.filePaths : result.filePaths[0]
},
)
ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
return pickedFiles.read(event.sender.id, token, filePath)
})
ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
pickedFiles.release(event.sender.id, token)
})
ipcMain.handle(
"save-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
@@ -226,10 +191,7 @@ export function registerIpcHandlers(deps: Deps) {
setTitlebar(win, theme)
})
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
})
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action)
})
}
+2 -3
View File
@@ -2,10 +2,9 @@ import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { getLogger } from "./logging"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
export type HealthCheck = { wait: Promise<void> }
@@ -44,7 +43,7 @@ export function setDefaultServerUrl(url: string | null) {
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
Object.assign(process.env, {
...(shell ? loadShellEnv(shell, getLogger()) : null),
...(shell ? loadShellEnv(shell) : null),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
+3 -4
View File
@@ -1,13 +1,11 @@
import { spawnSync } from "node:child_process"
import { userInfo } from "node:os"
import { basename } from "node:path"
import { getLogger } from "./logging"
const TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
type ShellEnvLogger = {
log: (message: string) => void
}
export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
@@ -67,7 +65,8 @@ export function isNushell(shell: string) {
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
}
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
export function loadShellEnv(shell: string) {
const logger = getLogger()
if (isNushell(shell)) {
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
return null
-4
View File
@@ -1,4 +0,0 @@
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
+3 -3
View File
@@ -1,7 +1,7 @@
import Store from "electron-store"
import electron from "electron"
import { app } from "electron"
import { SETTINGS_STORE } from "./store-keys"
import { SETTINGS_STORE } from "./constants"
const cache = new Map<string, Store>()
@@ -14,7 +14,7 @@ export function getStore(name = SETTINGS_STORE) {
if (cached) return cached
const next = new Store({
name,
cwd: electron.app.getPath("userData"),
cwd: app.getPath("userData"),
fileExtension: "",
accessPropertiesByDotNotation: false,
})
@@ -1,111 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
const calls: string[] = []
const backend: UpdaterBackend = {
async checkForUpdates() {
calls.push("check")
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
},
async downloadUpdate() {
calls.push("download")
},
quitAndInstall() {
calls.push("install")
},
}
let ready = input?.ready
const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0",
backend,
persistence: {
get: () => ready,
set: (value) => {
ready = value
},
clear: () => {
ready = undefined
},
},
stop: async () => {
calls.push("stop")
},
})
return { controller, calls, getReady: () => ready }
}
describe("updater controller", () => {
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
const app = setup()
const states: ReturnType<typeof app.controller.getState>[] = []
app.controller.subscribe((state) => states.push(state))
await app.controller.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.getReady()).toEqual({ version: "2.0.0" })
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("revalidates a persisted target through the updater cache on launch", async () => {
const app = setup({ ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("clears a target already installed before checking", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.getReady()).toBeUndefined()
expect(app.calls).toEqual(["check"])
})
test("coalesces concurrent checks", async () => {
const app = setup()
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
expect(app.calls).toEqual(["check", "download"])
})
test("returns to ready when quitAndInstall returns without exiting", async () => {
const app = setup()
await app.controller.start()
await app.controller.install()
expect(app.calls).toEqual(["check", "download", "stop", "install"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("returns to ready when installation cannot start", async () => {
const app = setup()
await app.controller.start()
const failed = createUpdaterController({
enabled: true,
currentVersion: "1.0.0",
backend: {
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
downloadUpdate: async () => {},
quitAndInstall() {},
},
persistence: { get: () => undefined, set() {}, clear() {} },
stop: async () => {
throw new Error("stop failed")
},
})
await failed.start()
await expect(failed.install()).rejects.toThrow("stop failed")
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
})
@@ -1,97 +0,0 @@
import type { UpdaterState } from "@opencode-ai/app/updater"
export type { UpdaterState } from "@opencode-ai/app/updater"
export type UpdaterReadyRecord = { version: string }
export type UpdaterBackend = {
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
downloadUpdate(): Promise<unknown>
quitAndInstall(): void
}
type UpdaterPersistence = {
get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
set(value: UpdaterReadyRecord): void | Promise<void>
clear(): void | Promise<void>
}
export function createUpdaterController(input: {
enabled: boolean
currentVersion: string
backend: UpdaterBackend
persistence: UpdaterPersistence
stop: () => Promise<void>
log?: (message: string, data?: object) => void
}) {
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
const transition = (next: UpdaterState) => {
input.log?.("updater state changed", { from: state.status, to: next.status })
state = next
listeners.forEach((listener) => listener(state))
return state
}
const check = () => {
if (!input.enabled) return Promise.resolve(state)
if (state.status === "ready") return Promise.resolve(state)
if (pending) return pending
pending = (async () => {
transition({ status: "checking" })
const result = await input.backend.checkForUpdates()
const version = result?.updateInfo?.version
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
await input.persistence.clear()
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
await input.backend.downloadUpdate()
await input.persistence.set({ version })
return transition({ status: "ready", version })
})()
.catch((error) =>
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
)
.finally(() => {
pending = undefined
})
return pending
}
return {
getState: () => state,
subscribe(listener: (state: UpdaterState) => void) {
listeners.add(listener)
listener(state)
return () => listeners.delete(listener)
},
async start() {
const ready = await input.persistence.get()
if (ready?.version === input.currentVersion) await input.persistence.clear()
return check()
},
check,
async install() {
if (state.status !== "ready") throw new Error("Update is not ready to install")
const version = state.version
transition({ status: "installing", version })
await input
.stop()
.then(() => {
input.backend.quitAndInstall()
transition({ status: "ready", version })
})
.catch((error) => {
transition({ status: "ready", version })
throw error
})
},
}
}
export type UpdaterController = ReturnType<typeof createUpdaterController>
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
describe("updater subscriptions", () => {
test("replaces the previous renderer subscription on reload", () => {
const subscriptions = createUpdaterSubscriptions()
const disposed: string[] = []
subscriptions.set(1, () => disposed.push("first"))
subscriptions.set(1, () => disposed.push("second"))
expect(disposed).toEqual(["first"])
subscriptions.delete(1)
expect(disposed).toEqual(["first", "second"])
})
})
@@ -1,20 +0,0 @@
export function createUpdaterSubscriptions() {
const subscriptions = new Map<number, () => void>()
const remove = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
return {
set(id: number, unsubscribe: () => void) {
remove(id)
subscriptions.set(id, unsubscribe)
},
delete: remove,
clear() {
subscriptions.forEach((unsubscribe) => unsubscribe())
subscriptions.clear()
},
}
}
+96 -32
View File
@@ -1,14 +1,15 @@
import { app, dialog } from "electron"
import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
const { autoUpdater } = pkg
const key = "ready"
type UpdateCheckResult = { updateAvailable: boolean; version?: string; failed?: boolean }
let downloadedVersion: string | undefined
let pendingCheck: Promise<UpdateCheckResult> | undefined
export function setupAutoUpdater(stop: () => Promise<void>) {
export function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
const logger = getLogger()
autoUpdater.logger = logger
autoUpdater.channel = "latest"
@@ -22,47 +23,110 @@ export function setupAutoUpdater(stop: () => Promise<void>) {
allowDowngrade: autoUpdater.allowDowngrade,
currentVersion: app.getVersion(),
})
const store = getStore("opencode.updater")
return createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: app.getVersion(),
backend: autoUpdater,
persistence: {
get() {
const value = store.get(key)
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
return { version: value.version } satisfies UpdaterReadyRecord
},
set: (value) => store.set(key, value),
clear: () => store.delete(key),
},
stop,
log: (message, data) => logger.log(message, data),
})
}
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
const state = await controller.check()
if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
export async function checkUpdate(): Promise<UpdateCheckResult> {
if (!UPDATER_ENABLED) return { updateAvailable: false }
if (downloadedVersion) return { updateAvailable: true, version: downloadedVersion }
if (pendingCheck) return pendingCheck
pendingCheck = checkAndDownloadUpdate().finally(() => {
pendingCheck = undefined
})
return pendingCheck
}
async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
const logger = getLogger()
logger.log("checking for updates", {
currentVersion: app.getVersion(),
channel: autoUpdater.channel,
allowPrerelease: autoUpdater.allowPrerelease,
allowDowngrade: autoUpdater.allowDowngrade,
})
try {
const result = await autoUpdater.checkForUpdates()
const updateInfo = result?.updateInfo
logger.log("update metadata fetched", {
releaseVersion: updateInfo?.version ?? null,
releaseDate: updateInfo?.releaseDate ?? null,
releaseName: updateInfo?.releaseName ?? null,
files: updateInfo?.files?.map((file) => file.url) ?? [],
})
const version = result?.updateInfo?.version
if (result?.isUpdateAvailable === false || !version) {
logger.log("no update available", {
reason: "provider returned no newer version",
})
return { updateAvailable: false }
}
logger.log("update available", { version })
await autoUpdater.downloadUpdate()
downloadedVersion = version
logger.log("update download completed", { version })
return { updateAvailable: true, version }
} catch (error) {
logger.error("update check failed", error)
return { updateAvailable: false, failed: true }
}
}
export async function installUpdate(killSidecar: () => Promise<void>) {
const result = downloadedVersion ? { updateAvailable: true, version: downloadedVersion } : await checkUpdate()
const logger = getLogger()
if (!result.updateAvailable || !downloadedVersion) {
logger.log("install update skipped", {
reason: result.failed ? "update check failed" : "no update available",
})
return
}
if (state.status === "up-to-date") {
logger.log("installing downloaded update", {
version: result.version ?? null,
})
await killSidecar()
autoUpdater.quitAndInstall()
}
export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
if (!UPDATER_ENABLED) return
const logger = getLogger()
logger.log("checkForUpdates invoked", { alertOnFail })
const result = await checkUpdate()
if (!result.updateAvailable) {
if (result.failed) {
logger.log("no update decision", { reason: "update check failed" })
if (!alertOnFail) return
await dialog.showMessageBox({
type: "error",
message: "Update check failed.",
title: "Update Error",
})
return
}
logger.log("no update decision", { reason: "already up to date" })
if (!alertOnFail) return
await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
await dialog.showMessageBox({
type: "info",
message: "You're up to date.",
title: "No Updates",
})
return
}
if (state.status !== "ready") return
const response = await dialog.showMessageBox({
type: "info",
message: `Update ${state.version} downloaded. Restart now?`,
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
title: "Update Ready",
buttons: ["Restart", "Later"],
defaultId: 0,
cancelId: 1,
})
if (response.response === 0) await controller.install()
logger.log("update prompt response", {
version: result.version ?? null,
restartNow: response.response === 0,
})
if (response.response === 0) {
await installUpdate(killSidecar)
}
}
+1 -1
View File
@@ -6,9 +6,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store"
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url))
-43
View File
@@ -2,14 +2,8 @@ import { app, ipcMain } from "electron"
import type { IpcMainInvokeEvent } from "electron"
import type { WslServersController } from "./servers"
import { requireWslIpcString } from "./policy"
import type { WslServersState } from "../../preload/types"
export function registerWslIpcHandlers(controller: WslServersController) {
if (process.platform !== "win32") {
registerUnavailableWslIpcHandlers()
return
}
const subscriptions = new Map<number, () => void>()
const unsubscribe = (id: number) => {
const off = subscriptions.get(id)
@@ -68,40 +62,3 @@ export function registerWslIpcHandlers(controller: WslServersController) {
controller.startServer(requireWslIpcString("server id", id)),
)
}
function registerUnavailableWslIpcHandlers() {
const unavailable = () => {
throw new Error("WSL is only available on Windows")
}
const state = (): WslServersState => ({
runtime: {
available: false,
version: null,
error: "WSL is only available on Windows",
},
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
})
ipcMain.handle("wsl-servers-subscribe", (event) => {
event.sender.send("wsl-servers-event", { type: "state", state: state() })
})
ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
ipcMain.handle("wsl-servers-get-state", () => state())
ipcMain.handle("wsl-servers-probe-runtime", unavailable)
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
ipcMain.handle("wsl-servers-install-wsl", unavailable)
ipcMain.handle("wsl-servers-install-distro", unavailable)
ipcMain.handle("wsl-servers-probe-distro", unavailable)
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
ipcMain.handle("wsl-servers-install-opencode", unavailable)
ipcMain.handle("wsl-servers-open-terminal", unavailable)
ipcMain.handle("wsl-servers-add", unavailable)
ipcMain.handle("wsl-servers-remove", unavailable)
ipcMain.handle("wsl-servers-start", unavailable)
}
@@ -6,10 +6,6 @@ import {
pollWslHealth,
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
test("starts every configured WSL server on initialization", () => {
expect(
@@ -95,73 +91,3 @@ test("derives a required Windows restart from the post-install runtime probe", (
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
})
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
persistedServers = []
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => ({
listener: {
stop: () => undefined,
onExit: () => undefined,
},
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
}),
testControllerOptions(),
)
await controller.addServer("Debian")
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => new Promise<never>(() => undefined),
testControllerOptions(),
)
await controller.initialize()
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
async function waitFor(check: () => boolean) {
for (let attempt = 0; attempt < 20; attempt++) {
if (check()) return
await new Promise((resolve) => setTimeout(resolve, 0))
}
throw new Error("Timed out waiting for condition")
}
function testControllerOptions() {
return {
readServers: () => persistedServers,
writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers
},
readCommandVersion: async () => "1.16.2",
resolveOpencode: async () => {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.opencode/bin/opencode"
},
}
}
+13 -72
View File
@@ -11,7 +11,7 @@ import type {
WslServersEvent,
WslServersState,
} from "../../preload/types"
import { WSL_SERVERS_KEY } from "../store-keys"
import { WSL_SERVERS_KEY } from "../constants"
import { getStore } from "../store"
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
@@ -43,33 +43,18 @@ type ControllerLogger = {
error: (message: string, meta?: unknown) => void
}
type WslServersControllerOptions = {
logger?: ControllerLogger
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
}
export type WslServersController = ReturnType<typeof createWslServersController>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(
appVersion: string,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -81,7 +66,7 @@ export function createWslServersController(
}
const persistServers = (servers: WslServerConfig[]) => {
writeServers(servers)
getStore().set(WSL_SERVERS_KEY, { servers })
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
@@ -104,7 +89,7 @@ export function createWslServersController(
}
const refreshFromStore = () => {
const persisted = readServers()
const persisted = readPersistedServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
@@ -128,52 +113,10 @@ export function createWslServersController(
})
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, version, appVersion)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
setOpencodeCheck(distro, await checkOpencode(distro, opts))
}
const hasServer = (id: string, distro: string) => {
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
}
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
void checkOpencode(distro)
.then((check) => {
if (!hasServer(id, distro)) return
setOpencodeCheck(distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro, message })
})
}
const refreshOpencodeChecks = async () => {
await Promise.all(
state.servers.map((item) =>
checkOpencode(item.config.distro)
.then((check) => {
if (!hasServer(item.config.id, item.config.distro)) return
setOpencodeCheck(item.config.distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", {
id: item.config.id,
distro: item.config.distro,
message,
})
}),
),
)
const resolved = await resolveWslOpencode(distro, opts)
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
}
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
@@ -227,7 +170,10 @@ export function createWslServersController(
setRuntime(id, { kind: "failed", message })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
refreshOpencodeCheckBackground(id, item.config.distro)
void refreshOpencodeCheck(item.config.distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
})
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -279,7 +225,6 @@ export function createWslServersController(
async initialize() {
refreshFromStore()
void refreshOpencodeChecks()
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
},
@@ -366,7 +311,7 @@ export function createWslServersController(
id,
distro,
}
persistServers([...readServers(), config])
persistServers([...readPersistedServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
@@ -378,7 +323,7 @@ export function createWslServersController(
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
invalidateStartAttempt(id)
await stopServerInternal(id)
const remaining = readServers().filter((item) => item.id !== id)
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({
servers: state.servers.filter((item) => item.config.id !== id),
@@ -426,10 +371,6 @@ function readPersistedServers(): WslServerConfig[] {
return []
}
function writePersistedServers(servers: WslServerConfig[]) {
getStore().set(WSL_SERVERS_KEY, { servers })
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
+4 -31
View File
@@ -1,14 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, WslServersEvent } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
const updaterHandler = (_: unknown, state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
@@ -37,26 +28,7 @@ const api: ElectronAPI = {
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
ipcRenderer.on("updater-state", updaterHandler)
updaterSubscription = ipcRenderer.invoke("updater-subscribe")
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
ipcRenderer.removeListener("updater-state", updaterHandler)
updaterSubscription = undefined
void ipcRenderer.invoke("updater-unsubscribe")
}
},
check: () => ipcRenderer.invoke("updater-check"),
install: () => ipcRenderer.invoke("updater-install"),
},
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
@@ -86,8 +58,6 @@ const api: ElectronAPI = {
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path),
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
@@ -113,6 +83,9 @@ const api: ElectronAPI = {
},
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
+10 -10
View File
@@ -1,6 +1,5 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { UpdaterState } from "@opencode-ai/app/updater"
export type {
WslDistroProbe,
WslInstalledDistro,
@@ -22,16 +21,15 @@ export type ServerReadyData = {
}
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void>
check: () => Promise<UpdaterState>
install: () => Promise<void>
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
}
export type WindowConfig = {
updaterEnabled: boolean
}
export type FatalRendererError = {
error: string
url: string
@@ -45,7 +43,7 @@ export type ElectronAPI = {
installCli: () => Promise<string>
awaitInitialization: () => Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
getWindowConfig: () => Promise<WindowConfig>
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
@@ -74,10 +72,9 @@ export type ElectronAPI = {
multiple?: boolean
title?: string
defaultPath?: string
accept?: string[]
extensions?: string[]
}) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null>
readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
releasePickedFiles: (token: string) => Promise<void>
}) => Promise<string | string[] | null>
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
@@ -95,6 +92,9 @@ export type ElectronAPI = {
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
setTitlebar: (theme: TitlebarTheme) => Promise<void>
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void>
setBackgroundColor: (color: string) => Promise<void>
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
+15 -20
View File
@@ -2,6 +2,7 @@
import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
AppBaseProviders,
AppInterface,
handleNotificationClick,
@@ -14,11 +15,10 @@ import {
useCommand,
useWslServers,
} from "@opencode-ai/app"
import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
@@ -59,9 +59,6 @@ if (import.meta.env.VITE_SENTRY_DSN) {
void initI18n()
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
void window.api.updater.subscribe(setUpdaterState)
const deepLinkEvent = "opencode:deep-link"
const emitDeepLinks = (urls: string[]) => {
@@ -143,21 +140,13 @@ const createPlatform = (): Platform => {
})
},
async openAttachmentPickerDialog(opts, onFile) {
const result = await window.api.openFilePicker({
async openFilePickerDialog(opts) {
return window.api.openFilePicker({
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
defaultPath: opts?.defaultPath,
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
})
if (!result) return
try {
for (const file of result.files) {
await onFile(new File([await window.api.readPickedFile(result.token, file.path)], file.name))
}
} finally {
await window.api.releasePickedFiles(result.token)
}
},
async saveFilePickerDialog(opts) {
@@ -188,10 +177,16 @@ const createPlatform = (): Platform => {
storage,
updater: {
state: updaterState,
check: () => window.api.updater.check(),
install: () => window.api.updater.install(),
checkUpdate: async () => {
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
if (!config.updaterEnabled) return { updateAvailable: false }
return window.api.checkUpdate()
},
updateAndRestart: async () => {
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
if (!config.updaterEnabled) return
await window.api.installUpdate()
},
exportDebugLogs: () => window.api.exportDebugLogs(),
+12
View File
@@ -0,0 +1,12 @@
import { initI18n, t } from "./i18n"
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n()
try {
await window.api.runUpdater(alertOnFail)
} catch {
if (alertOnFail) {
window.alert(t("desktop.updater.checkFailed.message"))
}
}
}
+3 -2
View File
@@ -80,7 +80,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -89,7 +89,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -111,6 +111,7 @@
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",
"diff": "catalog:",
+386 -1
View File
@@ -1 +1,386 @@
export { default } from "@opencode-ai/tui/parsers-config"
export default {
// NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
// Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
// marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
// ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
parsers: [
{
filetype: "python",
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
// Unclear.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
],
},
},
{
filetype: "rust",
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
],
},
},
{
filetype: "go",
wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
],
},
},
{
filetype: "cpp",
wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
],
},
},
{
filetype: "csharp",
wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
],
},
},
{
filetype: "bash",
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
],
},
},
{
filetype: "c",
wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
],
},
},
{
filetype: "java",
wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
],
},
},
{
filetype: "kotlin",
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
},
},
{
filetype: "ruby",
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
],
},
},
{
filetype: "php",
wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
],
},
},
{
filetype: "scala",
wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
],
},
},
{
filetype: "html",
wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
],
// TODO: Injections not working for some reason
// injections: [
// "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
// ],
},
// injectionMapping: {
// nodeTypes: {
// script_element: "javascript",
// style_element: "css",
// },
// infoStringMap: {
// javascript: "javascript",
// css: "css",
// },
// },
},
{
filetype: "vue",
wasm: "https://github.com/anomalyco/tree-sitter-vue/releases/download/v0.1.2/tree-sitter-vue.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/html_tags/highlights.scm",
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/vue/highlights.scm",
],
},
},
{
filetype: "hcl",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
],
},
},
{
filetype: "json",
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
],
},
},
{
filetype: "yaml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm",
],
},
},
{
filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
],
},
},
{
filetype: "css",
wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
],
},
},
{
filetype: "julia",
wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
],
},
},
{
filetype: "lua",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
],
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
},
},
{
filetype: "ocaml",
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
],
},
},
{
filetype: "clojure",
// temporarily using fork to fix issues
wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
],
},
},
{
filetype: "swift",
wasm: "https://github.com/alex-pinkus/tree-sitter-swift/releases/download/0.7.1/tree-sitter-swift.wasm",
queries: {
highlights: [
// NOTE: Using parser repo queries instead of nvim-treesitter due to incompatible #lua-match? predicates
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/highlights.scm
"https://raw.githubusercontent.com/alex-pinkus/tree-sitter-swift/main/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/swift/locals.scm",
],
},
},
{
filetype: "toml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
],
},
},
{
filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
],
},
},
{
filetype: "diff",
aliases: ["udiff", "patch"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm",
],
},
},
{
filetype: "elixir",
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
],
},
},
{
filetype: "fsharp",
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
],
},
},
{
filetype: "r",
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
],
},
},
{
filetype: "make",
aliases: ["makefile"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
],
},
},
{
filetype: "vim",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
],
},
},
{
filetype: "xml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
],
},
},
{
filetype: "agda",
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
],
},
},
],
}
+1 -1
View File
@@ -158,7 +158,7 @@ for (const item of targets) {
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/tui/worker.ts"
const workerPath = "./src/cli/cmd/tui/worker.ts"
// Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
+2 -2
View File
@@ -2,8 +2,8 @@
import { Config } from "@/config/config"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Schema } from "effect"
import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema"
type JsonSchema = Record<string, unknown>
const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model"
@@ -73,5 +73,5 @@ await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null,
if (tuiFile) {
console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2))
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2))
}
+1 -1
View File
@@ -5,7 +5,7 @@ import * as ts from "typescript"
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
// Get entry file from command line arg or use default
const ENTRY_FILE = process.argv[2] || "src/plugin/tui/runtime.ts"
const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
const visited = new Set<string>()
-1
View File
@@ -330,7 +330,6 @@ export class Subscription {
...pendingToolCall({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
+4 -8
View File
@@ -118,18 +118,14 @@ export function completedToolContent(toolName: string, state: CompletedToolState
return content
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
}): ToolCall {
export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall {
return {
toolCallId: input.toolCallId,
title: input.state.title || input.toolName,
title: input.toolName,
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
locations: [],
rawInput: {},
}
}

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