mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 802534891c | |||
| 3431dfb8b8 | |||
| fefe8b500a | |||
| 5b8573e804 | |||
| 40123cbe2d | |||
| 016c641860 | |||
| b41b0e3d2d | |||
| 3d3e50ebf0 | |||
| 0abc0d541a | |||
| 3f3989e694 | |||
| b4667d9b0d | |||
| 3c68d75776 | |||
| 23ed876835 |
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1601,7 +1601,7 @@ export type Config = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lsp?:
|
lsp?:
|
||||||
| false
|
| true
|
||||||
| {
|
| {
|
||||||
[key: string]:
|
[key: string]:
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user