Compare commits

..

38 Commits

Author SHA1 Message Date
Adam
0a53f8e084 chore: cleanup 2026-03-11 13:56:16 -05:00
Adam
6f5b2f786e wip: node-pty 2026-03-11 13:47:20 -05:00
Luke Parker
7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax
6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad
cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad
b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad
040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad
4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad
a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad
4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad
21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad
5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad
d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad
d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad
070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad
406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad
5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker
2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad
b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad
3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad
2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad
58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad
0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad
58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad
0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad
ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad
b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad
124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad
85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad
a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad
528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad
0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Dax
27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax
173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad
e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad
656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
139 changed files with 3432 additions and 6118 deletions

View File

@@ -149,10 +149,6 @@ jobs:
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4

View File

@@ -1,4 +1,6 @@
plans/
bun.lock
node_modules
plans
package.json
package-lock.json
bun.lock
.gitignore
package-lock.json

View File

@@ -1,912 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { extend, useKeyboard, useTerminalDimensions, type RenderableConstructor } from "@opentui/solid"
import { RGBA, VignetteEffect, type OptimizedBuffer, type RenderContext } from "@opentui/core"
import { ThreeRenderable, THREE } from "@opentui/core/3d"
import type { TuiApi, TuiKeybindSet, TuiPluginInit, TuiPluginInput } from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
}
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const num = (value: unknown, fallback: number) => {
if (typeof value !== "number") return fallback
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object") return
return value as Record<string, unknown>
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
}
type Route = {
modal: string
screen: string
}
type State = {
tab: number
count: number
source: string
note: string
selected: string
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
}
}
const names = (input: Cfg) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
}
}
type Keys = TuiKeybindSet
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const tone = (api: TuiApi) => {
const map = api.theme.current as Record<string, unknown>
const get = (name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value && typeof value === "object") return value as RGBA
return fallback
}
return {
panel: get("backgroundPanel", ui.panel),
border: get("border", ui.border),
text: get("text", ui.text),
muted: get("textMuted", ui.muted),
accent: get("primary", ui.accent),
selected: get("selectedListItemText", ui.text),
}
}
type Skin = {
panel: Color
border: Color
text: Color
muted: Color
accent: Color
selected: Color
}
type CubeOpts = ConstructorParameters<typeof ThreeRenderable>[1] & {
tint?: Color
spec?: Color
ambient?: Color
key_light?: Color
fill_light?: Color
}
const rgb = (value: unknown, fallback: string) => {
if (typeof value === "string") return new THREE.Color(value)
if (value && typeof value === "object") {
const item = value as { r?: unknown; g?: unknown; b?: unknown }
if (typeof item.r === "number" && typeof item.g === "number" && typeof item.b === "number") {
return new THREE.Color(item.r, item.g, item.b)
}
}
return new THREE.Color(fallback)
}
class Cube extends ThreeRenderable {
private cube: THREE.Mesh
private mat: THREE.MeshPhongMaterial
private amb: THREE.AmbientLight
private key: THREE.DirectionalLight
private fill: THREE.DirectionalLight
constructor(ctx: RenderContext, opts: CubeOpts) {
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100)
camera.position.set(0, 0, 2.55)
const amb = new THREE.AmbientLight(rgb(opts.ambient, "#666666"), 1.0)
scene.add(amb)
const key = new THREE.DirectionalLight(rgb(opts.key_light, "#fff2e6"), 1.2)
key.position.set(2.5, 2.0, 3.0)
scene.add(key)
const fill = new THREE.DirectionalLight(rgb(opts.fill_light, "#80b3ff"), 0.6)
fill.position.set(-2.0, -1.5, 2.5)
scene.add(fill)
const geo = new THREE.BoxGeometry(1.0, 1.0, 1.0)
const mat = new THREE.MeshPhongMaterial({
color: rgb(opts.tint, "#40ccff"),
shininess: 80,
specular: rgb(opts.spec, "#e6e6ff"),
})
const cube = new THREE.Mesh(geo, mat)
cube.scale.setScalar(1.12)
scene.add(cube)
super(ctx, {
...opts,
scene,
camera,
renderer: {
focalLength: 8,
alpha: true,
backgroundColor: RGBA.fromValues(0, 0, 0, 0),
},
})
this.cube = cube
this.mat = mat
this.amb = amb
this.key = key
this.fill = fill
}
set tint(value: Color | undefined) {
this.mat.color.copy(rgb(value, "#40ccff"))
}
set spec(value: Color | undefined) {
this.mat.specular.copy(rgb(value, "#e6e6ff"))
}
set ambient(value: Color | undefined) {
this.amb.color.copy(rgb(value, "#666666"))
}
set key_light(value: Color | undefined) {
this.key.color.copy(rgb(value, "#fff2e6"))
}
set fill_light(value: Color | undefined) {
this.fill.color.copy(rgb(value, "#80b3ff"))
}
protected override renderSelf(buf: OptimizedBuffer, dt: number): void {
const delta = dt / 1000
this.cube.rotation.x += delta * 0.6
this.cube.rotation.y += delta * 0.4
this.cube.rotation.z += delta * 0.2
super.renderSelf(buf, dt)
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
smoke_cube: RenderableConstructor
}
}
extend({ smoke_cube: Cube as unknown as RenderableConstructor })
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
return (
<box
onMouseUp={() => {
props.run()
}}
backgroundColor={props.on ? props.skin.accent : props.skin.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
</box>
)
}
const parse = (params: Record<string, unknown> | undefined) => {
const tab = typeof params?.tab === "number" ? params.tab : 0
const count = typeof params?.count === "number" ? params.count : 0
const source = typeof params?.source === "string" ? params.source : "unknown"
const note = typeof params?.note === "string" ? params.note : ""
const selected = typeof params?.selected === "string" ? params.selected : ""
const local = typeof params?.local === "number" ? params.local : 0
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
local: Math.max(0, local),
}
}
const current = (api: TuiApi, route: Route) => {
const value = api.route.current
const ok = Object.values(route).includes(value.name)
if (!ok) return parse(undefined)
if (!("params" in value)) return parse(undefined)
return parse(value.params)
}
const opts = [
{
title: "Overview",
value: 0,
description: "Switch to overview tab",
},
{
title: "Counter",
value: 1,
description: "Switch to counter tab",
},
{
title: "Help",
value: 2,
description: "Switch to help tab",
},
]
const host = (api: TuiApi, input: Cfg, skin: Skin) => {
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{input.label} host overlay</b>
</text>
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
<box flexDirection="row" gap={1}>
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
</box>
</box>
))
}
const warn = (api: TuiApi, route: Route, value: State) => {
const DialogAlert = api.ui.DialogAlert
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
/>
))
}
const check = (api: TuiApi, route: Route, value: State) => {
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
/>
))
}
const entry = (api: TuiApi, route: Route, value: State) => {
const DialogPrompt = api.ui.DialogPrompt
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogPrompt
title="Smoke prompt"
value={value.note}
onConfirm={(note) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
}}
onCancel={() => {
api.ui.dialog.clear()
api.route.navigate(route.screen, value)
}}
/>
))
}
const picker = (api: TuiApi, route: Route, value: State) => {
const DialogSelect = api.ui.DialogSelect
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogSelect
title="Smoke select"
options={opts}
current={value.tab}
onSelect={(item) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}}
/>
))
}
const Screen = (props: {
api: TuiApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginInit
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
const set = (local: number, base?: State) => {
const next = base ?? current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
}
const push = (base?: State) => {
const next = base ?? current(props.api, props.route)
set(next.local + 1, next)
}
const open = () => {
const next = current(props.api, props.route)
if (next.local > 0) return
set(1, next)
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={skin.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
{tabs.map((item, i) => {
const on = value.tab === i
return (
<Btn
txt={item}
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
skin={skin}
on={on}
/>
)
})}
</box>
<box
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexGrow={1}
>
{value.tab === 0 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Route: {props.route.screen}</text>
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
<text fg={skin.muted}>
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.entry.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.entry.source}</text>
<text fg={skin.muted}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
<text fg={skin.muted}>local stack depth: {value.local}</text>
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box>
) : null}
</box>
<box flexDirection="row" gap={1} paddingTop={1}>
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
<Btn txt="local overlay" run={show} skin={skin} />
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
</box>
</box>
<box
visible={value.local > 0}
width={dim().width}
height={dim().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dim().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
onMouseUp={() => {
pop()
}}
>
<box
onMouseUp={(evt) => {
evt.stopPropagation()
}}
width={60}
maxWidth={dim().width - 2}
backgroundColor={skin.panel}
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
flexDirection="column"
>
<text fg={skin.text}>
<b>{props.input.label} local overlay</b>
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
<Btn txt="pop" run={pop} skin={skin} />
</box>
</box>
</box>
</box>
)
}
const Modal = (props: { api: TuiApi; input: Cfg; route: Route; keys: Keys; params?: Record<string, unknown> }) => {
const Dialog = props.api.ui.Dialog
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
}
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate("home")}>
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
txt="open screen"
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
skin={skin}
on
/>
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
</box>
</box>
</Dialog>
</box>
)
}
const slot = (input: Cfg) => ({
id: "workspace-smoke",
slots: {
home_logo(ctx) {
const map = ctx.theme.current as Record<string, unknown>
const get = (name: string, fallback: string) => {
const value = map[name]
if (typeof value === "string") return value
if (value && typeof value === "object") return value as RGBA
return fallback
}
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const ink = [
get("primary", ui.accent),
get("textMuted", ui.muted),
get("info", ui.accent),
get("text", ui.text),
get("success", ui.accent),
get("warning", ui.accent),
get("secondary", ui.accent),
get("error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={ink[i]}>{line}</text>
))}
</box>
)
},
sidebar_top(ctx, value) {
const map = ctx.theme.current as Record<string, unknown>
const get = (name: string, fallback: string) => {
const item = map[name]
if (typeof item === "string") return item
if (item && typeof item === "object") return item as RGBA
return fallback
}
return (
<smoke_cube
id={`smoke-cube-${value.session_id.slice(0, 8)}`}
width="100%"
height={16}
tint={get("primary", ui.accent)}
spec={get("text", ui.text)}
ambient={get("textMuted", ui.muted)}
key_light={get("success", ui.accent)}
fill_light={get("info", ui.accent)}
/>
)
},
},
})
const reg = (api: TuiApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
},
onSelect: () => {
warn(api, route, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
check(api, route, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
keybind: keys.get("host"),
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
}
const tui = async (input: TuiPluginInput, options: Record<string, unknown> | null, meta: TuiPluginInit) => {
if (options?.enabled === false) return
await input.api.theme.install("./smoke-theme.json")
input.api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = input.api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
input.renderer.addPostProcessFn(fx.apply.bind(fx))
input.api.route.register([
{
name: route.screen,
render: ({ params }) => (
<Screen api={input.api} input={value} route={route} keys={keys} meta={meta} params={params} />
),
},
{
name: route.modal,
render: ({ params }) => <Modal api={input.api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(input.api, value, keys)
input.slots.register(slot(value))
}
export default {
tui,
}

View File

@@ -1 +0,0 @@
smoke-theme.json

View File

@@ -48,11 +48,11 @@
"light": "nord10"
},
"text": {
"dark": "nord6",
"dark": "nord4",
"light": "nord0"
},
"textMuted": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord1"
},
"background": {
@@ -64,7 +64,7 @@
"light": "nord5"
},
"backgroundElement": {
"dark": "nord2",
"dark": "nord1",
"light": "nord4"
},
"border": {
@@ -88,11 +88,11 @@
"light": "nord11"
},
"diffContext": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"diffHighlightAdded": {
@@ -104,12 +104,12 @@
"light": "nord11"
},
"diffAddedBg": {
"dark": "#36413C",
"light": "#E6EBE7"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffRemovedBg": {
"dark": "#43393D",
"light": "#ECE6E8"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffContextBg": {
"dark": "nord1",
@@ -120,12 +120,12 @@
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#303A35",
"light": "#DDE4DF"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffRemovedLineNumberBg": {
"dark": "#3C3336",
"light": "#E4DDE0"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"markdownText": {
"dark": "nord4",
@@ -148,7 +148,7 @@
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"markdownEmph": {
@@ -160,7 +160,7 @@
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"markdownListItem": {
@@ -184,7 +184,7 @@
"light": "nord0"
},
"syntaxComment": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"syntaxKeyword": {

View File

@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
@@ -24,7 +23,16 @@ interface PR {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),

View File

@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
@@ -40,7 +39,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])

View File

@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.

View File

@@ -1,19 +0,0 @@
{
"$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": true,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
}

View File

@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
#### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
It's very similar to Claude Code in terms of capability. Here are the key differences::
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

1619
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -41,9 +42,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.29",
"drizzle-kit": "1.0.0-beta.16-c2458b2",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -98,6 +99,7 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",

View File

@@ -9,12 +9,14 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await expect(terminal).not.toBeVisible()
await prompt.fill("/terminal")
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await prompt.fill("/terminal")
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()

View File

@@ -1,6 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const terminalSelector = '[data-component="terminal"]'
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'

View File

@@ -1,186 +0,0 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const head = Array.from(document.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 2]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(() => view.evaluate((el) => el.scrollTop)).toBeGreaterThan(200)
const prev = await view.evaluate((el) => el.scrollTop)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(async () => Math.abs((await view.evaluate((el) => el.scrollTop)) - prev), { timeout: 60_000 })
.toBeLessThanOrEqual(16)
})
})
})

View File

@@ -490,18 +490,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setComposing(false)
}
const handleCompositionStart = () => {
setComposing(true)
}
const handleCompositionEnd = () => {
setComposing(false)
requestAnimationFrame(() => {
if (composing()) return
reconcile(prompt.current().filter((part) => part.type !== "image"))
})
}
const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -692,27 +680,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const reconcile = (input: Prompt) => {
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(input)
return
}
const dom = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
renderEditorWithCursor(input)
}
createEffect(
on(
() => prompt.current(),
(parts) => {
if (composing()) return
reconcile(parts.filter((part) => part.type !== "image"))
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image")
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(inputParts)
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
renderEditorWithCursor(inputParts)
},
),
)
@@ -1223,8 +1208,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
spellcheck={store.mode === "normal"}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{

View File

@@ -185,9 +185,7 @@ export function StatusPopover() {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() =>
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => {

View File

@@ -862,36 +862,6 @@ export default function Page() {
</div>
)
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "turn") return emptyTurn()
if (hasReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (reviewEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">Create a Git repository</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
Track, review, and undo changes in this project
</div>
</div>
<Button size="large" disabled={ui.git} onClick={initGit}>
{ui.git ? "Creating Git repository..." : "Create Git repository"}
</Button>
</div>
)
}
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
const reviewContent = (input: {
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
@@ -900,25 +870,98 @@ export default function Page() {
emptyClass: string
}) => (
<Show when={!store.deferRender}>
<SessionReviewTab
title={changesTitle()}
empty={reviewEmpty(input)}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
<Switch>
<Match when={store.changes === "turn" && !!params.id}>
<SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<SessionReviewTab
title={changesTitle()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : reviewEmptyKey() === "session.review.noVcs" ? (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">Create a Git repository</div>
<div
class="text-14-regular text-text-base max-w-md"
style={{ "line-height": "var(--line-height-normal)" }}
>
Track, review, and undo changes in this project
</div>
</div>
<Button size="large" disabled={ui.git} onClick={initGit}>
{ui.git ? "Creating Git repository..." : "Create Git repository"}
</Button>
</div>
) : (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
</Show>
)

View File

@@ -1,10 +0,0 @@
export const todoState = (input: {
count: number
done: boolean
live: boolean
}): "hide" | "clear" | "open" | "close" => {
if (input.count === 0) return "hide"
if (!input.live) return "clear"
if (!input.done) return "open"
return "close"
}

View File

@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { todoState } from "./session-composer-helpers"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
@@ -104,25 +103,3 @@ describe("sessionQuestionRequest", () => {
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
})
describe("todoState", () => {
test("hides when there are no todos", () => {
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
})
test("opens while the session is still working", () => {
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
})
test("closes completed todos after a running turn", () => {
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
})
test("clears stale todos when the turn ends", () => {
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
})
test("clears completed todos when the session is no longer live", () => {
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
})
})

View File

@@ -8,11 +8,8 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { todoState } from "./session-composer-helpers"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const idle = { type: "idle" as const }
export function createSessionComposerBlocked() {
const params = useParams()
const permission = usePermission()
@@ -62,22 +59,9 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
return globalSync.data.session_todo[id] ?? []
})
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const status = createMemo(() => {
const id = params.id
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const busy = createMemo(() => status().type !== "idle")
const live = createMemo(() => busy() || blocked())
const [store, setStore] = createStore({
responding: undefined as string | undefined,
dock: todos().length > 0 && live(),
dock: todos().length > 0,
closing: false,
opening: false,
})
@@ -105,6 +89,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
})
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
let timer: number | undefined
let raf: number | undefined
@@ -123,42 +111,21 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
}, closeMs())
}
// Keep stale turn todos from reopening if the model never clears them.
const clear = () => {
const id = params.id
if (!id) return
globalSync.todo.set(id, [])
sync.set("todo", id, [])
}
createEffect(
on(
() => [todos().length, done(), live()] as const,
([count, complete, active]) => {
() => [todos().length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
const next = todoState({
count,
done: complete,
live: active,
})
if (next === "hide") {
if (count === 0) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ dock: false, closing: false, opening: false })
return
}
if (next === "clear") {
if (timer) window.clearTimeout(timer)
timer = undefined
clear()
return
}
if (next === "open") {
if (!complete) {
if (timer) window.clearTimeout(timer)
timer = undefined
const hidden = !store.dock || store.closing
@@ -175,8 +142,13 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
return
}
if (prev && prev[1]) {
if (store.closing && !timer) scheduleClose()
return
}
setStore({ dock: true, opening: false, closing: true })
if (!timer) scheduleClose()
scheduleClose()
},
),
)

View File

@@ -764,7 +764,6 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
>
<Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2">

View File

@@ -8,12 +8,6 @@ import { useI18n } from "~/context/i18n"
export function Footer() {
const language = useLanguage()
const i18n = useI18n()
const community = createMemo(() => {
const locale = language.locale()
return locale === "zh" || locale === "zht"
? ({ key: "footer.feishu", link: language.route("/feishu") } as const)
: ({ key: "footer.discord", link: language.route("/discord") } as const)
})
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
@@ -38,7 +32,7 @@ export function Footer() {
<a href={language.route("/changelog")}>{i18n.t("footer.changelog")}</a>
</div>
<div data-slot="cell">
<a href={community().link}>{i18n.t(community().key)}</a>
<a href={language.route("/discord")}>{i18n.t("footer.discord")}</a>
</div>
<div data-slot="cell">
<a href={config.social.twitter}>{i18n.t("footer.x")}</a>

View File

@@ -21,7 +21,6 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "Docs",
"footer.changelog": "Changelog",
"footer.feishu": "Feishu",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,7 +24,6 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文档",
"footer.changelog": "更新日志",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,7 +24,6 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文件",
"footer.changelog": "更新日誌",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -1,7 +0,0 @@
import { redirect } from "@solidjs/router"
export async function GET() {
return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
)
}

View File

@@ -107,7 +107,7 @@ export function syncCli() {
let version = ""
try {
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
version = execFileSync(installPath, ["--version"]).toString().trim()
} catch {
return
}
@@ -147,7 +147,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: process.platform !== "win32",
detached: true,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})

View File

@@ -8,37 +8,3 @@
- **Command**: `bun run db generate --name <slug>`.
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
# opencode Effect guide
Instructions to follow when writing Effect.
## Schemas
- Use `Schema.Class` for data types with multiple fields.
- Use branded schemas (`Schema.brand`) for single-value types.
## Services
- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
## Errors
- Use `Schema.TaggedErrorClass` for typed errors.
- For defect-like causes, use `Schema.Defect` instead of `unknown`.
- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
## Effects
- Use `Effect.gen(function* () { ... })` for composition.
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
## Time
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
## Errors
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.

View File

@@ -7,8 +7,9 @@
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test": "bun test --timeout 30000 registry",
"build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
@@ -25,9 +26,20 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
}
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -42,13 +54,14 @@
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"drizzle-kit": "catalog:",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -81,9 +94,12 @@
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -92,8 +108,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.0.0-20260310-a34fbfd3",
"@opentui/solid": "0.0.0-20260310-a34fbfd3",
"@opentui/core": "0.1.86",
"@opentui/solid": "0.1.86",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -108,8 +124,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"drizzle-orm": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
@@ -120,6 +135,7 @@
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"node-pty": "1.1.0",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser", "node-pty"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -4,7 +4,7 @@ import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import solidPlugin from "@opentui/solid/bun-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -59,7 +59,6 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const plugin = createSolidTransformPlugin({ mode: "build" })
const allTargets: {
os: string
@@ -174,7 +173,7 @@ for (const item of targets) {
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
plugins: [solidPlugin],
sourcemap: "external",
compile: {
autoloadBunfig: false,

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bun
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
if (process.platform !== "win32") {
const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
const result = await Promise.all(
files.map(async (file) => {
const stat = await fs.stat(file).catch(() => undefined)
if (!stat) return
if ((stat.mode & 0o111) === 0o111) return
await fs.chmod(file, stat.mode | 0o755)
return file
}),
)
const fixed = result.filter(Boolean)
if (fixed.length) {
console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
}
}

View File

@@ -1,24 +1,20 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema"
import { Timestamps } from "../storage/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().$type<AccountID>().primaryKey(),
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
...Timestamps,
})
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text()
.$type<AccountID>()
.references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text().$type<OrgID>(),
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text(),
})
// LEGACY
@@ -27,8 +23,8 @@ export const ControlAccountTable = sqliteTable(
{
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()

View File

@@ -1,4 +1,4 @@
import { Effect, Option } from "effect"
import { Effect, Option, ServiceMap } from "effect"
import {
Account as AccountSchema,
@@ -13,11 +13,13 @@ export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountService.use(f))
}
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountService.use(f))
}

View File

@@ -3,16 +3,43 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
const decodeAccount = Schema.decodeUnknownSync(Account)
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
const ACCOUNT_STATE_ID = 1
export namespace AccountRepo {
export interface Service {
const db = <A>(run: (db: DbClient) => A) =>
Effect.try({
try: () => Database.use(run),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: orgID },
})
.run()
export class AccountRepo extends ServiceMap.Service<
AccountRepo,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
@@ -20,96 +47,62 @@ export namespace AccountRepo {
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: AccessToken
refreshToken: RefreshToken
accessToken: string
refreshToken: string
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountRepoError>
readonly persistAccount: (input: {
id: AccountID
email: string
url: string
accessToken: AccessToken
refreshToken: RefreshToken
accessToken: string
refreshToken: string
expiry: number
orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError>
}
}
export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
AccountRepo,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Account)
AccountRepo.of({
active: Effect.fn("AccountRepo.active")(() =>
db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
),
const query = <A>(f: (db: DbClient) => A) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const tx = <A>(f: (db: DbClient) => A) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
const id = Option.getOrNull(orgID)
return db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: id },
})
.run()
}
const active = Effect.fn("AccountRepo.active")(() =>
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
)
const list = Effect.fn("AccountRepo.list")(() =>
query((db) =>
list: Effect.fn("AccountRepo.list")(() =>
db((db) =>
db
.select()
.from(AccountTable)
.all()
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
.map((row) => decodeAccount({ ...row, active_org_id: null })),
),
)
),
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
tx((db) => {
db.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}).pipe(Effect.asVoid),
)
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
db((db) =>
Database.transaction((tx) => {
tx.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}),
).pipe(Effect.asVoid),
),
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
)
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
),
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
)
),
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
query((db) =>
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
db((db) =>
db
.update(AccountTable)
.set({
@@ -120,41 +113,34 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
)
),
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
tx((db) => {
db.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
const orgID = Option.getOrNull(input.orgID)
return db((db) =>
Database.transaction((tx) => {
tx.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
void state(db, input.id, input.orgID)
}).pipe(Effect.asVoid),
)
return AccountRepo.of({
active,
list,
remove,
use,
getRow,
persistToken,
persistAccount,
})
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
setState(tx, input.id, orgID)
}),
).pipe(Effect.asVoid)
}),
}),
)
}

View File

@@ -20,24 +20,6 @@ export const AccessToken = Schema.String.pipe(
)
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export const RefreshToken = Schema.String.pipe(
Schema.brand("RefreshToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
export const DeviceCode = Schema.String.pipe(
Schema.brand("DeviceCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
export const UserCode = Schema.String.pipe(
Schema.brand("UserCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Account extends Schema.Class<Account>("Account")({
id: AccountID,
email: Schema.String,
@@ -63,12 +45,12 @@ export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceE
export type AccountError = AccountRepoError | AccountServiceError
export class Login extends Schema.Class<Login>("Login")({
code: DeviceCode,
user: UserCode,
code: Schema.String,
user: Schema.String,
url: Schema.String,
server: Schema.String,
expiry: Schema.Duration,
interval: Schema.Duration,
expiry: Schema.Number,
interval: Schema.Number,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {

View File

@@ -1,5 +1,11 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
@@ -8,8 +14,6 @@ import {
AccessToken,
Account,
AccountID,
DeviceCode,
RefreshToken,
AccountServiceError,
Login,
Org,
@@ -21,101 +25,83 @@ import {
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: readonly Org[]
orgs: Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
const RemoteOrg = Schema.Struct({
id: Schema.optional(OrgID),
name: Schema.optional(Schema.String),
})
const RemoteOrgs = Schema.Array(RemoteOrg)
const RemoteConfig = Schema.Struct({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
})
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
const TokenRefresh = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
const DeviceCode = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
expires_in: Schema.Number,
interval: Schema.Number,
})
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
const DeviceToken = Schema.Struct({
access_token: Schema.optional(Schema.String),
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const User = Schema.Struct({
id: Schema.optional(AccountID),
email: Schema.optional(Schema.String),
})
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
const ClientId = Schema.Struct({ client_id: Schema.String })
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
const DeviceTokenRequest = Schema.Struct({
grant_type: Schema.String,
device_code: DeviceCode,
device_code: Schema.String,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
})
const clientId = "opencode-cli"
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
const mapAccountServiceError =
(message = "Account service operation failed") =>
(operation: string, message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
Effect.mapError((error) =>
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
),
)
export namespace AccountService {
export interface Service {
export class AccountService extends ServiceMap.Service<
AccountService,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
@@ -124,98 +110,80 @@ export namespace AccountService {
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
}
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError(operation, "HTTP request failed"),
)
// Returns a usable access token for a stored account row, refreshing and
// persisting it when the cached token has expired.
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
HttpClientResponse.filterStatusOk(response).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
: Effect.fail(error),
),
mapAccountServiceError(operation),
)
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
const response = yield* execute(
"token.refresh",
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
HttpClientRequest.bodyUrlParams({
grant_type: "refresh_token",
refresh_token: found.refresh_token,
}),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
const ok = yield* okOrNone("token.refresh", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
mapAccountServiceError("token.refresh", "Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
yield* repo.persistToken({
accountID: row.id,
accountID: AccountID.make(found.id),
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
refreshToken: parsed.refresh_token ?? found.refresh_token,
expiry,
})
return parsed.access_token
return Option.some(AccessToken.make(parsed.access_token))
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const accessToken = yield* tokenForRow(account)
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some({ account, accessToken: accessToken.value })
})
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
@@ -224,17 +192,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
return yield* Effect.forEach(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
@@ -243,7 +205,23 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
const response = yield* executeRead(
"orgs",
HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
const ok = yield* okOrNone("orgs", response)
if (Option.isNone(ok)) return []
const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
mapAccountServiceError("orgs", "Failed to decode response"),
)
return orgs
.filter((org) => org.id !== undefined && org.name !== undefined)
.map((org) => new Org({ id: org.id!, name: org.name! }))
})
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
@@ -253,6 +231,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"config",
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
@@ -260,26 +239,32 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
),
)
if (response.status === 404) return Option.none()
const ok = yield* okOrNone("config", response)
if (Option.isNone(ok)) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
mapAccountServiceError("config", "Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffectOk(
const response = yield* executeEffect(
"login",
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
const ok = yield* okOrNone("login", response)
if (Option.isNone(ok)) {
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
}
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
mapAccountServiceError("login", "Failed to decode response"),
)
return new Login({
code: parsed.device_code,
@@ -292,49 +277,91 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
})
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
const response = yield* executeEffect(
"poll",
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
mapAccountServiceError("poll", "Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
if (!parsed.access_token) {
if (parsed.error === "authorization_pending") return new PollPending()
if (parsed.error === "slow_down") return new PollSlow()
if (parsed.error === "expired_token") return new PollExpired()
if (parsed.error === "access_denied") return new PollDenied()
return new PollError({ cause: parsed.error })
}
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const access = parsed.access_token
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
const fetchUser = executeRead(
"poll.user",
HttpClientRequest.get(`${input.server}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(User)(r).pipe(
mapAccountServiceError("poll.user", "Failed to decode response"),
),
),
)
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const fetchOrgs = executeRead(
"poll.orgs",
HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
mapAccountServiceError("poll.orgs", "Failed to decode response"),
),
),
)
const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
const userId = user.id
const userEmail = user.email
if (!userId || !userEmail) {
return new PollError({ cause: "No id or email in response" })
}
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
const expiry = now + (parsed.expires_in ?? 0) * 1000
const refresh = parsed.refresh_token ?? ""
if (!refresh) {
yield* Effect.logWarning(
"Server did not return a refresh token — session may expire without ability to refresh",
)
}
yield* repo.persistAccount({
id: account.id,
email: account.email,
id: userId,
email: userEmail,
url: input.server,
accessToken,
refreshToken,
accessToken: access,
refreshToken: refresh,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
return new PollSuccess({ email: userEmail })
})
return AccountService.of({

View File

@@ -1,13 +1,5 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
@@ -45,88 +37,4 @@ export namespace BunProc {
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version === "latest") {
if (!PackageRegistry.online()) return mod
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!stale) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
} else if (cachedVersion === version) {
return mod
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}

View File

@@ -1,4 +1,4 @@
import semver from "semver"
import { text } from "node:stream/consumers"
import { Log } from "../util/log"
import { Process } from "../util/process"
@@ -9,47 +9,28 @@ export namespace PackageRegistry {
return process.execPath
}
export function online() {
const nav = globalThis.navigator
if (!nav || typeof nav.onLine !== "boolean") return true
return nav.onLine
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
if (!online()) {
log.debug("offline, skipping bun info", { pkg, field })
return null
}
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
const result = Process.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
BUN_BE_BUN: "1",
},
nothrow: true,
})
const code = await result.exited
const stdout = result.stdout ? await text(result.stdout) : ""
const stderr = result.stderr ? await text(result.stderr) : ""
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
log.warn("bun info failed", { pkg, field, code, stderr })
return null
}
const value = stdout.toString().trim()
const value = stdout.trim()
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
}

View File

@@ -24,17 +24,17 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
const s = Prompt.spinner()
yield* s.start("Waiting for authorization...")
const poll = (wait: Duration.Duration): Effect.Effect<PollResult, AccountError> =>
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* service.poll(login)
if (result._tag === "PollPending") return yield* poll(wait)
if (result._tag === "PollSlow") return yield* poll(Duration.sum(wait, Duration.seconds(5)))
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
return result
})
const result = yield* poll(login.interval).pipe(
Effect.timeout(login.expiry),
const result = yield* poll(login.interval * 1000).pipe(
Effect.timeout(Duration.seconds(login.expiry)),
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
)

View File

@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,

View File

@@ -86,7 +86,7 @@ export const ImportCommand = cmd({
await bootstrap(process.cwd(), async () => {
let exportData:
| {
info: SDKSession
info: Session.Info
messages: Array<{
info: Message
parts: Part[]
@@ -152,7 +152,7 @@ export const ImportCommand = cmd({
return
}
const row = Session.toRow({ ...exportData.info, projectID: Instance.project.id })
const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id }
Database.use((db) =>
db
.insert(SessionTable)

View File

@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -1,25 +1,13 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig, type ParsedKey } from "@opentui/core"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Switch,
Match,
createEffect,
createMemo,
untrack,
ErrorBoundary,
createSignal,
onMount,
batch,
Show,
on,
} from "solid-js"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
import { Dialog as DialogUI, DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
@@ -33,7 +21,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
@@ -41,9 +29,6 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { DialogPrompt } from "./ui/dialog-prompt"
import { DialogSelect } from "./ui/dialog-select"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
@@ -56,8 +41,6 @@ import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import type { TuiApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { TuiPlugin } from "./plugin"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -121,25 +104,6 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
import type { EventSource } from "./context/sdk"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Clipboard.copy(text).catch((error) => {
console.error(`Failed to copy console selection to clipboard: ${error}`)
})
},
},
}
}
export function tui(input: {
url: string
args: Args
@@ -165,57 +129,73 @@ export function tui(input: {
resolve()
}
const renderer = await createCliRenderer(rendererConfig(input.config))
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
}, renderer)
render(
() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
},
{
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Clipboard.copy(text).catch((error) => {
console.error(`Failed to copy console selection to clipboard: ${error}`)
})
},
},
},
)
})
}
@@ -228,226 +208,12 @@ function App() {
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const sdk = useSDK()
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode } = themeState
const { theme, mode, setMode } = useTheme()
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const routes = new Map<string, { key: symbol; render: TuiRouteDefinition["render"] }[]>()
const [routeRev, setRouteRev] = createSignal(0)
const routeView = (name: string) => {
routeRev()
return routes.get(name)?.at(-1)?.render
}
const api: TuiApi<JSX.Element> = {
command: {
register(cb) {
command.register(() => cb())
},
trigger(value) {
command.trigger(value)
},
},
route: {
register(input) {
const key = Symbol()
for (const item of input) {
const list = routes.get(item.name) ?? []
list.push({ key, render: item.render })
routes.set(item.name, list)
}
setRouteRev((x) => x + 1)
return () => {
for (const item of input) {
const list = routes.get(item.name)
if (!list) continue
routes.set(
item.name,
list.filter((x) => x.key !== key),
)
if (!routes.get(item.name)?.length) routes.delete(item.name)
}
setRouteRev((x) => x + 1)
}
},
navigate(name, params) {
if (name === "home") {
route.navigate({ type: "home" })
return
}
if (name === "session") {
const sessionID = params?.sessionID
if (typeof sessionID !== "string") return
route.navigate({ type: "session", sessionID })
return
}
route.navigate({ type: "plugin", id: name, data: params })
},
get current() {
if (route.data.type === "home") return { name: "home" }
if (route.data.type === "session") {
return {
name: "session",
params: {
sessionID: route.data.sessionID,
initialPrompt: route.data.initialPrompt,
},
}
}
return {
name: route.data.id,
params: route.data.data,
}
},
},
ui: {
Dialog(props) {
return (
<DialogUI size={props.size} onClose={props.onClose}>
{props.children as JSX.Element}
</DialogUI>
)
},
DialogAlert(props) {
return <DialogAlert {...props} />
},
DialogConfirm(props) {
return <DialogConfirm {...props} />
},
DialogPrompt(props) {
return <DialogPrompt {...props} description={props.description as (() => JSX.Element) | undefined} />
},
DialogSelect(props) {
const list = props.options.map((item) => ({
...item,
footer: item.footer as JSX.Element | string | undefined,
onSelect: () => item.onSelect?.(),
}))
return (
<DialogSelect
title={props.title}
placeholder={props.placeholder}
options={list}
flat={props.flat}
onMove={
props.onMove
? (item) =>
props.onMove?.({
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
})
: undefined
}
onFilter={props.onFilter}
onSelect={
props.onSelect
? (item) =>
props.onSelect?.({
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
})
: undefined
}
skipFilter={props.skipFilter}
current={props.current}
/>
)
},
toast(input) {
toast.show({
title: input.title,
message: input.message,
variant: input.variant ?? "info",
duration: input.duration,
})
},
dialog: {
replace(render, onClose) {
dialog.replace(render, onClose)
},
clear() {
dialog.clear()
},
setSize(size) {
dialog.setSize(size)
},
get size() {
return dialog.size
},
get depth() {
return dialog.stack.length
},
get open() {
return dialog.stack.length > 0
},
},
},
keybind: {
parse(evt: ParsedKey) {
return keybind.parse(evt)
},
match(key, evt: ParsedKey) {
return keybind.match(key, evt)
},
print(key) {
return keybind.print(key)
},
create(defaults, overrides) {
return keybind.create(defaults, overrides)
},
},
theme: {
get current() {
return theme
},
get selected() {
return themeState.selected
},
has(name) {
return themeState.has(name)
},
set(name) {
return themeState.set(name)
},
async install(_jsonPath) {
throw new Error("theme.install is only available in plugin context")
},
mode() {
return themeState.mode()
},
get ready() {
return themeState.ready
},
},
}
const [ready, setReady] = createSignal(false)
TuiPlugin.init({
client: sdk.client,
event: sdk.event,
renderer,
api,
})
.catch((error) => {
console.error("Failed to load TUI plugins", error)
})
.finally(() => {
setReady(true)
})
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
@@ -490,6 +256,10 @@ function App() {
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
createEffect(() => {
console.log(JSON.stringify(route.data))
})
// Update terminal window title based on current route and session
createEffect(() => {
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
@@ -506,13 +276,9 @@ function App() {
return
}
// Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
return
}
if (route.data.type === "plugin") {
renderer.setTerminalTitle(`OC | ${route.data.id}`)
}
})
@@ -986,58 +752,29 @@ function App() {
})
})
const plugin = createMemo(() => {
if (route.data.type !== "plugin") return
const render = routeView(route.data.id)
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
return render({ params: route.data.data })
})
return (
<Show when={ready()}>
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)
}
>
<Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw />
</Show>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
{plugin()}
<TuiPlugin.Slot name="app" />
</box>
</Show>
)
}
function PluginRouteMissing(props: { id: string; onHome: () => void }) {
const { theme } = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.text}>go home</text>
</box>
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
)
}

View File

@@ -10,7 +10,7 @@ import {
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
@@ -21,7 +21,7 @@ export type Slash = {
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: string
keybind?: KeybindKey
suggested?: boolean
slash?: Slash
hidden?: boolean

View File

@@ -16,8 +16,7 @@ export function DialogStatus() {
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((item) => {
const value = typeof item === "string" ? item : item[0]
const result = list.map((value) => {
if (value.startsWith("file://")) {
const path = fileURLToPath(value)
const parts = path.split("/")

View File

@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
if (!result.data) {

View File

@@ -1,44 +0,0 @@
import type { ParsedKey } from "@opentui/core"
export type PluginKeybindMap = Record<string, string>
type Base<Info> = {
parse: (evt: ParsedKey) => Info
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
export type PluginKeybind<Info> = {
readonly all: PluginKeybindMap
get: (name: string) => string
parse: (evt: ParsedKey) => Info
match: (name: string, evt: ParsedKey) => boolean
print: (name: string) => string
}
const txt = (value: unknown) => {
if (typeof value !== "string") return
if (!value.trim()) return
return value
}
export function createPluginKeybind<Info>(
base: Base<Info>,
defaults: PluginKeybindMap,
overrides?: Record<string, unknown>,
): PluginKeybind<Info> {
const all = Object.freeze(
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
) as PluginKeybindMap
const get = (name: string) => all[name] ?? name
return {
get all() {
return all
},
get,
parse: (evt) => base.parse(evt),
match: (name, evt) => base.match(get(name), evt),
print: (name) => base.print(get(name)),
}
}

View File

@@ -6,7 +6,6 @@ import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { createPluginKeybind, type PluginKeybindMap } from "./keybind-plugin"
import { useTuiConfig } from "./tui-config"
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
@@ -81,27 +80,21 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
return Keybind.fromParsedKey(evt, store.leader)
},
match(key: string, evt: ParsedKey) {
const list = keybinds()[key] ?? Keybind.parse(key)
if (!list.length) return false
match(key: KeybindKey, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
for (const item of list) {
if (Keybind.match(item, parsed)) {
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
return true
}
}
return false
},
print(key: string) {
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
print(key: KeybindKey) {
const first = keybinds()[key]?.at(0)
if (!first) return ""
const text = Keybind.toString(first)
const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
},
create(defaults: PluginKeybindMap, overrides?: Record<string, unknown>) {
return createPluginKeybind(result, defaults, overrides)
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
},
}
return result

View File

@@ -14,13 +14,7 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
export type PluginRoute = {
type: "plugin"
id: string
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export type Route = HomeRoute | SessionRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -38,6 +32,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}

View File

@@ -42,7 +42,6 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
import { isRecord } from "@/util/record"
type ThemeColors = {
primary: RGBA
@@ -175,56 +174,6 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
carbonfox,
}
type State = {
themes: Record<string, ThemeJson>
mode: "dark" | "light"
active: string
ready: boolean
}
const [store, setStore] = createStore<State>({
themes: DEFAULT_THEMES,
mode: "dark",
active: "opencode",
ready: false,
})
function mergeThemes(themes: Record<string, ThemeJson>) {
setStore(
produce((draft) => {
for (const [name, theme] of Object.entries(themes)) {
if (draft.themes[name]) continue
draft.themes[name] = theme
}
}),
)
}
export function allThemes() {
return store.themes
}
function isTheme(theme: unknown): theme is ThemeJson {
if (!isRecord(theme)) return false
if (!isRecord(theme.theme)) return false
return true
}
export function hasTheme(name: string) {
if (!name) return false
return allThemes()[name] !== undefined
}
export function addTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
if (hasTheme(name)) return false
mergeThemes({
[name]: theme,
})
return true
}
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
@@ -333,14 +282,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
init: (props: { mode: "dark" | "light" }) => {
const config = useTuiConfig()
const kv = useKV()
setStore(
produce((draft) => {
draft.mode = kv.get("theme_mode", props.mode)
draft.active = (config.theme ?? kv.get("theme", "opencode")) as string
draft.ready = false
}),
)
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
createEffect(() => {
const theme = config.theme
@@ -348,49 +295,55 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
Promise.allSettled([
resolveSystemTheme(),
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
}),
]).finally(() => {
setStore("ready", true)
})
resolveSystemTheme()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
})
.finally(() => {
if (store.active !== "system") {
setStore("ready", true)
}
})
}
onMount(init)
function resolveSystemTheme() {
return renderer
console.log("resolveSystemTheme")
renderer
.getPalette({
size: 16,
})
.then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) {
if (store.active === "system") {
setStore("active", "opencode")
setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
}
return
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") {
draft.ready = true
}
}),
)
})
.catch(() => {
if (store.active === "system") {
setStore("active", "opencode")
}
})
}
const renderer = useRenderer()
@@ -417,10 +370,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.active
},
all() {
return allThemes()
},
has(name: string) {
return hasTheme(name)
return store.themes
},
syntax,
subtleSyntax,
@@ -432,10 +382,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
kv.set("theme_mode", mode)
},
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
return true
},
get ready() {
return store.ready

View File

@@ -1,322 +0,0 @@
import {
type TuiPlugin as TuiPluginFn,
type TuiPluginInit,
type TuiPluginInput,
type TuiTheme,
type TuiSlotContext,
type TuiSlotMap,
type TuiSlots,
type SlotMode,
} from "@opencode-ai/plugin/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import type { CliRenderer } from "@opentui/core"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { TuiConfig } from "@/config/tui"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
import { PluginMeta } from "@/plugin/meta"
import { addTheme, hasTheme } from "./context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
type SlotProps<K extends keyof TuiSlotMap> = {
name: K
mode?: SlotMode
children?: JSX.Element
} & TuiSlotMap[K]
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
type InitInput = Omit<TuiPluginInput<CliRenderer>, "slots">
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
return null
}
function isTuiSlotPlugin(value: unknown): value is SolidPlugin<TuiSlotMap, TuiSlotContext> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
if (!isRecord(value.slots)) return false
return true
}
function getTuiSlotPlugin(value: unknown) {
if (isTuiSlotPlugin(value)) return value
if (!isRecord(value)) return
if (!isTuiSlotPlugin(value.slots)) return
return value.slots
}
function isTuiPlugin(value: unknown): value is TuiPluginFn<CliRenderer> {
return typeof value === "function"
}
function getTuiPlugin(value: unknown) {
if (!isRecord(value) || !("tui" in value)) return
if (!isTuiPlugin(value.tui)) return
return value.tui
}
function isTheme(value: unknown) {
if (!isRecord(value)) return false
if (!isRecord(value.theme)) return false
return true
}
function localDir(file: string) {
const dir = path.dirname(file)
if (path.basename(dir) === ".opencode") return path.join(dir, "themes")
return path.join(dir, ".opencode", "themes")
}
function scopeDir(pluginMeta: TuiConfig.PluginMeta) {
if (pluginMeta.scope === "local") return localDir(pluginMeta.source)
return path.join(Global.Path.config, "themes")
}
function pluginRoot(spec: string, target: string) {
if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec))
if (target.startsWith("file://")) return path.dirname(fileURLToPath(target))
return target
}
function resolveThemePath(root: string, file: string) {
if (file.startsWith("file://")) return fileURLToPath(file)
if (path.isAbsolute(file)) return file
return path.resolve(root, file)
}
function themeName(file: string) {
return path.basename(file, path.extname(file))
}
function getPluginMeta(config: TuiConfig.Info, item: Config.PluginSpec) {
const key = Config.getPluginName(item)
const value = config.plugin_meta?.[key]
if (!value) {
throw new Error(`missing plugin metadata for ${key}`)
}
return value
}
function makeInstallFn(meta: TuiConfig.PluginMeta, root: string): TuiTheme["install"] {
return async (file) => {
const src = resolveThemePath(root, file)
const theme = themeName(src)
if (hasTheme(theme)) return
const text = await Bun.file(src)
.text()
.catch((error) => {
throw new Error(`failed to read theme at ${src}: ${error}`)
})
const data = JSON.parse(text)
if (!isTheme(data)) {
throw new Error(`invalid theme at ${src}`)
}
const dest = path.join(scopeDir(meta), `${theme}.json`)
if (!(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text)
}
addTheme(theme, data)
}
}
export namespace TuiPlugin {
const log = Log.create({ service: "tui.plugin" })
let loaded: Promise<void> | undefined
let view: Slot = empty
export const Slot: Slot = (props) => view(props)
function setupSlots(input: InitInput): TuiSlots {
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
input.renderer,
{
theme: input.api.theme,
},
{
onPluginError(event) {
console.error("[tui.slot] plugin error", {
plugin: event.pluginId,
slot: event.slot,
phase: event.phase,
source: event.source,
message: event.error.message,
})
},
},
)
const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
view = (props) => slot(props)
return {
register(pluginSlot) {
if (!isTuiSlotPlugin(pluginSlot)) return () => {}
return reg.register(pluginSlot)
},
}
}
export async function init(input: InitInput) {
if (loaded) return loaded
loaded = load({
...input,
slots: setupSlots(input),
})
return loaded
}
async function load(input: TuiPluginInput<CliRenderer>) {
const dir = process.cwd()
await Instance.provide({
directory: dir,
fn: async () => {
const config = await TuiConfig.get()
const plugins = config.plugin ?? []
let deps: Promise<void> | undefined
const wait = async () => {
if (deps) {
await deps
return
}
deps = TuiConfig.waitForDependencies().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await deps
}
const prep = async (item: (typeof plugins)[number], retry = false) => {
const spec = Config.pluginSpecifier(item)
log.info("loading tui plugin", { path: spec, retry })
const target = await resolvePluginTarget(spec).catch((error) => {
log.error("failed to resolve tui plugin", { path: spec, retry, error })
return
})
if (!target) return
const meta = await PluginMeta.touch(spec, target).catch((error) => {
log.warn("failed to track tui plugin", { path: spec, retry, error })
})
if (meta && meta.state !== "same") {
log.info("tui plugin metadata updated", {
path: spec,
retry,
state: meta.state,
source: meta.entry.source,
version: meta.entry.version,
modified: meta.entry.modified,
})
}
const now = Date.now()
const init: TuiPluginInit = meta
? {
state: meta.state,
entry: meta.entry,
}
: {
state: "first",
entry: {
name: spec,
source: spec.startsWith("file://") ? "file" : "npm",
spec,
target,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: target,
},
}
const root = pluginRoot(spec, target)
const install = makeInstallFn(getPluginMeta(config, item), root)
const mod = await import(target).catch((error) => {
log.error("failed to load tui plugin", { path: spec, retry, error })
return
})
if (!mod) return
return {
item,
spec,
mod,
install,
init,
}
}
try {
const loaded = await Promise.all(plugins.map((item) => prep(item)))
for (let i = 0; i < plugins.length; i++) {
let load = loaded[i]
if (!load) {
const item = plugins[i]
if (!item) continue
const spec = Config.pluginSpecifier(item)
if (!spec.startsWith("file://")) continue
await wait()
load = await prep(item, true)
}
if (!load) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
for (const [name, value] of uniqueModuleEntries(load.mod)) {
if (!value || typeof value !== "object") {
log.warn("ignoring non-object tui plugin export", {
path: load.spec,
name,
type: value === null ? "null" : typeof value,
})
continue
}
const slotPlugin = getTuiSlotPlugin(value)
if (slotPlugin) input.slots.register(slotPlugin)
const tuiPlugin = getTuiPlugin(value)
if (!tuiPlugin) continue
await tuiPlugin(
{
...input,
api: {
command: input.api.command,
route: input.api.route,
ui: input.api.ui,
keybind: input.api.keybind,
theme: Object.create(input.api.theme, {
install: {
value: load.install,
configurable: true,
enumerable: true,
},
}),
},
},
Config.pluginOptions(load.item) ?? null,
load.init,
)
}
}
} finally {
await PluginMeta.persist().catch((error) => {
log.warn("failed to persist tui plugin metadata", { error })
})
}
},
}).catch((error) => {
log.error("failed to load tui plugins", { directory: dir, error })
})
}
}

View File

@@ -1,4 +1,3 @@
import "@opentui/solid/runtime-plugin-support"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
@@ -16,7 +15,6 @@ import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { useLocal } from "../context/local"
import { TuiPlugin } from "../plugin"
// TODO: what is the best way to do this?
let once = false
@@ -59,8 +57,8 @@ export function Home() {
])
const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<Show when={connectedMcpCount() > 0}>
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
@@ -73,8 +71,8 @@ export function Home() {
</Match>
</Switch>
</text>
</Show>
</box>
</box>
</Show>
)
let prompt: PromptRef
@@ -113,9 +111,7 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<TuiPlugin.Slot name="home_logo" mode="replace">
<Logo />
</TuiPlugin.Slot>
<Logo />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>

View File

@@ -70,6 +70,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
@@ -906,12 +907,12 @@ export function Session() {
const filename = options.filename.trim()
const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript)
await Filesystem.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
await Bun.write(filepath, result)
await Filesystem.write(filepath, result)
}
toast.show({ message: `Session exported to ${filename}`, variant: "success" })

View File

@@ -11,7 +11,6 @@ import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
import { TuiPlugin } from "../../plugin"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
@@ -91,7 +90,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<TuiPlugin.Slot name="sidebar_top" session_id={props.sessionID} />
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session().title}</b>

View File

@@ -35,7 +35,6 @@ export function Dialog(
height={dimensions().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4}
left={0}
top={0}
@@ -71,10 +70,8 @@ function init() {
useKeyboard((evt) => {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -154,7 +151,6 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
zIndex={3000}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return

View File

@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Bun.Server<BunWebSocketData> | undefined
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
@@ -120,7 +119,7 @@ export const rpc = {
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = Server.listen(input)
server = await Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
@@ -143,7 +142,7 @@ export const rpc = {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) server.stop(true)
if (server) await server.stop(true)
},
}

View File

@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()

View File

@@ -22,7 +22,6 @@ import {
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
@@ -30,21 +29,14 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Npm } from "@/npm"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const PluginOptions = z.record(z.string(), z.unknown())
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec>
const log = Log.create({ service: "config" })
@@ -158,8 +150,7 @@ export namespace Config {
deps.push(
iife(async () => {
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
await installDependencies(dir)
}),
)
@@ -275,6 +266,10 @@ export namespace Config {
}
export async function installDependencies(dir: string) {
if (!(await isWritable(dir))) {
log.info("config dir is not writable, skipping dependency install", { dir })
return
}
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -288,22 +283,15 @@ export namespace Config {
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
if (!(await Filesystem.exists(gitignore)))
await Filesystem.write(
gitignore,
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
)
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
await Npm.install(dir)
}
async function isWritable(dir: string) {
@@ -315,42 +303,6 @@ export namespace Config {
}
}
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
if (!PackageRegistry.online()) return false
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!stale) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -479,7 +431,7 @@ export namespace Config {
}
async function loadPlugin(dir: string) {
const plugins: PluginSpec[] = []
const plugins: string[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
@@ -492,32 +444,6 @@ export namespace Config {
return plugins
}
export function pluginSpecifier(plugin: PluginSpec): string {
return Array.isArray(plugin) ? plugin[0] : plugin
}
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
return Array.isArray(plugin) ? plugin[1] : undefined
}
export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec {
const spec = pluginSpecifier(plugin)
try {
const resolved = import.meta.resolve!(spec, configFilepath)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
try {
const require = createRequire(configFilepath)
const resolved = pathToFileURL(require.resolve(spec)).href
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
return plugin
}
}
}
/**
* Extracts a canonical plugin name from a plugin specifier.
* - For file:// URLs: extracts filename without extension
@@ -528,16 +454,15 @@ export namespace Config {
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
*/
export function getPluginName(plugin: PluginSpec): string {
const spec = pluginSpecifier(plugin)
if (spec.startsWith("file://")) {
return path.parse(new URL(spec).pathname).name
export function getPluginName(plugin: string): string {
if (plugin.startsWith("file://")) {
return path.parse(new URL(plugin).pathname).name
}
const lastAt = spec.lastIndexOf("@")
const lastAt = plugin.lastIndexOf("@")
if (lastAt > 0) {
return spec.substring(0, lastAt)
return plugin.substring(0, lastAt)
}
return spec
return plugin
}
/**
@@ -551,14 +476,14 @@ export namespace Config {
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
export function deduplicatePlugins(plugins: string[]): string[] {
// seenNames: canonical plugin names for duplicate detection
// e.g., "oh-my-opencode", "@scope/pkg"
const seenNames = new Set<string>()
// uniqueSpecifiers: full plugin specifiers to return
// e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }]
const uniqueSpecifiers: PluginSpec[] = []
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
const uniqueSpecifiers: string[] = []
for (const specifier of plugins.toReversed()) {
const name = getPluginName(specifier)
@@ -1062,7 +987,7 @@ export namespace Config {
ignore: z.array(z.string()).optional(),
})
.optional(),
plugin: PluginSpec.array().optional(),
plugin: z.string().array().optional(),
snapshot: z.boolean().optional(),
share: z
.enum(["manual", "auto", "disabled"])
@@ -1310,7 +1235,19 @@ export namespace Config {
const data = parsed.data
if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, options.path)
} catch (e) {
try {
// import.meta.resolve sometimes fails with newly created node_modules
const require = createRequire(options.path)
const resolvedPath = require.resolve(plugin)
data.plugin[i] = pathToFileURL(resolvedPath).href
} catch {
// Ignore, plugin might be a generic string identifier like "mcp-server"
}
}
}
}
return data
@@ -1357,6 +1294,10 @@ export namespace Config {
return candidates[0]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {
@@ -1450,3 +1391,5 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write

View File

@@ -29,7 +29,6 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(),
})
.extend(TuiOptions.shape)
.strict()

View File

@@ -8,7 +8,6 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
export namespace TuiConfig {
@@ -16,43 +15,10 @@ export namespace TuiConfig {
export const Info = TuiInfo
export type PluginMeta = {
scope: "global" | "local"
source: string
}
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
}
export type Info = z.output<typeof Info> & {
plugin_meta?: Record<string, PluginMeta>
}
function pluginScope(file: string): PluginMeta["scope"] {
if (Instance.containsPath(file)) return "local"
return "global"
}
function dedupePlugins(list: PluginEntry[]) {
const seen = new Set<string>()
const result: PluginEntry[] = []
for (const item of list.toReversed()) {
const name = Config.getPluginName(item.item)
if (seen.has(name)) continue
seen.add(name)
result.push(item)
}
return result.toReversed()
}
export type Info = z.output<typeof Info>
function mergeInfo(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = [...target.plugin, ...source.plugin]
}
return merged
return mergeDeep(target, source)
}
function customPath() {
@@ -73,74 +39,37 @@ export namespace TuiConfig {
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
const pluginEntries: PluginEntry[] = []
const mergeFile = async (file: string) => {
const data = await loadFile(file)
result = mergeInfo(result, data)
if (!data.plugin?.length) return
const sourceScope = pluginScope(file)
for (const item of data.plugin) {
pluginEntries.push({
item,
meta: {
scope: sourceScope,
source: file,
},
})
}
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
if (custom) {
await mergeFile(custom)
result = mergeInfo(result, await loadFile(custom))
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
}
const merged = dedupePlugins(pluginEntries)
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
result.plugin = merged.map((item) => item.item)
result.plugin_meta = merged.length
? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta]))
: undefined
const deps: Promise<void>[] = []
if (result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(
(async () => {
const shouldInstall = await Config.needsInstall(dir)
if (!shouldInstall) return
await Config.installDependencies(dir)
})(),
)
}
}
return {
config: result,
deps,
}
})
@@ -148,11 +77,6 @@ export namespace TuiConfig {
return state().then((x) => x.config)
}
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@@ -163,19 +87,19 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => {
const copy = { ...raw }
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!isRecord(copy.tui)) {
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
@@ -189,13 +113,6 @@ export namespace TuiConfig {
return {}
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
return parsed.data
}
}

View File

@@ -1,6 +1,5 @@
import z from "zod"
import { Identifier } from "@/id/id"
import { ProjectID } from "@/project/schema"
export const WorkspaceInfo = z.object({
id: Identifier.schema("workspace"),
@@ -9,7 +8,7 @@ export const WorkspaceInfo = z.object({
name: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
projectID: z.string(),
})
export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>

View File

@@ -1,3 +1,4 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -55,10 +56,24 @@ export namespace WorkspaceServer {
}
export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
const server = createAdaptorServer({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
}
}

View File

@@ -1,6 +1,5 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().primaryKey(),
@@ -10,7 +9,6 @@ export const WorkspaceTable = sqliteTable("workspace", {
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<ProjectID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
})

View File

@@ -1,4 +1,5 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { Identifier } from "@/id/id"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
@@ -6,7 +7,6 @@ import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Log } from "@/util/log"
import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
@@ -49,7 +49,7 @@ export namespace Workspace {
id: Identifier.schema("workspace").optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
projectID: Info.shape.projectID,
extra: Info.shape.extra,
})
@@ -117,7 +117,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -127,7 +127,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
await Bun.sleep(250)
await sleep(250)
}
}

View File

@@ -14,12 +14,10 @@ export namespace Flag {
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
@@ -107,17 +105,6 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
configurable: false,
})
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
get() {
return process.env["OPENCODE_PLUGIN_META_FILE"]
},
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime

View File

@@ -1,40 +1,40 @@
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
enabled(): Promise<string[] | false>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return which("gofmt") !== null
const p = which("gofmt")
if (p === null) return false
return [p, "-w", "$FILE"]
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return which("mix") !== null
const p = which("mix")
if (p === null) return false
return [p, "format", "$FILE"]
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -73,8 +73,9 @@ export const prettier: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.prettier) return true
if (json.devDependencies?.prettier) return true
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
return [await Npm.which("prettier"), "--write", "$FILE"]
}
}
return false
},
@@ -82,7 +83,6 @@ export const prettier: Info = {
export const oxfmt: Info = {
name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -95,8 +95,9 @@ export const oxfmt: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.oxfmt) return true
if (json.devDependencies?.oxfmt) return true
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
return [await Npm.which("oxfmt"), "$FILE"]
}
}
return false
},
@@ -104,7 +105,6 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -141,7 +141,7 @@ export const biome: Info = {
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
return true
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
}
}
return false
@@ -150,47 +150,49 @@ export const biome: Info = {
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return which("zig") !== null
const p = which("zig")
if (p === null) return false
return [p, "fmt", "$FILE"]
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return ["clang-format", "-i", "$FILE"]
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return which("ktlint") !== null
const p = which("ktlint")
if (p === null) return false
return [p, "-F", "$FILE"]
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!which("ruff")) return false
const p = which("ruff")
if (p === null) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
} else {
return true
return [p, "format", "$FILE"]
}
}
}
@@ -199,7 +201,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true
if (content.includes("ruff")) return [p, "format", "$FILE"]
}
}
return false
@@ -208,14 +210,13 @@ export const ruff: Info = {
export const rlang: Info = {
name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = which("air")
if (airPath == null) return false
try {
const proc = Process.spawn(["air", "--help"], {
const proc = Process.spawn([airPath, "--help"], {
stdout: "pipe",
stderr: "pipe",
})
@@ -227,7 +228,10 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
return hasR && hasFormatter
if (hasR && hasFormatter) {
return [airPath, "format", "$FILE"]
}
return false
} catch (error) {
return false
}
@@ -236,14 +240,14 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const uvPath = which("uv")
if (uvPath !== null) {
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
if (code === 0) return [uvPath, "format", "--", "$FILE"]
}
return false
},
@@ -251,108 +255,118 @@ export const uvformat: Info = {
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("rubocop") !== null
const path = which("rubocop")
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("standardrb") !== null
const path = which("standardrb")
if (path === null) return false
return [path, "--fix", "$FILE"]
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return which("htmlbeautifier") !== null
const path = which("htmlbeautifier")
if (path === null) return false
return [path, "$FILE"]
},
}
export const dart: Info = {
name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return which("dart") !== null
const path = which("dart")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const ocamlformat: Info = {
name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!which("ocamlformat")) return false
const path = which("ocamlformat")
if (!path) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return [path, "-i", "$FILE"]
},
}
export const terraform: Info = {
name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return which("terraform") !== null
const path = which("terraform")
if (path === null) return false
return [path, "fmt", "$FILE"]
},
}
export const latexindent: Info = {
name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return which("latexindent") !== null
const path = which("latexindent")
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
},
}
export const gleam: Info = {
name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return which("gleam") !== null
const path = which("gleam")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const shfmt: Info = {
name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return which("shfmt") !== null
const path = which("shfmt")
if (path === null) return false
return [path, "-w", "$FILE"]
},
}
export const nixfmt: Info = {
name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return which("nixfmt") !== null
const path = which("nixfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const rustfmt: Info = {
name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return which("rustfmt") !== null
const path = which("rustfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const pint: Info = {
name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"],
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@@ -361,8 +375,9 @@ export const pint: Info = {
require?: Record<string, string>
"require-dev"?: Record<string, string>
}>(item)
if (json.require?.["laravel/pint"]) return true
if (json["require-dev"]?.["laravel/pint"]) return true
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
return ["./vendor/bin/pint", "$FILE"]
}
}
return false
},
@@ -370,27 +385,30 @@ export const pint: Info = {
export const ormolu: Info = {
name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return which("ormolu") !== null
const path = which("ormolu")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}
export const cljfmt: Info = {
name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return which("cljfmt") !== null
const path = which("cljfmt")
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
},
}
export const dfmt: Info = {
name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return which("dfmt") !== null
const path = which("dfmt")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}

View File

@@ -25,14 +25,14 @@ export namespace Format {
export type Status = z.infer<typeof Status>
const state = Instance.state(async () => {
const enabled: Record<string, boolean> = {}
const cache: Record<string, string[] | false> = {}
const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
cache,
formatters,
}
}
@@ -46,43 +46,41 @@ export namespace Format {
continue
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (result.command.length === 0) continue
result.enabled = async () => true
result.enabled = async () => item.command ?? false
result.name = name
formatters[name] = result
}
return {
enabled,
cache,
formatters,
}
})
async function isEnabled(item: Formatter.Info) {
async function resolveCommand(item: Formatter.Info) {
const s = await state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
let command = s.cache[item.name]
if (command === undefined) {
log.info("resolving command", { name: item.name })
command = await item.enabled()
s.cache[item.name] = command
}
return status
return command
}
async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters)
const result = []
const result: { info: Formatter.Info; command: string[] }[] = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
const command = await resolveCommand(item)
if (!command) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
result.push({ info: item, command })
}
return result
}
@@ -91,11 +89,11 @@ export namespace Format {
const s = await state()
const result: Status[] = []
for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter)
const command = await resolveCommand(formatter)
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled,
enabled: !!command,
})
}
return result
@@ -108,29 +106,27 @@ export namespace Format {
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
for (const { info, command } of await getFormatter(ext)) {
const replaced = command.map((x) => x.replace("$FILE", file))
log.info("running", { replaced })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const proc = Process.spawn(replaced, {
cwd: Instance.directory,
env: { ...process.env, ...info.environment },
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
command,
...info.environment,
})
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
command,
...info.environment,
file,
})
}

View File

@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(data, "bin"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
cache,
config,

View File

@@ -114,7 +114,6 @@ export namespace LSP {
return {
process: spawn(item.command[0], item.command.slice(1), {
cwd: root,
windowsHide: true,
env: {
...process.env,
...item.env,

View File

@@ -1,9 +1,8 @@
import { spawn as launch, type ChildProcessWithoutNullStreams } from "child_process"
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -13,11 +12,7 @@ import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
const spawn = ((cmd, args, opts) => {
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
return launch(cmd, { ...(args ?? {}), windowsHide: true })
}) as typeof launch
import { Npm } from "@/npm"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -107,7 +102,7 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
cwd: root,
env: {
...process.env,
@@ -133,29 +128,8 @@ export namespace LSPServer {
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@vue/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -218,7 +192,7 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath })
}
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
@@ -349,8 +323,8 @@ export namespace LSPServer {
if (!bin) {
const resolved = Module.resolve("biome", root)
if (!resolved) return
bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"]
bin = await Npm.which("biome")
args = ["lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
@@ -376,9 +350,7 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("gopls")
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -413,9 +385,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("rubocop")
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
@@ -520,19 +490,8 @@ export namespace LSPServer {
let binary = which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("pyright")
}
args.push("--stdio")
@@ -634,9 +593,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("zls")
if (!bin) {
const zig = which("zig")
@@ -746,9 +703,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("csharp-ls")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
@@ -785,9 +740,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("fsautocomplete")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
@@ -1053,22 +1006,8 @@ export namespace LSPServer {
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("svelte-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1100,22 +1039,8 @@ export namespace LSPServer {
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@astrojs/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1364,31 +1289,8 @@ export namespace LSPServer {
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("yaml-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1417,9 +1319,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("lua-language-server")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1555,22 +1455,8 @@ export namespace LSPServer {
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("intelephense")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1652,22 +1538,8 @@ export namespace LSPServer {
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("bash-language-server")
}
args.push("start")
const proc = spawn(binary, args, {
@@ -1688,9 +1560,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("terraform-ls")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1771,9 +1641,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("texlab")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1864,22 +1732,8 @@ export namespace LSPServer {
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("dockerfile-language-server-nodejs")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1970,9 +1824,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("tinymist")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return

View File

@@ -11,6 +11,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
const queue = [pid]
while (queue.length > 0) {
const current = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
for (const tok of lines) {
const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
if (!isNaN(cpid) && !pids.includes(cpid)) {
pids.push(cpid)
queue.push(cpid)
}

View File

@@ -1,4 +1,5 @@
import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -52,11 +53,74 @@ interface PendingAuth {
}
export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(): Promise<void> {
if (server) return
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
return
}
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
})
server!.on("error", reject)
})
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string): Promise<string> {
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> {
if (server) {
server.stop()
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}

View File

@@ -0,0 +1,8 @@
import { Server } from "./server/server"
const result = await Server.listen({
port: 1338,
hostname: "0.0.0.0",
})
console.log(result)

View File

@@ -0,0 +1,160 @@
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
// tar silently swallows the error and skips writing files, leaving only empty
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
// flag. See tar's get-write-flag.js.
// Must be set before @npmcli/arborist is imported since tar caches the flag
// at module evaluation time — so we use a dynamic import() below.
if (process.platform === "win32") {
process.env.__FAKE_PLATFORM__ = "linux"
}
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
import { Lock } from "../util/lock"
import { Log } from "../util/log"
import path from "path"
import { readdir } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
const { Arborist } = await import("@npmcli/arborist")
export namespace Npm {
const log = Log.create({ service: "npm" })
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg)
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
using _ = await Lock.write("npm-install")
log.info("installing package", {
pkg,
})
const hash = pkg
const dir = directory(hash)
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
log.info("package already installed", { pkg })
return first.path
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return first.path
}
export async function install(dir: string) {
log.info("checking dependencies", { dir })
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
}
export async function which(pkg: string) {
const dir = path.join(directory(pkg), "node_modules", ".bin")
const files = await readdir(dir).catch(() => [])
if (!files.length) {
await add(pkg)
const retry = await readdir(dir).catch(() => [])
if (!retry.length) throw new Error(`No binary found for package "${pkg}" after install`)
return path.join(dir, retry[0])
}
return path.join(dir, files[0])
}
}

View File

@@ -7,7 +7,6 @@ import { Database, eq } from "@/storage/db"
import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { ProjectID } from "@/project/schema"
import { Wildcard } from "@/util/wildcard"
import os from "os"
import z from "zod"
@@ -91,7 +90,7 @@ export namespace PermissionNext {
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
projectID: z.string(),
patterns: z.string().array(),
})

View File

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

View File

@@ -4,13 +4,13 @@ import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { Npm } from "../npm"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
export namespace Plugin {
@@ -27,7 +27,9 @@ export namespace Plugin {
directory: Instance.directory,
fetch: async (...args) => Server.Default().fetch(...args),
})
log.info("loading config")
const config = await Config.get()
log.info("config loaded")
const hooks: Hooks[] = []
const input: PluginInput = {
client,
@@ -37,7 +39,8 @@ export namespace Plugin {
get serverUrl(): URL {
throw new Error("Server URL is no longer supported in plugins")
},
$: Bun.$,
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
@@ -54,83 +57,45 @@ export namespace Plugin {
plugins = [...BUILTIN, ...plugins]
}
async function resolvePlugin(spec: string) {
const parsed = parsePluginSpecifier(spec)
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!target) return
return target
}
function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
}
function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin(value.server)) return
return value.server
}
const prep = async (item: (typeof plugins)[number]) => {
const spec = Config.pluginSpecifier(item)
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) return
log.info("loading plugin", { path: spec })
const target = await resolvePlugin(spec)
if (!target) return
const mod = await import(target).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
plugin = await Npm.add(plugin).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { plugin, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${plugin}: ${detail}`,
}).toObject(),
})
return ""
})
return
})
if (!mod) return
return {
item,
spec,
mod,
if (!plugin) continue
}
}
const loaded = await Promise.all(plugins.map((item) => prep(item)))
for (const load of loaded) {
if (!load) continue
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// uniqueModuleEntries keeps only the first export for each shared value reference.
await (async () => {
for (const [, entry] of uniqueModuleEntries(load.mod)) {
const server = getServerPlugin(entry)
if (!server) throw new TypeError("Plugin export is not a function")
hooks.push(await server(input, Config.pluginOptions(load.item)))
}
})().catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: load.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
// Object.entries(mod) would return both entries pointing to the same function reference.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
})
})
}
return {

View File

@@ -1,160 +0,0 @@
import path from "path"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { parsePluginSpecifier } from "./shared"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Entry = {
name: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
}
export type State = "first" | "updated" | "same"
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
const cache = {
ready: false,
path: "",
store: {} as Store,
dirty: false,
}
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function sourceKind(spec: string): Source {
if (spec.startsWith("file://")) return "file"
return "npm"
}
function entryKey(spec: string) {
if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}`
return `npm:${parsePluginSpecifier(spec).pkg}`
}
function entryName(spec: string) {
if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name
return parsePluginSpecifier(spec).pkg
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
function modifiedAt(file: string) {
const stat = Filesystem.stat(file)
if (!stat) return
const value = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value)
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function entryCore(spec: string, target: string): Promise<Core> {
const source = sourceKind(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
name: entryName(spec),
source,
spec,
target,
modified: file ? modifiedAt(file) : undefined,
}
}
return {
name: entryName(spec),
source,
spec,
target,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
}
}
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
async function load() {
const next = storePath()
if (cache.ready && cache.path === next) return
cache.path = next
cache.store = await Filesystem.readJson<Store>(next).catch(() => ({}) as Store)
cache.dirty = false
cache.ready = true
}
export async function touch(spec: string, target: string): Promise<{ state: State; entry: Entry }> {
await load()
const now = Date.now()
const id = entryKey(spec)
const prev = cache.store[id]
const core = await entryCore(spec, target)
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
cache.store[id] = entry
cache.dirty = true
return {
state,
entry,
}
}
export async function persist() {
await load()
if (!cache.dirty) return
await Filesystem.writeJson(cache.path, cache.store)
cache.dirty = false
}
export async function list(): Promise<Store> {
await load()
return { ...cache.store }
}
}

View File

@@ -1,26 +0,0 @@
import { BunProc } from "@/bun"
export function parsePluginSpecifier(spec: string) {
const lastAt = spec.lastIndexOf("@")
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
return { pkg, version }
}
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (spec.startsWith("file://")) return spec
return BunProc.install(parsed.pkg, parsed.version)
}
export function uniqueModuleEntries(mod: Record<string, unknown>) {
const seen = new Set<unknown>()
const entries: [string, unknown][] = []
for (const [name, entry] of Object.entries(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
entries.push([name, entry])
}
return entries
}

View File

@@ -1,9 +1,8 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql"
import type { ProjectID } from "./schema"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectID>().primaryKey(),
id: text().primaryKey(),
worktree: text().notNull(),
vcs: text(),
name: text(),

View File

@@ -15,7 +15,6 @@ import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
import { ProjectID } from "./schema"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -34,7 +33,7 @@ export namespace Project {
export const Info = z
.object({
id: ProjectID.zod,
id: z.string(),
worktree: z.string(),
vcs: z.literal("git").optional(),
name: z.string().optional(),
@@ -74,7 +73,7 @@ export namespace Project {
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined
return {
id: ProjectID.make(row.id),
id: row.id,
worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined,
@@ -92,7 +91,6 @@ export namespace Project {
function readCachedId(dir: string) {
return Filesystem.readText(path.join(dir, "opencode"))
.then((x) => x.trim())
.then(ProjectID.make)
.catch(() => undefined)
}
@@ -113,7 +111,7 @@ export namespace Project {
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
id: id ?? "global",
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
@@ -132,7 +130,7 @@ export namespace Project {
if (!worktree) {
return {
id: id ?? ProjectID.global,
id: id ?? "global",
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
@@ -162,14 +160,14 @@ export namespace Project {
if (!roots) {
return {
id: ProjectID.global,
id: "global",
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
id = roots[0] ? ProjectID.make(roots[0]) : undefined
id = roots[0]
if (id) {
await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
}
@@ -177,7 +175,7 @@ export namespace Project {
if (!id) {
return {
id: ProjectID.global,
id: "global",
worktree: sandbox,
sandbox,
vcs: "git",
@@ -210,7 +208,7 @@ export namespace Project {
}
return {
id: ProjectID.global,
id: "global",
worktree: "/",
sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
@@ -230,7 +228,7 @@ export namespace Project {
updated: Date.now(),
},
}
if (data.id !== ProjectID.global) {
if (data.id !== "global") {
await migrateFromGlobal(data.id, data.worktree)
}
return fresh
@@ -310,12 +308,12 @@ export namespace Project {
return
}
async function migrateFromGlobal(id: ProjectID, worktree: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, ProjectID.global)).get())
async function migrateFromGlobal(id: string, worktree: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
if (!row) return
const sessions = Database.use((db) =>
db.select().from(SessionTable).where(eq(SessionTable.project_id, ProjectID.global)).all(),
db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
)
if (sessions.length === 0) return
@@ -325,14 +323,14 @@ export namespace Project {
// Skip sessions that belong to a different directory
if (row.directory && row.directory !== worktree) return
log.info("migrating session", { sessionID: row.id, from: ProjectID.global, to: id })
log.info("migrating session", { sessionID: row.id, from: "global", to: id })
Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
}).catch((error) => {
log.error("failed to migrate sessions from global to project", { error, projectId: id })
})
}
export function setInitialized(id: ProjectID) {
export function setInitialized(id: string) {
Database.use((db) =>
db
.update(ProjectTable)
@@ -354,7 +352,7 @@ export namespace Project {
)
}
export function get(id: ProjectID): Info | undefined {
export function get(id: string): Info | undefined {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return undefined
return fromRow(row)
@@ -377,13 +375,12 @@ export namespace Project {
export const update = fn(
z.object({
projectID: ProjectID.zod,
projectID: z.string(),
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
}),
async (input) => {
const id = ProjectID.make(input.projectID)
const result = Database.use((db) =>
db
.update(ProjectTable)
@@ -394,7 +391,7 @@ export namespace Project {
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get(),
)
@@ -410,7 +407,7 @@ export namespace Project {
},
)
export async function sandboxes(id: ProjectID) {
export async function sandboxes(id: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
@@ -422,7 +419,7 @@ export namespace Project {
return valid
}
export async function addSandbox(id: ProjectID, directory: string) {
export async function addSandbox(id: string, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = [...row.sandboxes]
@@ -446,7 +443,7 @@ export namespace Project {
return data
}
export async function removeSandbox(id: ProjectID, directory: string) {
export async function removeSandbox(id: string, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = row.sandboxes.filter((s) => s !== directory)

View File

@@ -1,16 +0,0 @@
import { Schema } from "effect"
import z from "zod"
import { withStatics } from "@/util/schema"
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectId"))
export type ProjectID = typeof projectIdSchema.Type
export const ProjectID = projectIdSchema.pipe(
withStatics((schema: typeof projectIdSchema) => ({
global: schema.makeUnsafe("global"),
make: (id: string) => schema.makeUnsafe(id),
zod: z.string().pipe(z.custom<ProjectID>()),
})),
)

View File

@@ -40,6 +40,14 @@ export namespace ProviderError {
return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
}
function error(providerID: string, error: APICallError) {
if (providerID.includes("github-copilot") && error.statusCode === 403) {
return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
}
return error.message
}
function message(providerID: string, e: APICallError) {
return iife(() => {
const msg = e.message
@@ -52,6 +60,10 @@ export namespace ProviderError {
return "Unknown error"
}
const transformed = error(providerID, e)
if (transformed !== msg) {
return transformed
}
if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) {
return msg
}

View File

@@ -5,7 +5,7 @@ import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Npm } from "../npm"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
@@ -67,11 +67,7 @@ export namespace Provider {
const project =
options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
const location =
options["location"] ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
return {
@@ -441,11 +437,7 @@ export namespace Provider {
Env.get("GCLOUD_PROJECT")
const location =
provider.options?.location ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
provider.options?.location ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
const autoload = Boolean(project)
if (!autoload) return { autoload: false }
@@ -1209,7 +1201,7 @@ export namespace Provider {
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
installedPath = await BunProc.install(model.api.npm, "latest")
installedPath = await Npm.add(model.api.npm)
} else {
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm

View File

@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { type IPty } from "bun-pty"
import type { Proc } from "#pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
@@ -23,6 +23,8 @@ export namespace Pty {
close: (code?: number, reason?: string) => void
}
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -33,10 +35,7 @@ export namespace Pty {
return out
}
const pty = lazy(async () => {
const { spawn } = await import("bun-pty")
return spawn
})
const pty = lazy(() => import("#pty"))
export const Info = z
.object({
@@ -83,7 +82,7 @@ export namespace Pty {
interface ActiveSession {
info: Info
process: IPty
process: Proc
buffer: string
bufferCursor: number
cursor: number
@@ -97,9 +96,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
for (const [id, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
if (key(ws) === id) ws.close()
} catch {
// ignore
}
@@ -142,7 +141,7 @@ export namespace Pty {
}
log.info("creating session", { id, cmd: command, args, cwd })
const spawn = await pty()
const { spawn } = await pty()
const ptyProcess = spawn(command, args, {
name: "xterm-256color",
cwd,
@@ -170,21 +169,21 @@ export namespace Pty {
ptyProcess.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
for (const [id, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
session.subscribers.delete(id)
continue
}
if (ws.data !== key) {
session.subscribers.delete(key)
if (key(ws) !== id) {
session.subscribers.delete(id)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
session.subscribers.delete(id)
}
}
@@ -226,9 +225,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
for (const [id, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
if (key(ws) === id) ws.close()
} catch {
// ignore
}
@@ -259,16 +258,13 @@ export namespace Pty {
}
log.info("client connected to session", { id })
// Use ws.data as the unique key for this connection lifecycle.
// If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
const sub = key(ws)
// Optionally cleanup if the key somehow exists
session.subscribers.delete(connectionKey)
session.subscribers.set(connectionKey, ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => {
session.subscribers.delete(connectionKey)
session.subscribers.delete(sub)
}
const start = session.bufferCursor

View File

@@ -0,0 +1,26 @@
import { spawn as create } from "bun-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const pty = create(file, args, opts)
return {
pid: pty.pid,
onData(listener) {
return pty.onData(listener)
},
onExit(listener) {
return pty.onExit(listener)
},
write(data) {
pty.write(data)
},
resize(cols, rows) {
pty.resize(cols, rows)
},
kill(signal) {
pty.kill(signal)
},
}
}

View File

@@ -0,0 +1,26 @@
import * as pty from "node-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, opts)
return {
pid: proc.pid,
onData(listener) {
return proc.onData(listener)
},
onExit(listener) {
return proc.onExit(listener)
},
write(data) {
proc.write(data)
},
resize(cols, rows) {
proc.resize(cols, rows)
},
kill(signal) {
proc.kill(signal)
},
}
}

View File

@@ -0,0 +1,25 @@
export type Disp = {
dispose(): void
}
export type Exit = {
exitCode: number
signal?: number | string
}
export type Opts = {
name: string
cols?: number
rows?: number
cwd?: string
env?: Record<string, string>
}
export type Proc = {
pid: number
onData(listener: (data: string) => void): Disp
onExit(listener: (event: Exit) => void): Disp
write(data: string): void
resize(cols: number, rows: number): void
kill(signal?: string): void
}

View File

@@ -4,7 +4,6 @@ import { resolver } from "hono-openapi"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import z from "zod"
import { ProjectID } from "../../project/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -29,7 +28,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
async (c) => {
const projects = await Project.list()
const projects = Project.list()
return c.json(projects)
},
)
@@ -106,7 +105,7 @@ export const ProjectRoutes = lazy(() =>
...errors(400, 404),
},
}),
validator("param", z.object({ projectID: ProjectID.zod })),
validator("param", z.object({ projectID: z.string() })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID

View File

@@ -1,14 +1,13 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import type { UpgradeWebSocket } from "hono/ws"
import z from "zod"
import { Pty } from "@/pty"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PtyRoutes = lazy(() =>
new Hono()
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
.get(
"/",
describeRoute({
@@ -196,5 +195,5 @@ export const PtyRoutes = lazy(() =>
},
}
}),
),
)
)
}

View File

@@ -34,7 +34,8 @@ import { ProviderRoutes } from "./routes/provider"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
@@ -48,13 +49,20 @@ import { lazy } from "@/util/lazy"
globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server {
const log = Log.create({ service: "server" })
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export const Default = lazy(() => createApp({}))
export const Default = lazy(() => create({}).app)
export const createApp = (opts: { cors?: string[] }): Hono => {
function create(opts: { cors?: string[] }) {
const log = Log.create({ service: "server" })
const app = new Hono()
return app
const ws = createNodeWebSocket({ app })
const route = app
.onError((err, c) => {
log.error("failed", {
error: err,
@@ -239,7 +247,6 @@ export namespace Server {
),
)
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
@@ -552,6 +559,7 @@ export namespace Server {
})
},
)
.route("/pty", PtyRoutes(ws.upgradeWebSocket))
.all("/*", async (c) => {
const path = c.req.path
@@ -568,6 +576,11 @@ export namespace Server {
)
return response
})
return {
app: route as Hono,
ws,
}
}
export async function openapi() {
@@ -585,48 +598,86 @@ export namespace Server {
return result
}
export function listen(opts: {
export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}) {
const app = createApp(opts)
const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: app.fetch,
websocket: websocket,
} as const
const tryServe = (port: number) => {
try {
return Bun.serve({ ...args, port })
} catch {
return undefined
}
}): Promise<Listener> {
const log = Log.create({ service: "server" })
const built = create({
...opts,
})
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: built.app.fetch })
built.ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(addr.port)
const shouldPublishMDNS =
opts.mdns &&
server.port &&
addr.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(server.port!, opts.mdnsDomain)
MDNS.publish(addr.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
const originalStop = server.stop.bind(server)
server.stop = async (closeActiveConnections?: boolean) => {
if (shouldPublishMDNS) MDNS.unpublish()
return originalStop(closeActiveConnections)
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: addr.port,
url,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
if (shouldPublishMDNS) MDNS.unpublish()
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
}
return server
}
}

View File

@@ -23,7 +23,6 @@ import { fn } from "@/util/fn"
import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import { WorkspaceContext } from "../control-plane/workspace-context"
import { ProjectID } from "../project/schema"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
@@ -121,7 +120,7 @@ export namespace Session {
.object({
id: Identifier.schema("session"),
slug: z.string(),
projectID: ProjectID.zod,
projectID: z.string(),
workspaceID: z.string().optional(),
directory: z.string(),
parentID: Identifier.schema("session").optional(),
@@ -163,7 +162,7 @@ export namespace Session {
export const ProjectInfo = z
.object({
id: ProjectID.zod,
id: z.string(),
name: z.string().optional(),
worktree: z.string(),
})

View File

@@ -8,12 +8,9 @@ import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { Database, eq, desc, inArray } from "@/storage/db"
import { MessageTable, PartTable } from "./session.sql"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import { type SystemError } from "bun"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
export namespace MessageV2 {

View File

@@ -31,7 +31,6 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
@@ -46,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { Process } from "@/util/process"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -650,12 +650,7 @@ export namespace SessionPrompt {
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// Build system prompt, adding structured output instruction if needed
const skills = await SystemPrompt.skills(agent)
const system = [
...(await SystemPrompt.environment(model)),
...(skills ? [skills] : []),
...(await InstructionPrompt.system()),
]
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
const format = lastUser.format ?? { type: "text" }
if (format.type === "json_schema") {
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
@@ -1634,7 +1629,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const proc = spawn(shell, args, {
cwd,
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
@@ -1784,15 +1778,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments
}
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const sh = Shell.preferred()
const results = await Promise.all(
shell.map(async ([, cmd]) => {
try {
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
shellMatches.map(async ([, cmd]) => {
const out = await Process.text([cmd], { shell: sh, nothrow: true })
return out.text
}),
)
let index = 0

View File

@@ -3,7 +3,6 @@ import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot"
import type { PermissionNext } from "../permission/next"
import type { ProjectID } from "../project/schema"
import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
@@ -14,7 +13,6 @@ export const SessionTable = sqliteTable(
{
id: text().primaryKey(),
project_id: text()
.$type<ProjectID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
workspace_id: text(),

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