Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton c454c4acc5 feat: unwrap BusEvent namespace to flat exports + barrel 2026-04-16 07:29:31 -04:00
42 changed files with 491 additions and 489 deletions
+24 -26
View File
@@ -1,33 +1,31 @@
import z 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) {
const result = {
type,
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()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
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()
}
+1 -1
View File
@@ -2,7 +2,7 @@ import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
import * as BusEvent from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
+1
View File
@@ -1 +1,2 @@
export * as Bus from "./bus"
export * as BusEvent from "./bus-event"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { SessionID } from "@/session/schema"
import z from "zod"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { InstanceState } from "@/effect"
import { EffectBridge } from "@/effect"
import type { InstanceContext } from "@/project/instance"
@@ -3,7 +3,7 @@ import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, asc, eq, inArray } from "@/storage"
import { Project } from "@/project"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+1 -1
View File
@@ -6,7 +6,7 @@ import { readdir } from "fs/promises"
import path from "path"
import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { InstanceState } from "@/effect"
import { Flag } from "@/flag/flag"
import { Git } from "@/git"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Log } from "../util"
@@ -5,7 +5,7 @@ import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Flag } from "../flag/flag"
import { Log } from "../util"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import { Log } from "../util"
import * as LSPClient from "./client"
+1 -1
View File
@@ -21,7 +21,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { McpOAuthProvider } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { BusEvent } from "../bus/bus-event"
import { BusEvent } from "../bus"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
@@ -1,5 +1,5 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Config } from "@/config"
import { InstanceState } from "@/effect"
import { ProjectID } from "@/project/schema"
@@ -5,7 +5,7 @@ import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
import { setTimeout as sleep } from "node:timers/promises"
import * as CopilotModels from "./models"
import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" })
@@ -1,145 +1,146 @@
import { z } from "zod"
import type { Model } from "@opencode-ai/sdk/v2"
export const schema = z.object({
data: z.array(
z.object({
model_picker_enabled: z.boolean(),
id: z.string(),
name: z.string(),
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
max_context_window_tokens: z.number(),
max_output_tokens: z.number(),
max_prompt_tokens: z.number(),
vision: z
.object({
max_prompt_image_size: z.number(),
max_prompt_images: z.number(),
supported_media_types: z.array(z.string()),
})
.optional(),
}),
supports: z.object({
adaptive_thinking: z.boolean().optional(),
max_thinking_budget: z.number().optional(),
min_thinking_budget: z.number().optional(),
reasoning_effort: z.array(z.string()).optional(),
streaming: z.boolean(),
structured_outputs: z.boolean().optional(),
tool_calls: z.boolean(),
vision: z.boolean().optional(),
export namespace CopilotModels {
export const schema = z.object({
data: z.array(
z.object({
model_picker_enabled: z.boolean(),
id: z.string(),
name: z.string(),
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
max_context_window_tokens: z.number(),
max_output_tokens: z.number(),
max_prompt_tokens: z.number(),
vision: z
.object({
max_prompt_image_size: z.number(),
max_prompt_images: z.number(),
supported_media_types: z.array(z.string()),
})
.optional(),
}),
supports: z.object({
adaptive_thinking: z.boolean().optional(),
max_thinking_budget: z.number().optional(),
min_thinking_budget: z.number().optional(),
reasoning_effort: z.array(z.string()).optional(),
streaming: z.boolean(),
structured_outputs: z.boolean().optional(),
tool_calls: z.boolean(),
vision: z.boolean().optional(),
}),
}),
}),
}),
),
})
type Item = z.infer<typeof schema>["data"][number]
function build(key: string, remote: Item, url: string, prev?: Model): Model {
const reasoning =
!!remote.capabilities.supports.adaptive_thinking ||
!!remote.capabilities.supports.reasoning_effort?.length ||
remote.capabilities.supports.max_thinking_budget !== undefined ||
remote.capabilities.supports.min_thinking_budget !== undefined
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
},
// API response wins
status: "active",
limit: {
context: remote.capabilities.limits.max_context_window_tokens,
input: remote.capabilities.limits.max_prompt_tokens,
output: remote.capabilities.limits.max_output_tokens,
},
capabilities: {
temperature: prev?.capabilities.temperature ?? true,
reasoning: prev?.capabilities.reasoning ?? reasoning,
attachment: prev?.capabilities.attachment ?? true,
toolcall: remote.capabilities.supports.tool_calls,
input: {
text: true,
audio: false,
image,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
// existing wins
family: prev?.family ?? remote.capabilities.family,
name: prev?.name ?? remote.name,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
options: prev?.options ?? {},
headers: prev?.headers ?? {},
release_date:
prev?.release_date ??
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
variants: prev?.variants ?? {},
}
}
export async function get(
baseURL: string,
headers: HeadersInit = {},
existing: Record<string, Model> = {},
): Promise<Record<string, Model>> {
const data = await fetch(`${baseURL}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
}).then(async (res) => {
if (!res.ok) {
throw new Error(`Failed to fetch models: ${res.status}`)
}
return schema.parse(await res.json())
),
})
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
type Item = z.infer<typeof schema>["data"][number]
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
const m = remote.get(model.api.id)
if (!m) {
delete result[key]
continue
function build(key: string, remote: Item, url: string, prev?: Model): Model {
const reasoning =
!!remote.capabilities.supports.adaptive_thinking ||
!!remote.capabilities.supports.reasoning_effort?.length ||
remote.capabilities.supports.max_thinking_budget !== undefined ||
remote.capabilities.supports.min_thinking_budget !== undefined
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
},
// API response wins
status: "active",
limit: {
context: remote.capabilities.limits.max_context_window_tokens,
input: remote.capabilities.limits.max_prompt_tokens,
output: remote.capabilities.limits.max_output_tokens,
},
capabilities: {
temperature: prev?.capabilities.temperature ?? true,
reasoning: prev?.capabilities.reasoning ?? reasoning,
attachment: prev?.capabilities.attachment ?? true,
toolcall: remote.capabilities.supports.tool_calls,
input: {
text: true,
audio: false,
image,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
// existing wins
family: prev?.family ?? remote.capabilities.family,
name: prev?.name ?? remote.name,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
options: prev?.options ?? {},
headers: prev?.headers ?? {},
release_date:
prev?.release_date ??
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
variants: prev?.variants ?? {},
}
result[key] = build(key, m, baseURL, model)
}
// add new endpoint models not already keyed in result
for (const [id, m] of remote) {
if (id in result) continue
result[id] = build(id, m, baseURL)
}
export async function get(
baseURL: string,
headers: HeadersInit = {},
existing: Record<string, Model> = {},
): Promise<Record<string, Model>> {
const data = await fetch(`${baseURL}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
}).then(async (res) => {
if (!res.ok) {
throw new Error(`Failed to fetch models: ${res.status}`)
}
return schema.parse(await res.json())
})
return result
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
const m = remote.get(model.api.id)
if (!m) {
delete result[key]
continue
}
result[key] = build(key, m, baseURL, model)
}
// add new endpoint models not already keyed in result
for (const [id, m] of remote) {
if (id in result) continue
result[id] = build(id, m, baseURL)
}
return result
}
}
export * as CopilotModels from "./models"
+148 -147
View File
@@ -11,163 +11,164 @@ import {
import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version"
export type Plan = {
spec: string
options: ConfigPlugin.Options | undefined
deprecated: boolean
}
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
start?: (candidate: Candidate, retry: boolean) => void
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
error?: (
candidate: Candidate,
retry: boolean,
stage: "install" | "entry" | "compatibility" | "load",
error: unknown,
resolved?: Resolved,
) => void
}
function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
export async function resolve(
plan: Plan,
kind: PluginKind,
): Promise<
| { ok: true; value: Resolved }
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
} catch (error) {
return { ok: false, stage: "install", error }
export namespace PluginLoader {
export type Plan = {
spec: string
options: ConfigPlugin.Options | undefined
deprecated: boolean
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
} catch (error) {
return { ok: false, stage: "entry", error }
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
if (!base.entry)
return {
ok: false,
stage: "missing",
value: {
...plan,
source: base.source,
target: base.target,
pkg: base.pkg,
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
},
}
if (base.source === "npm") {
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
start?: (candidate: Candidate, retry: boolean) => void
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
error?: (
candidate: Candidate,
retry: boolean,
stage: "install" | "entry" | "compatibility" | "load",
error: unknown,
resolved?: Resolved,
) => void
}
function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
export async function resolve(
plan: Plan,
kind: PluginKind,
): Promise<
| { ok: true; value: Resolved }
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
let target = ""
try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
target = await resolvePluginTarget(plan.spec)
} catch (error) {
return { ok: false, stage: "compatibility", error }
return { ok: false, stage: "install", error }
}
}
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
mod = await import(row.entry)
} catch (error) {
return { ok: false, error }
}
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
return { ok: true, value: { ...row, mod } }
}
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
retry: boolean,
finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
if (plan.deprecated) return
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
} catch (error) {
return { ok: false, stage: "entry", error }
}
if (!base.entry)
return {
ok: false,
stage: "missing",
value: {
...plan,
source: base.source,
target: base.target,
pkg: base.pkg,
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
},
}
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
} catch (error) {
return { ok: false, stage: "compatibility", error }
}
}
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
mod = await import(row.entry)
} catch (error) {
return { ok: false, error }
}
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
return { ok: true, value: { ...row, mod } }
}
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
retry: boolean,
finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
if (plan.deprecated) return
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
}
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
return
}
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
}
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
}
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
}
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
}
type Input<R> = {
items: ConfigPlugin.Origin[]
kind: PluginKind
wait?: () => Promise<void>
finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
report?: Report
}
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
for (const candidate of candidates) {
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
}
const out = await Promise.all(list)
if (input.wait) {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
await deps
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
}
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
}
type Input<R> = {
items: ConfigPlugin.Origin[]
kind: PluginKind
wait?: () => Promise<void>
finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
report?: Report
}
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
for (const candidate of candidates) {
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
}
const out = await Promise.all(list)
if (input.wait) {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
await deps
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
}
}
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
return ready
}
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
return ready
}
export * as PluginLoader from "./loader"
+149 -148
View File
@@ -8,180 +8,181 @@ import { Flock } from "@opencode-ai/shared/util/flock"
import { parsePluginSpecifier, pluginSource } from "./shared"
type Source = "file" | "npm"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Entry = {
id: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type Entry = {
id: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type State = "first" | "updated" | "same"
export type State = "first" | "updated" | "same"
export type Touch = {
spec: string
target: string
id: string
}
export type Touch = {
spec: string
target: string
id: string
}
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core }
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core }
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function lock(file: string) {
return `plugin-meta:${file}`
}
function lock(file: string) {
return `plugin-meta:${file}`
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function entryCore(item: Touch): Promise<Core> {
const spec = item.spec
const target = item.target
const source = pluginSource(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
id: item.id,
source,
spec,
target,
modified: file ? await modifiedAt(file) : undefined,
}
}
async function entryCore(item: Touch): Promise<Core> {
const spec = item.spec
const target = item.target
const source = pluginSource(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
id: item.id,
source,
spec,
target,
modified: file ? await modifiedAt(file) : undefined,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
}
}
return {
id: item.id,
source,
spec,
target,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
}
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
async function read(file: string): Promise<Store> {
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
}
async function row(item: Touch): Promise<Row> {
return {
...item,
core: await entryCore(item),
async function read(file: string): Promise<Store> {
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
}
}
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
return {
state,
entry,
}
}
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
if (!items.length) return []
const file = storePath()
const rows = await Promise.all(items.map((item) => row(item)))
return Flock.withLock(lock(file), async () => {
const store = await read(file)
const now = Date.now()
const out: Array<{ state: State; entry: Entry }> = []
for (const item of rows) {
const hit = next(store[item.id], item.core, now)
store[item.id] = hit.entry
out.push(hit)
async function row(item: Touch): Promise<Row> {
return {
...item,
core: await entryCore(item),
}
await Filesystem.writeJson(file, store)
return out
})
}
}
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
return touchMany([{ spec, target, id }]).then((item) => {
const hit = item[0]
if (hit) return hit
throw new Error("Failed to touch plugin metadata.")
})
}
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...entry.themes,
[name]: theme,
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
await Filesystem.writeJson(file, store)
})
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
return {
state,
entry,
}
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
if (!items.length) return []
const file = storePath()
const rows = await Promise.all(items.map((item) => row(item)))
return Flock.withLock(lock(file), async () => {
const store = await read(file)
const now = Date.now()
const out: Array<{ state: State; entry: Entry }> = []
for (const item of rows) {
const hit = next(store[item.id], item.core, now)
store[item.id] = hit.entry
out.push(hit)
}
await Filesystem.writeJson(file, store)
return out
})
}
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
return touchMany([{ spec, target, id }]).then((item) => {
const hit = item[0]
if (hit) return hit
throw new Error("Failed to touch plugin metadata.")
})
}
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...entry.themes,
[name]: theme,
}
await Filesystem.writeJson(file, store)
})
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))
}
}
export * as PluginMeta from "./meta"
+1 -1
View File
@@ -21,7 +21,7 @@ import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
import { errorMessage } from "@/util/error"
import * as PluginLoader from "./loader"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdaptor } from "@/control-plane/adaptors"
import type { WorkspaceAdaptor } from "@/control-plane/types"
+1 -1
View File
@@ -4,7 +4,7 @@ import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { Log } from "../util"
import { Flag } from "@/flag/flag"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { ProjectID } from "./schema"
+1 -1
View File
@@ -2,7 +2,7 @@ import { Effect, Layer, Context, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileWatcher } from "@/file/watcher"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { Instance } from "@/project/instance"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { InstanceState } from "@/effect"
import { SessionID, MessageID } from "@/session/schema"
import { zod } from "@/util/effect-zod"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import z from "zod"
export const Event = {
@@ -3,7 +3,7 @@ import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Log } from "@/util"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import { AsyncQueue } from "../../util/queue"
@@ -3,7 +3,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Effect } from "effect"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { AppRuntime } from "@/effect/app-runtime"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import * as Session from "./session"
import { SessionID, MessageID, PartID } from "./schema"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { SessionID, MessageID, PartID } from "./schema"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Slug } from "@opencode-ai/shared/util/slug"
import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { SessionID } from "./schema"
+1 -1
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Bus } from "@/bus"
import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
+1 -1
View File
@@ -3,7 +3,7 @@ import type { ZodObject } from "zod"
import { Database, eq } from "@/storage"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { Instance } from "@/project/instance"
import { EventSequenceTable, EventTable } from "./event.sql"
import { WorkspaceContext } from "@/control-plane/workspace-context"
+1 -1
View File
@@ -10,7 +10,7 @@ import type { ProjectID } from "../project/schema"
import { Log } from "../util"
import { Slug } from "@opencode-ai/shared/util/slug"
import { errorMessage } from "../util/error"
import { BusEvent } from "@/bus/bus-event"
import { BusEvent } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Git } from "@/git"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Stream } from "effect"
import z from "zod"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { BusEvent } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import z from "zod"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { BusEvent } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
+1 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import z from "zod"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { BusEvent } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
@@ -14,6 +14,6 @@ if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
process.env.OPENCODE_PLUGIN_META_FILE = msg.file
const PluginMeta = await import("../../src/plugin/meta")
const { PluginMeta } = await import("../../src/plugin/meta")
await PluginMeta.touch(msg.spec, msg.target, msg.id)
@@ -1,5 +1,5 @@
import { afterEach, expect, mock, test } from "bun:test"
import { CopilotModels } from "../../src/plugin/github-copilot/models"
import { CopilotModels } from "@/plugin/github-copilot/models"
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
const originalFetch = globalThis.fetch
@@ -10,7 +10,7 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const PluginLoader = await import("../../src/plugin/loader")
const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared")
const { Instance } = await import("../../src/project/instance")
const { Npm } = await import("../../src/npm")
+1 -1
View File
@@ -7,7 +7,7 @@ import { tmpdir } from "../fixture/fixture"
import { Process } from "../../src/util"
import { Filesystem } from "../../src/util"
const PluginMeta = await import("../../src/plugin/meta")
const { PluginMeta } = await import("../../src/plugin/meta")
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
+1 -1
View File
@@ -11,7 +11,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { BusEvent } from "../../src/bus"
import { Truncate } from "../../src/tool"
import { SessionID, MessageID } from "../../src/session/schema"