Compare commits

...

13 Commits

Author SHA1 Message Date
Aiden Cline 802534891c fix: ensure copilot model list filters out disabled models 2026-04-17 15:50:43 -05:00
Kit Langton 3431dfb8b8 refactor: unwrap ServerProxy namespace + self-reexport (#22954) 2026-04-16 21:40:31 +00:00
Kit Langton fefe8b500a refactor: unwrap FileWatcher namespace + self-reexport (#22941) 2026-04-16 17:39:56 -04:00
Kit Langton 5b8573e804 refactor: unwrap FileTime namespace + self-reexport (#22940) 2026-04-16 17:39:53 -04:00
Kit Langton 40123cbe2d refactor: unwrap Server namespace + self-reexport (#22955) 2026-04-16 21:39:52 +00:00
Kit Langton 016c641860 refactor: unwrap Ripgrep namespace + self-reexport (#22939) 2026-04-16 17:39:50 -04:00
Kit Langton b41b0e3d2d refactor: unwrap MDNS namespace + self-reexport (#22953) 2026-04-16 21:38:47 +00:00
Kit Langton 3d3e50ebf0 refactor: unwrap Identifier namespace + self-reexport (#22932) 2026-04-16 17:37:51 -04:00
Kit Langton 0abc0d541a refactor: unwrap BusEvent namespace + self-reexport (#22930) 2026-04-16 17:37:47 -04:00
Dax Raad 3f3989e694 fix type error 2026-04-16 17:35:56 -04:00
opencode-agent[bot] b4667d9b0d chore: generate 2026-04-16 21:33:01 +00:00
Dax Raad 3c68d75776 import performance improvements 2026-04-16 17:31:43 -04:00
Kit Langton 23ed876835 fix: narrow several from any type assertions in opencode core (#22926) 2026-04-16 17:15:18 -04:00
30 changed files with 1175 additions and 1193 deletions
+1 -1
View File
@@ -213,7 +213,7 @@ for (const item of targets) {
}, },
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: [ entrypoints: [
"./src/index.ts", "./src/temporary.ts",
parserWorker, parserWorker,
workerPath, workerPath,
rgPath, rgPath,
+26 -26
View File
@@ -1,33 +1,33 @@
import z from "zod" import z from "zod"
import type { ZodType } from "zod" import type { ZodType } from "zod"
export namespace BusEvent { export type Definition = ReturnType<typeof define>
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>() const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) { export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = { const result = {
type, type,
properties, properties,
}
registry.set(type, result)
return result
}
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
} }
registry.set(type, result)
return result
} }
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
}
export * as BusEvent from "./bus-event"
+3 -5
View File
@@ -28,7 +28,7 @@ import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading" import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local" import { LocalProvider, parseModel, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status" import { DialogStatus } from "@tui/component/dialog-status"
@@ -49,10 +49,8 @@ import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm" import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast" import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit" import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event" import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv" import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args" import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
@@ -304,7 +302,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
if (route.data.type === "session") { if (route.data.type === "session") {
const session = sync.session.get(route.data.sessionID) const session = sync.session.get(route.data.sessionID)
if (!session || SessionApi.isDefaultTitle(session.title)) { if (!session) {
renderer.setTerminalTitle("OpenCode") renderer.setTerminalTitle("OpenCode")
return return
} }
@@ -324,7 +322,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
batch(() => { batch(() => {
if (args.agent) local.agent.set(args.agent) if (args.agent) local.agent.set(args.agent)
if (args.model) { if (args.model) {
const { providerID, modelID } = Provider.parseModel(args.model) const { providerID, modelID } = parseModel(args.model)
if (!providerID || !modelID) if (!providerID || !modelID)
return toast.show({ return toast.show({
variant: "warning", variant: "warning",
@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [store, setStore] = createStore<Record<string, any>>() const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json") const filePath = path.join(Global.Path.state, "kv.json")
Filesystem.readJson(filePath) Filesystem.readJson<Record<string, any>>(filePath)
.then((x) => { .then((x) => {
setStore(x) setStore(x)
}) })
+1 -2
View File
@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import z from "zod" import z from "zod"
export const TuiEvent = { export const TuiEvent = {
@@ -42,7 +41,7 @@ export const TuiEvent = {
SessionSelect: BusEvent.define( SessionSelect: BusEvent.define(
"tui.session.select", "tui.session.select",
z.object({ z.object({
sessionID: SessionID.zod.describe("Session ID to navigate to"), sessionID: z.string().describe("Session ID to navigate to"),
}), }),
), ),
} }
@@ -7,7 +7,7 @@ function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true) const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.lsp()) const list = createMemo(() => props.api.state.lsp())
const off = createMemo(() => props.api.state.config.lsp === false) const off = createMemo(() => props.api.state.config.lsp)
return ( return (
<box> <box>
@@ -16,7 +16,6 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util" import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { import {
readPackageThemes, readPackageThemes,
readPluginId, readPluginId,
@@ -790,10 +789,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
} }
const ready = await Instance.provide({ const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()).catch((error) => {
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
fail("failed to add tui plugin", { path: next, error }) fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[] return [] as PluginLoad[]
}) })
@@ -987,42 +983,37 @@ export namespace TuiPluginRuntime {
} }
runtime = next runtime = next
try { try {
await Instance.provide({ const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
directory: cwd, if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
fn: async () => { log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) }
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) { for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id }) log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item) const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, { addPluginEntry(next, {
id: entry.id, id: entry.id,
load: entry, load: entry,
meta, meta,
themes: {}, themes: {},
plugin: entry.module.tui, plugin: entry.module.tui,
enabled: true, enabled: true,
}) })
} }
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready) await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config) applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) { for (const plugin of next.plugins) {
if (!plugin.enabled) continue if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects: // Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence, // command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide, // route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering. // and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false) await activatePluginEntry(next, plugin, false)
} }
},
})
} catch (error) { } catch (error) {
fail("failed to load tui plugins", { directory: cwd, error }) fail("failed to load tui plugins", { directory: cwd, error })
} }
+4 -4
View File
@@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) { if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data const data = (input as ErrorLike).data
const suggestions = data?.suggestions as string[] | undefined const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
return [ return [
`Model not found: ${data?.providerID}/${data?.modelID}`, `Model not found: ${data?.providerID}/${data?.modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`, `Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`, `Or check your config (opencode.json) provider/model names`,
].join("\n") ].join("\n")
@@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
const data = (input as ErrorLike).data const data = (input as ErrorLike).data
const path = data?.path const path = data?.path
const message = data?.message const message = data?.message
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
return [ return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n") ].join("\n")
} }
+1 -1
View File
@@ -203,7 +203,7 @@ export const Info = z
.optional(), .optional(),
lsp: z lsp: z
.union([ .union([
z.literal(false), z.literal(true),
z.record( z.record(
z.string(), z.string(),
z.union([ z.union([
+1 -1
View File
@@ -5,7 +5,7 @@ import os from "os"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import { InvalidError } from "./error" import { InvalidError } from "./error"
type ParseSource = export type ParseSource =
| { | {
type: "path" type: "path"
path: string path: string
File diff suppressed because it is too large Load Diff
+104 -104
View File
@@ -5,109 +5,109 @@ import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Log } from "../util" import { Log } from "../util"
export namespace FileTime { const log = Log.create({ service: "file.time" })
const log = Log.create({ service: "file.time" })
export type Stamp = { export type Stamp = {
readonly read: Date readonly read: Date
readonly mtime: number | undefined readonly mtime: number | undefined
readonly size: number | undefined readonly size: number | undefined
}
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
size: info ? Number(info.size) : undefined,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
filepath = AppFileSystem.normalizePath(filepath)
const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
})
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
filepath = AppFileSystem.normalizePath(filepath)
const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
} }
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
size: info ? Number(info.size) : undefined,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
filepath = AppFileSystem.normalizePath(filepath)
const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
})
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
filepath = AppFileSystem.normalizePath(filepath)
const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export * as FileTime from "./time"
+139 -139
View File
@@ -19,145 +19,145 @@ import { Log } from "../util"
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
export namespace FileWatcher { const log = Log.create({ service: "file.watcher" })
const log = Log.create({ service: "file.watcher" }) const SUBSCRIBE_TIMEOUT_MS = 10_000
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
"file.watcher.updated", "file.watcher.updated",
z.object({ z.object({
file: z.string(), file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const rel = path.relative(dir, item)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
})
}
export const hasNativeBinding = () => !!watcher()
export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: Instance.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* config.get()
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (Instance.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
})
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
},
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.void
}),
),
)
return Service.of({
init: Effect.fn("FileWatcher.init")(function* () {
yield* InstanceState.get(state)
}),
})
}), }),
) ),
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
} }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const rel = path.relative(dir, item)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
})
}
export const hasNativeBinding = () => !!watcher()
export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: Instance.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* config.get()
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (Instance.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
})
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
},
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.void
}),
),
)
return Service.of({
init: Effect.fn("FileWatcher.init")(function* () {
yield* InstanceState.get(state)
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
export * as FileWatcher from "./watcher"
+81 -81
View File
@@ -1,86 +1,86 @@
import z from "zod" import z from "zod"
import { randomBytes } from "crypto" import { randomBytes } from "crypto"
export namespace Identifier { const prefixes = {
const prefixes = { event: "evt",
event: "evt", session: "ses",
session: "ses", message: "msg",
message: "msg", permission: "per",
permission: "per", question: "que",
question: "que", user: "usr",
user: "usr", part: "prt",
part: "prt", pty: "pty",
pty: "pty", tool: "tool",
tool: "tool", workspace: "wrk",
workspace: "wrk", entry: "ent",
entry: "ent", } as const
} as const
export function schema(prefix: keyof typeof prefixes) { export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix]) return z.string().startsWith(prefixes[prefix])
}
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return create(prefixes[prefix], direction)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
} }
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return create(prefixes[prefix], direction)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
export * as Identifier from "./id"
+5 -6
View File
@@ -167,7 +167,7 @@ export const layer = Layer.effect(
const servers: Record<string, LSPServer.Info> = {} const servers: Record<string, LSPServer.Info> = {}
if (cfg.lsp === false) { if (!cfg.lsp) {
log.info("all LSPs are disabled") log.info("all LSPs are disabled")
} else { } else {
for (const server of Object.values(LSPServer)) { for (const server of Object.values(LSPServer)) {
@@ -440,12 +440,11 @@ export const layer = Layer.effect(
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
const results = yield* runAll((client) => const results = yield* runAll((client) =>
client.connection client.connection
.sendRequest("workspace/symbol", { query }) .sendRequest<Symbol[]>("workspace/symbol", { query })
.then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind))) .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
.then((result: any) => result.slice(0, 10)) .catch(() => [] as Symbol[]),
.catch(() => []),
) )
return results.flat() as Symbol[] return results.flat()
}) })
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
+11 -2
View File
@@ -124,8 +124,17 @@ export async function install(dir: string) {
return return
} }
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({})) type PackageDeps = Record<string, string>
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({})) type PackageJson = {
dependencies?: PackageDeps
devDependencies?: PackageDeps
peerDependencies?: PackageDeps
optionalDependencies?: PackageDeps
}
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
packages?: Record<string, PackageJson>
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([ const declared = new Set([
...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.dependencies || {}),
@@ -1,6 +1,5 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2" import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version" import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "../../util" import { Log } from "../../util"
@@ -11,6 +11,11 @@ export namespace CopilotModels {
// every version looks like: `{model.id}-YYYY-MM-DD` // every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(), version: z.string(),
supported_endpoints: z.array(z.string()).optional(), supported_endpoints: z.array(z.string()).optional(),
policy: z
.object({
state: z.string().optional(),
})
.optional(),
capabilities: z.object({ capabilities: z.object({
family: z.string(), family: z.string(),
limits: z.object({ limits: z.object({
@@ -123,7 +128,9 @@ export namespace CopilotModels {
}) })
const result = { ...existing } const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const)) const remote = new Map(
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
)
// prune existing models whose api.id isn't in the endpoint response // prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) { for (const [key, model] of Object.entries(result)) {
+4 -2
View File
@@ -547,12 +547,14 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}, },
async getModel(sdk: any, modelID: string, options?: Record<string, any>) { async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (modelID.startsWith("duo-workflow-")) { if (modelID.startsWith("duo-workflow-")) {
const workflowRef = options?.workflowRef as string | undefined const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
const workflowDefinition =
typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined
const model = sdk.workflowChat(sdkModelID, { const model = sdk.workflowChat(sdkModelID, {
featureFlags, featureFlags,
workflowDefinition: options?.workflowDefinition as string | undefined, workflowDefinition,
}) })
if (workflowRef) { if (workflowRef) {
model.selectedModelRef = workflowRef model.selectedModelRef = workflowRef
+2 -1
View File
@@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "../../util/queue" import { AsyncQueue } from "../../util/queue"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { SessionID } from "@/session/schema"
const TuiRequest = z.object({ const TuiRequest = z.object({
path: z.string(), path: z.string(),
@@ -371,7 +372,7 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties), validator("json", TuiEvent.SessionSelect.properties),
async (c) => { async (c) => {
const { sessionID } = c.req.valid("json") const { sessionID } = c.req.valid("json")
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(SessionID.make(sessionID))))
await Bus.publish(TuiEvent.SessionSelect, { sessionID }) await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true) return c.json(true)
}, },
+44 -44
View File
@@ -3,58 +3,58 @@ import { Bonjour } from "bonjour-service"
const log = Log.create({ service: "mdns" }) const log = Log.create({ service: "mdns" })
export namespace MDNS { let bonjour: Bonjour | undefined
let bonjour: Bonjour | undefined let currentPort: number | undefined
let currentPort: number | undefined
export function publish(port: number, domain?: string) { export function publish(port: number, domain?: string) {
if (currentPort === port) return if (currentPort === port) return
if (bonjour) unpublish() if (bonjour) unpublish()
try { try {
const host = domain ?? "opencode.local" const host = domain ?? "opencode.local"
const name = `opencode-${port}` const name = `opencode-${port}`
bonjour = new Bonjour() bonjour = new Bonjour()
const service = bonjour.publish({ const service = bonjour.publish({
name, name,
type: "http", type: "http",
host, host,
port, port,
txt: { path: "/" }, txt: { path: "/" },
}) })
service.on("up", () => { service.on("up", () => {
log.info("mDNS service published", { name, port }) log.info("mDNS service published", { name, port })
}) })
service.on("error", (err) => { service.on("error", (err) => {
log.error("mDNS service error", { error: err }) log.error("mDNS service error", { error: err })
}) })
currentPort = port currentPort = port
} catch (err) { } catch (err) {
log.error("mDNS publish failed", { error: err }) log.error("mDNS publish failed", { error: err })
if (bonjour) {
try {
bonjour.destroy()
} catch {}
}
bonjour = undefined
currentPort = undefined
}
}
export function unpublish() {
if (bonjour) { if (bonjour) {
try { try {
bonjour.unpublishAll()
bonjour.destroy() bonjour.destroy()
} catch (err) { } catch {}
log.error("mDNS unpublish failed", { error: err })
}
bonjour = undefined
currentPort = undefined
log.info("mDNS service unpublished")
} }
bonjour = undefined
currentPort = undefined
} }
} }
export function unpublish() {
if (bonjour) {
try {
bonjour.unpublishAll()
bonjour.destroy()
} catch (err) {
log.error("mDNS unpublish failed", { error: err })
}
bonjour = undefined
currentPort = undefined
log.info("mDNS service unpublished")
}
}
export * as MDNS from "./mdns"
+73 -73
View File
@@ -101,83 +101,83 @@ const app = (upgrade: UpgradeWebSocket) =>
}), }),
) )
export namespace ServerProxy { const log = Log.Default.clone().tag("service", "server-proxy")
const log = Log.Default.clone().tag("service", "server-proxy")
export async function http( export async function http(
url: string | URL, url: string | URL,
extra: HeadersInit | undefined, extra: HeadersInit | undefined,
req: Request, req: Request,
workspaceID: WorkspaceID, workspaceID: WorkspaceID,
) { ) {
if (!Workspace.isSyncing(workspaceID)) { if (!Workspace.isSyncing(workspaceID)) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, { return new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503, status: 503,
headers: { headers: {
"content-type": "text/plain; charset=utf-8", "content-type": "text/plain; charset=utf-8",
}, },
})
}
return fetch(
new Request(url, {
method: req.method,
headers: headers(req, extra),
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
redirect: "manual",
signal: req.signal,
}),
).then((res) => {
const sync = Fence.parse(res.headers)
const next = new Headers(res.headers)
next.delete("content-encoding")
next.delete("content-length")
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
return done.then(async () => {
console.log("proxy http response", {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
})
})
}) })
} }
export function websocket( return fetch(
upgrade: UpgradeWebSocket, new Request(url, {
target: string | URL, method: req.method,
extra: HeadersInit | undefined, headers: headers(req, extra),
req: Request, body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
env: unknown, redirect: "manual",
) { signal: req.signal,
const proxy = new URL(req.url) }),
proxy.pathname = "/__workspace_ws" ).then((res) => {
proxy.search = "" const sync = Fence.parse(res.headers)
const next = new Headers(req.headers) const next = new Headers(res.headers)
next.set("x-opencode-proxy-url", socket(target)) next.delete("content-encoding")
for (const [key, value] of new Headers(extra).entries()) { next.delete("content-length")
next.set(key, value)
} const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
log.info("proxy websocket", {
request: req.url, return done.then(async () => {
target: String(target), console.log("proxy http response", {
})
return app(upgrade).fetch(
new Request(proxy, {
method: req.method, method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next, headers: next,
signal: req.signal, })
}), })
env as never, })
)
}
} }
export function websocket(
upgrade: UpgradeWebSocket,
target: string | URL,
extra: HeadersInit | undefined,
req: Request,
env: unknown,
) {
const proxy = new URL(req.url)
proxy.pathname = "/__workspace_ws"
proxy.search = ""
const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target))
for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value)
}
log.info("proxy websocket", {
request: req.url,
target: String(target),
})
return app(upgrade).fetch(
new Request(proxy, {
method: req.method,
headers: next,
signal: req.signal,
}),
env as never,
)
}
export * as ServerProxy from "./proxy"
+90 -90
View File
@@ -17,37 +17,22 @@ globalThis.AI_SDK_LOG_WARNINGS = false
initProjectors() initProjectors()
export namespace Server { const log = Log.create({ service: "server" })
const log = Log.create({ service: "server" })
export type Listener = { export type Listener = {
hostname: string hostname: string
port: number port: number
url: URL url: URL
stop: (close?: boolean) => Promise<void> stop: (close?: boolean) => Promise<void>
} }
export const Default = lazy(() => create({})) export const Default = lazy(() => create({}))
function create(opts: { cors?: string[] }) { function create(opts: { cors?: string[] }) {
const app = new Hono() const app = new Hono()
const runtime = adapter.create(app) const runtime = adapter.create(app)
if (Flag.OPENCODE_WORKSPACE_ID) {
return {
app: app
.onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.use(FenceMiddleware)
.route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
runtime,
}
}
if (Flag.OPENCODE_WORKSPACE_ID) {
return { return {
app: app app: app
.onError(ErrorMiddleware) .onError(ErrorMiddleware)
@@ -55,73 +40,88 @@ export namespace Server {
.use(LoggerMiddleware) .use(LoggerMiddleware)
.use(CompressionMiddleware) .use(CompressionMiddleware)
.use(CorsMiddleware(opts)) .use(CorsMiddleware(opts))
.use(FenceMiddleware)
.route("/", ControlPlaneRoutes()) .route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", InstanceRoutes(runtime.upgradeWebSocket)),
.route("/", UIRoutes()),
runtime, runtime,
} }
} }
export async function openapi() { return {
// Build a fresh app with all routes registered directly so app: app
// hono-openapi can see describeRoute metadata (`.route()` wraps .onError(ErrorMiddleware)
// handlers when the sub-app has a custom errorHandler, which .use(AuthMiddleware)
// strips the metadata symbol). .use(LoggerMiddleware)
const { app } = create({}) .use(CompressionMiddleware)
const result = await generateSpecs(app, { .use(CorsMiddleware(opts))
documentation: { .route("/", ControlPlaneRoutes())
info: { .route("/", InstanceRoutes(runtime.upgradeWebSocket))
title: "opencode", .route("/", UIRoutes()),
version: "1.0.0", runtime,
description: "opencode api",
},
openapi: "3.1.1",
},
})
return result
}
export let url: URL
export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
const next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(server.port)
url = next
const mdns =
opts.mdns &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (mdns) {
MDNS.publish(server.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: server.port,
url: next,
stop(close?: boolean) {
closing ??= (async () => {
if (mdns) MDNS.unpublish()
await server.stop(close)
})()
return closing
},
}
} }
} }
export async function openapi() {
// Build a fresh app with all routes registered directly so
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which
// strips the metadata symbol).
const { app } = create({})
const result = await generateSpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
description: "opencode api",
},
openapi: "3.1.1",
},
})
return result
}
export let url: URL
export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
const next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(server.port)
url = next
const mdns =
opts.mdns &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (mdns) {
MDNS.publish(server.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: server.port,
url: next,
stop(close?: boolean) {
closing ??= (async () => {
if (mdns) MDNS.unpublish()
await server.stop(close)
})()
return closing
},
}
}
export * as Server from "./server"
+12 -10
View File
@@ -272,16 +272,18 @@ export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsa
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
) )
const cacheWriteInputTokens = safe( const cacheWriteInputTokens = safe(
(input.usage.inputTokenDetails?.cacheWriteTokens ?? Number(
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? input.usage.inputTokenDetails?.cacheWriteTokens ??
// google-vertex-anthropic returns metadata under "vertex" key input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') // google-vertex-anthropic returns metadata under "vertex" key
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
// @ts-expect-error input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? // @ts-expect-error
// @ts-expect-error input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? // @ts-expect-error
0) as number, input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0,
),
) )
// AI SDK v6 normalized inputTokens to include cached tokens across all providers // AI SDK v6 normalized inputTokens to include cached tokens across all providers
+3 -26
View File
@@ -1,33 +1,10 @@
import yargs from "yargs"
import { TuiThreadCommand } from "./cli/cmd/tui/thread" import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { InstallationVersion } from "./installation/version"
import { hideBin } from "yargs/helpers"
import { Log } from "./node" import { Log } from "./node"
Log.init({ Log.init({
print: false, print: false,
}) })
const cli = yargs(hideBin(process.argv)) console.log(TuiThreadCommand)
.parserConfiguration({ "populate--": true })
.scriptName("opencode") console.log(performance.now())
.wrap(100)
.help("help", "show help")
.alias("help", "h")
.version("version", "show version number", InstallationVersion)
.alias("version", "v")
.option("print-logs", {
describe: "print logs to stderr",
type: "boolean",
})
.option("log-level", {
describe: "log level",
type: "string",
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
})
.option("pure", {
describe: "run without external plugins",
type: "boolean",
})
.command(TuiThreadCommand)
.parse()
+1 -1
View File
@@ -19,7 +19,7 @@ export type Context<M extends Metadata = Metadata> = {
agent: string agent: string
abort: AbortSignal abort: AbortSignal
callID?: string callID?: string
extra?: { [key: string]: any } extra?: { [key: string]: unknown }
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void> metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void> ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
+1 -1
View File
@@ -39,7 +39,7 @@ export async function readText(p: string): Promise<string> {
return readFile(p, "utf-8") return readFile(p, "utf-8")
} }
export async function readJson<T = any>(p: string): Promise<T> { export async function readJson<T = unknown>(p: string): Promise<T> {
return JSON.parse(await readFile(p, "utf-8")) return JSON.parse(await readFile(p, "utf-8"))
} }
+1 -1
View File
@@ -757,7 +757,7 @@ test("updates config and writes to file", async () => {
const newConfig = { model: "updated/model" } const newConfig = { model: "updated/model" }
await save(newConfig as any) await save(newConfig as any)
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json")) const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model") expect(writtenConfig.model).toBe("updated/model")
}, },
}) })
+1 -1
View File
@@ -1601,7 +1601,7 @@ export type Config = {
} }
} }
lsp?: lsp?:
| false | true
| { | {
[key: string]: [key: string]:
| { | {
+3 -5
View File
@@ -6938,8 +6938,7 @@
"properties": { "properties": {
"sessionID": { "sessionID": {
"description": "Session ID to navigate to", "description": "Session ID to navigate to",
"type": "string", "type": "string"
"pattern": "^ses.*"
} }
}, },
"required": ["sessionID"] "required": ["sessionID"]
@@ -8511,8 +8510,7 @@
"properties": { "properties": {
"sessionID": { "sessionID": {
"description": "Session ID to navigate to", "description": "Session ID to navigate to",
"type": "string", "type": "string"
"pattern": "^ses.*"
} }
}, },
"required": ["sessionID"] "required": ["sessionID"]
@@ -11761,7 +11759,7 @@
"anyOf": [ "anyOf": [
{ {
"type": "boolean", "type": "boolean",
"const": false "const": true
}, },
{ {
"type": "object", "type": "object",