Compare commits

..

16 Commits

Author SHA1 Message Date
opencode-agent[bot]
c8af8f96ce chore: generate 2026-04-16 03:57:53 +00:00
Kit Langton
5011465c81 feat: unwrap tool namespaces to flat exports + barrel (#22762) 2026-04-16 03:56:54 +00:00
Kit Langton
f6cc228684 feat: unwrap cli-tui namespaces to flat exports + barrel (#22759) 2026-04-16 03:56:51 +00:00
Kit Langton
9f4b73b6a3 fix: clean up final 16 no-unused-vars warnings (#22751) 2026-04-16 03:54:21 +00:00
Kit Langton
bd29004831 feat: enable type-aware no-misused-spread rule, fix 8 violations (#22749) 2026-04-16 03:50:50 +00:00
Kit Langton
8aa0f9fe95 feat: enable type-aware no-base-to-string rule, fix 56 violations (#22750) 2026-04-16 03:50:47 +00:00
Kit Langton
c802695ee9 docs: add circular import rules to namespace treeshake spec (#22754) 2026-04-15 23:44:08 -04:00
opencode-agent[bot]
225a769411 chore: generate 2026-04-16 03:42:25 +00:00
Kit Langton
0e20382396 fix: resolve circular sibling imports causing runtime ReferenceError (#22752) 2026-04-15 23:41:34 -04:00
Kit Langton
509bc11f81 feat: unwrap lsp namespaces to flat exports + barrel (#22748) 2026-04-15 23:30:52 -04:00
Kit Langton
f24207844f feat: unwrap storage namespaces to flat exports + barrel (#22747) 2026-04-15 23:30:49 -04:00
Kit Langton
1ca257e356 feat: unwrap config namespaces to flat exports + barrel (#22746) 2026-04-15 23:29:14 -04:00
Kit Langton
d4cfbd020d feat: unwrap effect namespaces to flat exports + barrel (#22745) 2026-04-15 23:29:12 -04:00
Kit Langton
581d5208ca feat: unwrap share namespaces to flat exports + barrel (#22744) 2026-04-15 23:28:46 -04:00
Kit Langton
a427a28fa9 feat: unwrap project namespaces to flat exports + barrel (#22743) 2026-04-15 23:28:46 -04:00
opencode-agent[bot]
0beaf04df5 chore: generate 2026-04-16 03:28:30 +00:00
205 changed files with 6414 additions and 6359 deletions

View File

@@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
},
})
if (!response.ok) {

View File

@@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
},
})
if (!response.ok) {

View File

@@ -1,9 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"options": {
"typeAware": true
},
"categories": {
"suspicious": "warn"
},
"rules": {
"typescript/no-base-to-string": "warn",
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
@@ -33,10 +37,15 @@
"no-new": "off",
// Type-aware: catch unhandled promises
"typescript/no-floating-promises": "warn"
"typescript/no-floating-promises": "warn",
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}

View File

@@ -191,7 +191,7 @@ export const Terminal = (props: TerminalProps) => {
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
let _ghostty: Ghostty
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
@@ -372,7 +372,7 @@ export const Terminal = (props: TerminalProps) => {
cleanup()
return
}
ghostty = g
_ghostty = g
term = t
output = terminalWriter((data, done) =>
t.write(data, () => {

View File

@@ -1,5 +1,3 @@
import { dict as en } from "./en"
export const dict = {
"command.category.suggested": "추천",
"command.category.view": "보기",

View File

@@ -432,7 +432,6 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const canReview = createMemo(() => !!sync.project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({

View File

@@ -16,7 +16,10 @@ export function createSdkForServer({
return createOpencodeClient({
...config,
headers: { ...config.headers, ...auth },
headers: {
...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers),
...auth,
},
baseUrl: server.url,
})
}

View File

@@ -37,5 +37,5 @@ export async function GET({ params: { platform, channel } }: APIEvent) {
const headers = new Headers(resp.headers)
if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`)
return new Response(resp.body, { ...resp, headers })
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers })
}

View File

@@ -12,7 +12,6 @@ import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { github } from "~/lib/github"
import { createMemo } from "solid-js"
import { config } from "~/config"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
@@ -30,7 +29,7 @@ function CopyStatus() {
export default function Home() {
const i18n = useI18n()
const language = useLanguage()
const githubData = createAsync(() => github())
const _githubData = createAsync(() => github())
const handleCopyClick = (event: Event) => {
const button = event.currentTarget as HTMLButtonElement
const text = button.textContent

View File

@@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
const setUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = form.get("useBalance")?.toString() === "true"
const useBalance = (form.get("useBalance") as string | null) === "true"
return json(
await withActor(async () => {

View File

@@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error"
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit")?.toString()
const limit = form.get("limit") as string | null
if (!limit) return { error: formError.limitRequired }
const numericLimit = parseInt(limit)
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(

View File

@@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Billing.reload(), workspaceID), {
revalidate: queryBillingInfo.key,
@@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => {
const setReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const reloadValue = form.get("reload")?.toString() === "true"
const amountStr = form.get("reloadAmount")?.toString()
const triggerStr = form.get("reloadTrigger")?.toString()
const reloadValue = (form.get("reload") as string | null) === "true"
const amountStr = form.get("reloadAmount") as string | null
const triggerStr = form.get("reloadTrigger") as string | null
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
@@ -91,8 +91,8 @@ export function ReloadSection() {
const info = billingInfo()!
setStore("show", true)
setStore("reload", true)
setStore("reloadAmount", info.reloadAmount.toString())
setStore("reloadTrigger", info.reloadTrigger.toString())
setStore("reloadAmount", String(info.reloadAmount))
setStore("reloadTrigger", String(info.reloadTrigger))
}
function hide() {
@@ -152,11 +152,11 @@ export function ReloadSection() {
data-component="input"
name="reloadAmount"
type="number"
min={billingInfo()?.reloadAmountMin.toString()}
min={String(billingInfo()?.reloadAmountMin ?? "")}
step="1"
value={store.reloadAmount}
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
placeholder={billingInfo()?.reloadAmount.toString()}
placeholder={String(billingInfo()?.reloadAmount ?? "")}
disabled={!store.reload}
/>
</div>
@@ -166,11 +166,11 @@ export function ReloadSection() {
data-component="input"
name="reloadTrigger"
type="number"
min={billingInfo()?.reloadTriggerMin.toString()}
min={String(billingInfo()?.reloadTriggerMin ?? "")}
step="1"
value={store.reloadTrigger}
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
placeholder={billingInfo()?.reloadTrigger.toString()}
placeholder={String(billingInfo()?.reloadTrigger ?? "")}
disabled={!store.reload}
/>
</div>

View File

@@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
const setLiteUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = form.get("useBalance")?.toString() === "true"
const useBalance = (form.get("useBalance") as string | null) === "true"
return json(
await withActor(async () => {

View File

@@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error"
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
const createKey = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
const name = (form.get("name") as string | null)?.trim()
if (!name) return { error: formError.nameRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(

View File

@@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => {
const inviteMember = action(async (form: FormData) => {
"use server"
const email = form.get("email")?.toString().trim()
const email = (form.get("email") as string | null)?.trim()
if (!email) return { error: formError.emailRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
const role = form.get("role") as (typeof UserRole)[number] | null
if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString()
const limit = form.get("limit") as string | null
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json(
@@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
const removeMember = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
@@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => {
const updateMember = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
const role = form.get("role") as (typeof UserRole)[number] | null
if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString()
const limit = form.get("limit") as string | null
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
@@ -118,7 +118,7 @@ function MemberRow(props: {
}
setStore("editing", true)
setStore("selectedRole", props.member.role)
setStore("limit", props.member.monthlyLimit?.toString() ?? "")
setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "")
}
function hide() {

View File

@@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => {
const updateModel = action(async (form: FormData) => {
"use server"
const model = form.get("model")?.toString()
const model = form.get("model") as string | null
if (!model) return { error: formError.modelRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const enabled = form.get("enabled")?.toString() === "true"
const enabled = (form.get("enabled") as string | null) === "true"
return json(
withActor(async () => {
if (enabled) {
@@ -163,7 +163,7 @@ export function ModelSection() {
<form action={updateModel} method="post">
<input type="hidden" name="model" value={id} />
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="enabled" value={isEnabled().toString()} />
<input type="hidden" name="enabled" value={String(isEnabled())} />
<label data-slot="model-toggle-label">
<input
type="checkbox"

View File

@@ -21,9 +21,9 @@ function maskCredentials(credentials: string) {
const removeProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider")?.toString()
const provider = form.get("provider") as string | null
if (!provider) return { error: formError.providerRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
revalidate: listProviders.key,
@@ -32,11 +32,11 @@ const removeProvider = action(async (form: FormData) => {
const saveProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider")?.toString()
const credentials = form.get("credentials")?.toString()
const provider = form.get("provider") as string | null
const credentials = form.get("credentials") as string | null
if (!provider) return { error: formError.providerRequired }
if (!credentials) return { error: formError.apiKeyRequired }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
@@ -59,10 +59,13 @@ function ProviderRow(props: { provider: Provider }) {
const params = useParams()
const i18n = useI18n()
const providers = createAsync(() => listProviders(params.id!))
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
const saveSubmission = useSubmission(
saveProvider,
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
)
const removeSubmission = useSubmission(
removeProvider,
([fd]) => fd.get("provider")?.toString() === props.provider.key,
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
)
const [store, setStore] = createStore({ editing: false })

View File

@@ -30,10 +30,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
const updateWorkspace = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
const name = (form.get("name") as string | null)?.trim()
if (!name) return { error: formError.workspaceNameRequired }
if (name.length > 255) return { error: formError.nameTooLong }
const workspaceID = form.get("workspaceID")?.toString()
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(

View File

@@ -68,23 +68,6 @@ function findBinary() {
}
}
function prepareBinDirectory(binaryName) {
const binDir = path.join(__dirname, "bin")
const targetPath = path.join(binDir, binaryName)
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true })
}
// Remove existing binary/symlink if it exists
if (fs.existsSync(targetPath)) {
fs.unlinkSync(targetPath)
}
return { binDir, targetPath }
}
async function main() {
try {
if (os.platform() === "win32") {

View File

@@ -2,7 +2,7 @@
import { z } from "zod"
import { Config } from "../src/config"
import { TuiConfig } from "../src/config/tui"
import { TuiConfig } from "../src/config"
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
@@ -33,7 +33,7 @@ function generate(schema: z.ZodType) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
schema.description = [schema.description || "", `default: \`${String(schema.default)}\``]
.filter(Boolean)
.join("\n\n")
.trim()

View File

@@ -442,3 +442,58 @@ Going forward:
- If a file grows large enough that it's hard to navigate, split by concern
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
bundler handles that.
## Circular import rules
Barrel files (`index.ts` with `export * as`) introduce circular import risks.
These cause `ReferenceError: Cannot access 'X' before initialization` at
runtime — not caught by the type checker.
### Rule 1: Sibling files never import through their own barrel
Files in the same directory must import directly from the source file, never
through `"."` or `"@/<own-dir>"`:
```ts
// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
import { Sibling } from "."
// GOOD — direct, no cycle
import * as Sibling from "./sibling"
```
### Rule 2: Cross-directory imports must not form cycles through barrels
If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
```
lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
```
Fix by importing the specific file, breaking the cycle:
```ts
// In config/config.ts — import directly, not through the lsp barrel
import * as LSPServer from "../lsp/server"
```
### Why the type checker doesn't catch this
TypeScript resolves types lazily — it doesn't evaluate module-scope
expressions. The `ReferenceError` only happens at runtime when a module-scope
`const` or function call accesses a value from a circular dependency that
hasn't finished initializing. The SDK build step (`bun run --conditions=browser
./src/index.ts generate`) is the reliable way to catch these because it
evaluates all modules eagerly.
### How to verify
After any namespace conversion, run:
```bash
cd packages/opencode
bun run --conditions=browser ./src/index.ts generate
```
If this completes without `ReferenceError`, the module graph is safe.

View File

@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
import { Database } from "@/storage"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { normalizeServerUrl } from "./url"

View File

@@ -4,7 +4,7 @@ import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate"
import { Truncate } from "../tool"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"

View File

@@ -1,11 +1,11 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "../../storage/db"
import { Database } from "../../storage"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "../../storage/json-migration"
import { JsonMigration } from "../../storage"
import { EOL } from "os"
import { errorMessage } from "../../util/error"

View File

@@ -6,7 +6,7 @@ import { Provider } from "../../../provider"
import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { ToolRegistry } from "../../../tool"
import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"

View File

@@ -1,5 +1,5 @@
import { EOL } from "os"
import { Project } from "../../../project/project"
import { Project } from "../../../project"
import { Log } from "../../../util"
import { cmd } from "../cmd"

View File

@@ -21,7 +21,7 @@ import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { SessionShare } from "@/share"
import { Session } from "../../session"
import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema"

View File

@@ -4,10 +4,10 @@ import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage/db"
import { Database } from "../../storage"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { ShareNext } from "../../share"
import { EOL } from "os"
import { Filesystem } from "../../util"
import { AppRuntime } from "@/effect/app-runtime"

View File

@@ -1,7 +1,7 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import type { Argv } from "yargs"
import { ConfigPaths } from "../../config/paths"
import { ConfigPaths } from "../../config"
import { Global } from "../../global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"

View File

@@ -12,7 +12,7 @@ import { Server } from "../../server/server"
import { Provider } from "../../provider"
import { Agent } from "../../agent/agent"
import { Permission } from "../../permission"
import { Tool } from "../../tool/tool"
import { Tool } from "../../tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ReadTool } from "../../tool/read"

View File

@@ -2,9 +2,9 @@ import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage/db"
import { Database } from "../../storage"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Project } from "../../project"
import { Instance } from "../../project/instance"
import { AppRuntime } from "@/effect/app-runtime"

View File

@@ -1,7 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/config"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"

View File

@@ -2,7 +2,7 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/config"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"

View File

@@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"

View File

@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32"

View File

@@ -1,7 +1,7 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import { Sound } from "@tui/util/sound"
import * as Sound from "@tui/util/sound"
import { logo } from "@/cli/logo"
// Shadow markers (rendered chars in parens):
@@ -520,7 +520,7 @@ export function Logo() {
const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return [...line].map((char, i) => {
return Array.from(line).map((char, i) => {
const h = field(off + i, y, frame)
const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h

View File

@@ -21,9 +21,9 @@ import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import * as Clipboard from "../../util/clipboard"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"

View File

@@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import { Keybind } from "@/util"
import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/config/tui"
import type { TuiConfig } from "@/config"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"

View File

@@ -1,4 +1,4 @@
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/config"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({

View File

@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
import type { TuiConfig } from "@/config/tui"
import type { TuiConfig } from "@/config"
import { createPluginKeybind } from "../context/plugin-keybinds"
import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert"

View File

@@ -14,7 +14,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { Config } from "@/config"
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/config"
import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"

View File

@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard"
import * as Clipboard from "@tui/util/clipboard"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"

View File

@@ -34,7 +34,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util"
import type { Tool } from "@/tool/tool"
import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
import { BashTool } from "@/tool/bash"
@@ -66,10 +66,10 @@ import { SubagentFooter } from "./subagent-footer.tsx"
import { Flag } from "@/flag/flag"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import { Clipboard } from "../../util/clipboard"
import * as Clipboard from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import * as Editor from "../../util/editor"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"

View File

@@ -13,7 +13,7 @@ import { Filesystem } from "@/util"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/config"
import { Instance } from "@/project/instance"
import { writeHeapSnapshot } from "v8"

View File

@@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection"
import * as Selection from "@tui/util/selection"
export function Dialog(
props: ParentProps<{

View File

@@ -22,171 +22,169 @@ function writeOsc52(text: string): void {
process.stdout.write(sequence)
}
export namespace Clipboard {
export interface Content {
data: string
mime: string
}
export interface Content {
data: string
mime: string
}
// Checks clipboard for images first, then falls back to text.
//
// On Windows prompt/ can call this from multiple paste signals because
// terminals surface image paste differently:
// 1. A forwarded Ctrl+V keypress
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
// Terminal <1.25
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
export async function read(): Promise<Content | undefined> {
const os = platform()
// Checks clipboard for images first, then falls back to text.
//
// On Windows prompt/ can call this from multiple paste signals because
// terminals surface image paste differently:
// 1. A forwarded Ctrl+V keypress
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
// Terminal <1.25
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
export async function read(): Promise<Content | undefined> {
const os = platform()
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
// Windows/WSL: probe clipboard for images via PowerShell.
// Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
// Windows/WSL: probe clipboard for images via PowerShell.
// Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
if (os === "win32") {
console.log("clipboard: using powershell")
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
}
}
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
console.log("clipboard: no native support")
return async (text: string) => {
await clipboardy.write(text).catch(() => {})
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
await getCopyMethod()(text)
}
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
console.log("clipboard: no native support")
return async (text: string) => {
await clipboardy.write(text).catch(() => {})
}
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
await getCopyMethod()(text)
}

View File

@@ -6,32 +6,30 @@ import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util"
import { Process } from "@/util"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
await Filesystem.write(filepath, opts.value)
opts.renderer.suspend()
await Filesystem.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
shell: process.platform === "win32",
})
await proc.exited
const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear()
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
shell: process.platform === "win32",
})
await proc.exited
const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
}
opts.renderer.resume()
opts.renderer.requestRender()
}
}

View File

@@ -0,0 +1,5 @@
export * as Editor from "./editor"
export * as Selection from "./selection"
export * as Sound from "./sound"
export * as Terminal from "./terminal"
export * as Clipboard from "./clipboard"

View File

@@ -1,5 +1,5 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
import type { TuiConfig } from "@/config/tui"
import type { TuiConfig } from "@/config"
export class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}

View File

@@ -1,4 +1,4 @@
import { Clipboard } from "./clipboard"
import * as Clipboard from "./clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@@ -10,16 +10,14 @@ type Renderer = {
clearSelection: () => void
}
export namespace Selection {
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
return true
}
renderer.clearSelection()
return true
}

View File

@@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) {
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
}
export namespace Sound {
let item: Player | null | undefined
let kind: Kind | null | undefined
let proc: Process.Child | undefined
let tail: ReturnType<typeof setTimeout> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let seq = 0
let shot = 0
let item: Player | null | undefined
let kind: Kind | null | undefined
let proc: Process.Child | undefined
let tail: ReturnType<typeof setTimeout> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let seq = 0
let shot = 0
function load() {
if (item !== undefined) return item
try {
item = new Player({ volume: 0.35 })
} catch {
item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
function load() {
if (item !== undefined) return item
try {
item = new Player({ volume: 0.35 })
} catch {
item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
}

View File

@@ -1,137 +1,135 @@
import { RGBA } from "@opentui/core"
export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>>
export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
*
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
* OSC 10/11 (foreground/background) typically work in most environments.
*
* Returns an object with background, foreground, and colors array.
* Any query that fails will be null/empty.
*/
export async function colors(): Promise<{
background: RGBA | null
foreground: RGBA | null
colors: RGBA[]
}> {
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
return new Promise((resolve) => {
let background: RGBA | null = null
let foreground: RGBA | null = null
const paletteColors: RGBA[] = []
let timeout: NodeJS.Timeout
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
*
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
* OSC 10/11 (foreground/background) typically work in most environments.
*
* Returns an object with background, foreground, and colors array.
* Any query that fails will be null/empty.
*/
export async function colors(): Promise<{
background: RGBA | null
foreground: RGBA | null
colors: RGBA[]
}> {
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
return new Promise((resolve) => {
let background: RGBA | null = null
let foreground: RGBA | null = null
const paletteColors: RGBA[] = []
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parse(fgMatch[1])
}
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parse(match[2])
if (color) paletteColors[index] = color
}
// Return immediately if we have all 16 palette colors
if (paletteColors.filter((c) => c !== undefined).length === 16) {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parse(fgMatch[1])
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
// Query background (OSC 11)
process.stdout.write("\x1b]11;?\x07")
// Query foreground (OSC 10)
process.stdout.write("\x1b]10;?\x07")
// Query palette colors 0-15 (OSC 4)
for (let i = 0; i < 16; i++) {
process.stdout.write(`\x1b]4;${i};?\x07`)
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parse(match[2])
if (color) paletteColors[index] = color
}
timeout = setTimeout(() => {
// Return immediately if we have all 16 palette colors
if (paletteColors.filter((c) => c !== undefined).length === 16) {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}, 1000)
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
// Query background (OSC 11)
process.stdout.write("\x1b]11;?\x07")
// Query foreground (OSC 10)
process.stdout.write("\x1b]10;?\x07")
// Query palette colors 0-15 (OSC 4)
for (let i = 0; i < 16; i++) {
process.stdout.write(`\x1b]4;${i};?\x07`)
}
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
timeout = setTimeout(() => {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}, 1000)
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}

View File

@@ -1,5 +1,5 @@
import { AccountServiceError, AccountTransportError } from "@/account"
import { ConfigMarkdown } from "@/config/markdown"
import { ConfigMarkdown } from "@/config"
import { errorFormat } from "@/util/error"
import { Config } from "../config"
import { MCP } from "../mcp"

View File

@@ -19,9 +19,9 @@ import {
printParseErrorCode,
} from "jsonc-parser"
import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
import * as LSPServer from "../lsp/server"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import * as ConfigMarkdown from "./markdown"
import { existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
@@ -29,7 +29,7 @@ import { Event } from "../server/event"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import * as ConfigPaths from "./paths"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect"
@@ -1297,7 +1297,7 @@ export const layer: Layer.Layer<
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, input?: InstallInput) {
const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, _input?: InstallInput) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),

View File

@@ -1 +1,4 @@
export * as Config from "./config"
export * as ConfigMarkdown from "./markdown"
export * as ConfigPaths from "./paths"
export * as TuiConfig from "./tui"

View File

@@ -3,97 +3,95 @@ import matter from "gray-matter"
import { z } from "zod"
import { Filesystem } from "../util"
export namespace ConfigMarkdown {
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /!`([^`]+)`/g
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /!`([^`]+)`/g
export function files(template: string) {
return Array.from(template.matchAll(FILE_REGEX))
}
export function shell(template: string) {
return Array.from(template.matchAll(SHELL_REGEX))
}
// other coding agents like claude code allow invalid yaml in their
// frontmatter, we need to fallback to a more permissive parser for those cases
export function fallbackSanitization(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split(/\r?\n/)
const result: string[] = []
for (const line of lines) {
// skip comments and empty lines
if (line.trim().startsWith("#") || line.trim() === "") {
result.push(line)
continue
}
// skip lines that are continuations (indented)
if (line.match(/^\s+/)) {
result.push(line)
continue
}
// match key: value pattern
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!kvMatch) {
result.push(line)
continue
}
const key = kvMatch[1]
const value = kvMatch[2].trim()
// skip if value is empty, already quoted, or uses block scalar
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
result.push(line)
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |-`)
result.push(` ${value}`)
continue
}
result.push(line)
}
const processed = result.join("\n")
return content.replace(frontmatter, () => processed)
}
export async function parse(filePath: string) {
const template = await Filesystem.readText(filePath)
try {
const md = matter(template)
return md
} catch {
try {
return matter(fallbackSanitization(template))
} catch (err) {
throw new FrontmatterError(
{
path: filePath,
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
},
{ cause: err },
)
}
}
}
export const FrontmatterError = NamedError.create(
"ConfigFrontmatterError",
z.object({
path: z.string(),
message: z.string(),
}),
)
export function files(template: string) {
return Array.from(template.matchAll(FILE_REGEX))
}
export function shell(template: string) {
return Array.from(template.matchAll(SHELL_REGEX))
}
// other coding agents like claude code allow invalid yaml in their
// frontmatter, we need to fallback to a more permissive parser for those cases
export function fallbackSanitization(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split(/\r?\n/)
const result: string[] = []
for (const line of lines) {
// skip comments and empty lines
if (line.trim().startsWith("#") || line.trim() === "") {
result.push(line)
continue
}
// skip lines that are continuations (indented)
if (line.match(/^\s+/)) {
result.push(line)
continue
}
// match key: value pattern
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!kvMatch) {
result.push(line)
continue
}
const key = kvMatch[1]
const value = kvMatch[2].trim()
// skip if value is empty, already quoted, or uses block scalar
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
result.push(line)
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |-`)
result.push(` ${value}`)
continue
}
result.push(line)
}
const processed = result.join("\n")
return content.replace(frontmatter, () => processed)
}
export async function parse(filePath: string) {
const template = await Filesystem.readText(filePath)
try {
const md = matter(template)
return md
} catch {
try {
return matter(fallbackSanitization(template))
} catch (err) {
throw new FrontmatterError(
{
path: filePath,
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
},
{ cause: err },
)
}
}
}
export const FrontmatterError = NamedError.create(
"ConfigFrontmatterError",
z.object({
path: z.string(),
message: z.string(),
}),
)

View File

@@ -7,161 +7,159 @@ import { Filesystem } from "@/util"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
export async function directories(directory: string, worktree: string) {
return [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: directory,
stop: worktree,
}),
)
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
]
}
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
export async function projectFiles(name: string, directory: string, worktree: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
export async function directories(directory: string, worktree: string) {
return [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: directory,
stop: worktree,
}),
)
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
]
}
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}

View File

@@ -2,7 +2,7 @@ import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import * as ConfigPaths from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"

View File

@@ -1,5 +1,5 @@
import z from "zod"
import { Config } from "."
import * as Config from "./config"
const KeybindOverride = z
.object(

View File

@@ -2,8 +2,8 @@ import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { Config } from "."
import { ConfigPaths } from "./paths"
import * as Config from "./config"
import * as ConfigPaths from "./paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Flag } from "@/flag/flag"
@@ -14,201 +14,199 @@ import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export const Info = TuiInfo
type Acc = {
result: Info
}
type Acc = {
result: Info
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: Config.PluginOrigin[]
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: Config.PluginOrigin[]
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
if (AppFileSystem.contains(ctx.directory, file)) return "local"
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
if (AppFileSystem.contains(ctx.directory, file)) return "local"
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return {
...tui,
...data,
}
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = Config.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string; worktree: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(acc, file, ctx)
}
}
const keybinds = { ...acc.result.keybinds }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
",",
)
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("TuiConfig.state")(function* (ctx) {
const data = yield* Effect.promise(() => loadState(ctx))
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
concurrency: "unbounded",
})
return { config: data.config, deps }
}),
)
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
),
)
return Service.of({ get, waitForDependencies })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
}
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = Config.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string; worktree: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(acc, file, ctx)
}
}
const keybinds = { ...acc.result.keybinds }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
",",
)
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("TuiConfig.state")(function* (ctx) {
const data = yield* Effect.promise(() => loadState(ctx))
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
concurrency: "unbounded",
})
return { config: data.config, deps }
}),
)
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
),
)
return Service.of({ get, waitForDependencies })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
}

View File

@@ -1,8 +1,8 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, asc, eq, inArray } from "@/storage/db"
import { Project } from "@/project/project"
import { Database, asc, eq, inArray } from "@/storage"
import { Project } from "@/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
@@ -114,7 +114,7 @@ export namespace Workspace {
await adaptor.create(config)
void startSync(info)
startSync(info)
await waitEvent({
timeout: TIMEOUT,
@@ -294,7 +294,7 @@ export namespace Workspace {
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) void startSync(space)
for (const space of spaces) startSync(space)
return spaces
}
@@ -307,7 +307,7 @@ export namespace Workspace {
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
void startSync(space)
startSync(space)
return space
})

View File

@@ -1,6 +1,6 @@
import { Layer, ManagedRuntime } from "effect"
import { attach, memoMap } from "./run-service"
import { Observability } from "./observability"
import * as Observability from "./observability"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Bus } from "@/bus"
@@ -12,7 +12,7 @@ import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Storage } from "@/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider"
@@ -37,16 +37,16 @@ import { LSP } from "@/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool"
import { ToolRegistry } from "@/tool"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Project } from "@/project"
import { Vcs } from "@/project"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share"
import { SessionShare } from "@/share"
export const AppLayer = Layer.mergeAll(
AppFileSystem.defaultLayer,

View File

@@ -5,12 +5,12 @@ import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
import { ShareNext } from "@/share"
import { File } from "@/file"
import { Vcs } from "@/project/vcs"
import { Vcs } from "@/project"
import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Observability } from "./observability"
import * as Observability from "./observability"
export const BootstrapLayer = Layer.mergeAll(
Plugin.defaultLayer,

View File

@@ -1,2 +1,5 @@
export * as InstanceState from "./instance-state"
export * as EffectBridge from "./bridge"
export * as Runner from "./runner"
export * as Observability from "./observability"
export * as EffectLogger from "./logger"

View File

@@ -1,5 +1,5 @@
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import * as EffectLogger from "./logger"
import { Instance, type InstanceContext } from "@/project/instance"
import { LocalContext } from "@/util"
import { InstanceRef, WorkspaceRef } from "./instance-ref"

View File

@@ -1,67 +1,67 @@
import { Cause, Effect, Logger, References } from "effect"
import { Log } from "@/util"
export namespace EffectLogger {
type Fields = Record<string, unknown>
type Fields = Record<string, unknown>
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
// oxlint-disable-next-line no-base-to-string
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})

View File

@@ -1,80 +1,78 @@
import { Effect, Layer, Logger } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import { EffectLogger } from "@/effect/logger"
import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability {
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...value] = x.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...value] = x.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
const resource = {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}
const resource = {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}
const logs = Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource,
const logs = Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource,
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
// @effect/opentelemetry creates a NodeTracerProvider but never calls
// register(), so the global @opentelemetry/api context manager stays
// as the no-op default. Non-Effect code (like the AI SDK) that calls
// tracer.startActiveSpan() relies on context.active() to find the
// parent span — without a real context manager every span starts a
// new trace. Registering AsyncLocalStorageContextManager fixes this.
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const mgr = new AsyncLocalStorageContextManager()
mgr.enable()
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource,
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
// @effect/opentelemetry creates a NodeTracerProvider but never calls
// register(), so the global @opentelemetry/api context manager stays
// as the no-op default. Non-Effect code (like the AI SDK) that calls
// tracer.startActiveSpan() relies on context.active() to find the
// parent span — without a real context manager every span starts a
// new trace. Registering AsyncLocalStorageContextManager fixes this.
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const mgr = new AsyncLocalStorageContextManager()
mgr.enable()
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource,
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
headers,
}),
),
}))
}
export const layer = !base
? EffectLogger.layer
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs)
}),
)
),
}))
}
export const layer = !base
? EffectLogger.layer
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs)
}),
)

View File

@@ -3,7 +3,7 @@ import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./observability"
import * as Observability from "./observability"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance"

View File

@@ -1,208 +1,206 @@
import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
export interface Runner<A, E = never> {
readonly state: Runner.State<A, E>
readonly state: State<A, E>
readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly cancel: Effect.Effect<void>
}
export namespace Runner {
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
interface RunHandle<A, E> {
id: number
done: Deferred.Deferred<A, E | Cancelled>
fiber: Fiber.Fiber<A, E>
interface RunHandle<A, E> {
id: number
done: Deferred.Deferred<A, E | Cancelled>
fiber: Fiber.Fiber<A, E>
}
interface ShellHandle<A, E> {
id: number
fiber: Fiber.Fiber<A, E>
}
interface PendingHandle<A, E> {
id: number
done: Deferred.Deferred<A, E | Cancelled>
work: Effect.Effect<A, E>
}
export type State<A, E> =
| { readonly _tag: "Idle" }
| { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
| { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
| { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
export const make = <A, E = never>(
scope: Scope.Scope,
opts?: {
onIdle?: Effect.Effect<void>
onBusy?: Effect.Effect<void>
onInterrupt?: Effect.Effect<A, E>
busy?: () => never
},
): Runner<A, E> => {
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
const idle = opts?.onIdle ?? Effect.void
const busy = opts?.onBusy ?? Effect.void
const onInterrupt = opts?.onInterrupt
let ids = 0
const state = () => SynchronizedRef.getUnsafe(ref)
const next = () => {
ids += 1
return ids
}
interface ShellHandle<A, E> {
id: number
fiber: Fiber.Fiber<A, E>
}
const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
: Deferred.done(done, exit).pipe(Effect.asVoid)
interface PendingHandle<A, E> {
id: number
done: Deferred.Deferred<A, E | Cancelled>
work: Effect.Effect<A, E>
}
const idleIfCurrent = () =>
SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
export type State<A, E> =
| { readonly _tag: "Idle" }
| { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
| { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
| { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
SynchronizedRef.modify(
ref,
(st) =>
[
Effect.gen(function* () {
if (st._tag === "Running" && st.run.id === id) yield* idle
yield* complete(done, exit)
}),
st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
] as const,
).pipe(Effect.flatten)
export const make = <A, E = never>(
scope: Scope.Scope,
opts?: {
onIdle?: Effect.Effect<void>
onBusy?: Effect.Effect<void>
onInterrupt?: Effect.Effect<A, E>
busy?: () => never
},
): Runner<A, E> => {
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
const idle = opts?.onIdle ?? Effect.void
const busy = opts?.onBusy ?? Effect.void
const onInterrupt = opts?.onInterrupt
let ids = 0
const state = () => SynchronizedRef.getUnsafe(ref)
const next = () => {
ids += 1
return ids
}
const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
: Deferred.done(done, exit).pipe(Effect.asVoid)
const idleIfCurrent = () =>
SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
SynchronizedRef.modify(
ref,
(st) =>
[
Effect.gen(function* () {
if (st._tag === "Running" && st.run.id === id) yield* idle
yield* complete(done, exit)
}),
st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
] as const,
).pipe(Effect.flatten)
const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
Effect.gen(function* () {
const id = next()
const fiber = yield* work.pipe(
Effect.onExit((exit) => finishRun(id, done, exit)),
Effect.forkIn(scope),
)
return { id, done, fiber } satisfies RunHandle<A, E>
})
const finishShell = (id: number) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
if (st._tag === "ShellThenRun" && st.shell.id === id) {
const run = yield* startRun(st.run.work, st.run.done)
return [Effect.void, { _tag: "Running", run }] as const
}
return [Effect.void, st] as const
}),
).pipe(Effect.flatten)
const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
const ensureRunning = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
switch (st._tag) {
case "Running":
case "ShellThenRun":
return [Deferred.await(st.run.done), st] as const
case "Shell": {
const run = {
id: next(),
done: yield* Deferred.make<A, E | Cancelled>(),
work,
} satisfies PendingHandle<A, E>
return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
}
case "Idle": {
const done = yield* Deferred.make<A, E | Cancelled>()
const run = yield* startRun(work, done)
return [Deferred.await(done), { _tag: "Running", run }] as const
}
}
}),
).pipe(
Effect.flatten,
Effect.catch(
(e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
),
const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
Effect.gen(function* () {
const id = next()
const fiber = yield* work.pipe(
Effect.onExit((exit) => finishRun(id, done, exit)),
Effect.forkIn(scope),
)
return { id, done, fiber } satisfies RunHandle<A, E>
})
const startShell = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag !== "Idle") {
return [
Effect.sync(() => {
if (opts?.busy) opts.busy()
throw new Error("Runner is busy")
}),
st,
] as const
const finishShell = (id: number) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
if (st._tag === "ShellThenRun" && st.shell.id === id) {
const run = yield* startRun(st.run.work, st.run.done)
return [Effect.void, { _tag: "Running", run }] as const
}
return [Effect.void, st] as const
}),
).pipe(Effect.flatten)
const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
const ensureRunning = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
switch (st._tag) {
case "Running":
case "ShellThenRun":
return [Deferred.await(st.run.done), st] as const
case "Shell": {
const run = {
id: next(),
done: yield* Deferred.make<A, E | Cancelled>(),
work,
} satisfies PendingHandle<A, E>
return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
}
yield* busy
const id = next()
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
const shell = { id, fiber } satisfies ShellHandle<A, E>
return [
Effect.gen(function* () {
const exit = yield* Fiber.await(fiber)
if (Exit.isSuccess(exit)) return exit.value
if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
return yield* Effect.failCause(exit.cause)
}),
{ _tag: "Shell", shell },
] as const
}),
).pipe(Effect.flatten)
case "Idle": {
const done = yield* Deferred.make<A, E | Cancelled>()
const run = yield* startRun(work, done)
return [Deferred.await(done), { _tag: "Running", run }] as const
}
}
}),
).pipe(
Effect.flatten,
Effect.catch(
(e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
),
)
const cancel = SynchronizedRef.modify(ref, (st) => {
switch (st._tag) {
case "Idle":
return [Effect.void, st] as const
case "Running":
const startShell = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag !== "Idle") {
return [
Effect.gen(function* () {
yield* Fiber.interrupt(st.run.fiber)
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
yield* idleIfCurrent()
Effect.sync(() => {
if (opts?.busy) opts.busy()
throw new Error("Runner is busy")
}),
{ _tag: "Idle" } as const,
st,
] as const
case "Shell":
return [
Effect.gen(function* () {
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
case "ShellThenRun":
return [
Effect.gen(function* () {
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
}
}).pipe(Effect.flatten)
}
yield* busy
const id = next()
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
const shell = { id, fiber } satisfies ShellHandle<A, E>
return [
Effect.gen(function* () {
const exit = yield* Fiber.await(fiber)
if (Exit.isSuccess(exit)) return exit.value
if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
return yield* Effect.failCause(exit.cause)
}),
{ _tag: "Shell", shell },
] as const
}),
).pipe(Effect.flatten)
return {
get state() {
return state()
},
get busy() {
return state()._tag !== "Idle"
},
ensureRunning,
startShell,
cancel,
const cancel = SynchronizedRef.modify(ref, (st) => {
switch (st._tag) {
case "Idle":
return [Effect.void, st] as const
case "Running":
return [
Effect.gen(function* () {
yield* Fiber.interrupt(st.run.fiber)
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
case "Shell":
return [
Effect.gen(function* () {
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
case "ShellThenRun":
return [
Effect.gen(function* () {
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
] as const
}
}).pipe(Effect.flatten)
return {
get state() {
return state()
},
get busy() {
return state()._tag !== "Idle"
},
ensureRunning,
startShell,
cancel,
}
}

View File

@@ -31,8 +31,8 @@ import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
import { JsonMigration } from "./storage"
import { Database } from "./storage"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"

View File

@@ -8,7 +8,7 @@ import { Log } from "../util"
import { Process } from "../util"
import { LANGUAGE_EXTENSIONS } from "./language"
import z from "zod"
import type { LSPServer } from "./server"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/shared/util/error"
import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
@@ -16,237 +16,235 @@ import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
const log = Log.create({ service: "lsp.client" })
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
export type Diagnostic = VSCodeDiagnostic
export type Diagnostic = VSCodeDiagnostic
export const InitializeError = NamedError.create(
"LSPInitializeError",
export const InitializeError = NamedError.create(
"LSPInitializeError",
z.object({
serverID: z.string(),
}),
)
export const Event = {
Diagnostics: BusEvent.define(
"lsp.client.diagnostics",
z.object({
serverID: z.string(),
path: z.string(),
}),
),
}
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any),
new StreamMessageWriter(input.server.process.stdin as any),
)
export const Event = {
Diagnostics: BusEvent.define(
"lsp.client.diagnostics",
z.object({
serverID: z.string(),
path: z.string(),
}),
),
const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
l.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
})
const exists = diagnostics.has(filePath)
diagnostics.set(filePath, params.diagnostics)
if (!exists && input.serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
})
connection.onRequest("window/workDoneProgress/create", (params) => {
l.info("window/workDoneProgress/create", params)
return null
})
connection.onRequest("workspace/configuration", async () => {
// Return server initialization options
return [input.server.initialization ?? {}]
})
connection.onRequest("client/registerCapability", async () => {})
connection.onRequest("client/unregisterCapability", async () => {})
connection.onRequest("workspace/workspaceFolders", async () => [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
])
connection.listen()
l.info("sending initialize")
await withTimeout(
connection.sendRequest("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
],
initializationOptions: {
...input.server.initialization,
},
capabilities: {
window: {
workDoneProgress: true,
},
workspace: {
configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
},
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
publishDiagnostics: {
versionSupport: true,
},
},
},
}),
45_000,
).catch((err) => {
l.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
if (input.server.initialization) {
await connection.sendNotification("workspace/didChangeConfiguration", {
settings: input.server.initialization,
})
}
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
const files: {
[path: string]: number
} = {}
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any),
new StreamMessageWriter(input.server.process.stdin as any),
)
const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
l.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
})
const exists = diagnostics.has(filePath)
diagnostics.set(filePath, params.diagnostics)
if (!exists && input.serverID === "typescript") return
void Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
})
connection.onRequest("window/workDoneProgress/create", (params) => {
l.info("window/workDoneProgress/create", params)
return null
})
connection.onRequest("workspace/configuration", async () => {
// Return server initialization options
return [input.server.initialization ?? {}]
})
connection.onRequest("client/registerCapability", async () => {})
connection.onRequest("client/unregisterCapability", async () => {})
connection.onRequest("workspace/workspaceFolders", async () => [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
])
connection.listen()
l.info("sending initialize")
await withTimeout(
connection.sendRequest("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
],
initializationOptions: {
...input.server.initialization,
},
capabilities: {
window: {
workDoneProgress: true,
},
workspace: {
configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
},
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
publishDiagnostics: {
versionSupport: true,
},
},
},
}),
45_000,
).catch((err) => {
l.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
if (input.server.initialization) {
await connection.sendNotification("workspace/didChangeConfiguration", {
settings: input.server.initialization,
})
}
const files: {
[path: string]: number
} = {}
const result = {
root: input.root,
get serverID() {
return input.serverID
},
get connection() {
return connection
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
const version = files[input.path]
if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 2, // Changed
},
],
})
const next = version + 1
files[input.path] = next
log.info("textDocument/didChange", {
path: input.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: pathToFileURL(input.path).href,
version: next,
},
contentChanges: [{ text }],
})
return
}
const result = {
root: input.root,
get serverID() {
return input.serverID
},
get connection() {
return connection
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
const version = files[input.path]
if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 1, // Created
type: 2, // Changed
},
],
})
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didOpen", {
const next = version + 1
files[input.path] = next
log.info("textDocument/didChange", {
path: input.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: pathToFileURL(input.path).href,
languageId,
version: 0,
text,
version: next,
},
contentChanges: [{ text }],
})
files[input.path] = 0
return
},
}
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 1, // Created
},
],
})
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(input.path).href,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
return
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void
let debounceTimer: ReturnType<typeof setTimeout> | undefined
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
// Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
log.info("got diagnostics", { path: normalizedPath })
unsub?.()
resolve()
}, DIAGNOSTICS_DEBOUNCE_MS)
}
})
}),
3000,
)
.catch(() => {})
.finally(() => {
if (debounceTimer) clearTimeout(debounceTimer)
unsub?.()
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void
let debounceTimer: ReturnType<typeof setTimeout> | undefined
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
// Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
log.info("got diagnostics", { path: normalizedPath })
unsub?.()
resolve()
}, DIAGNOSTICS_DEBOUNCE_MS)
}
})
},
async shutdown() {
l.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
l.info("shutdown")
},
}
l.info("initialized")
return result
}),
3000,
)
.catch(() => {})
.finally(() => {
if (debounceTimer) clearTimeout(debounceTimer)
unsub?.()
})
},
async shutdown() {
l.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
l.info("shutdown")
},
}
l.info("initialized")
return result
}

View File

@@ -1,537 +1,3 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Log } from "../util"
import { LSPClient } from "./client"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { LSPServer } from "./server"
import z from "zod"
import { Config } from "../config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Event = {
Updated: BusEvent.define("lsp.updated", z.object({})),
}
export const Range = z
.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
})
.meta({
ref: "Range",
})
export type Range = z.infer<typeof Range>
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: Range,
}),
})
.meta({
ref: "Symbol",
})
export type Symbol = z.infer<typeof Symbol>
export const DocumentSymbol = z
.object({
name: z.string(),
detail: z.string().optional(),
kind: z.number(),
range: Range,
selectionRange: Range,
})
.meta({
ref: "DocumentSymbol",
})
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
export const Status = z
.object({
id: z.string(),
name: z.string(),
root: z.string(),
status: z.union([z.literal("connected"), z.literal("error")]),
})
.meta({
ref: "LSPStatus",
})
export type Status = z.infer<typeof Status>
enum SymbolKind {
File = 1,
Module = 2,
Namespace = 3,
Package = 4,
Class = 5,
Method = 6,
Property = 7,
Field = 8,
Constructor = 9,
Enum = 10,
Interface = 11,
Function = 12,
Variable = 13,
Constant = 14,
String = 15,
Number = 16,
Boolean = 17,
Array = 18,
Object = 19,
Key = 20,
Null = 21,
EnumMember = 22,
Struct = 23,
Event = 24,
Operator = 25,
TypeParameter = 26,
}
const kinds = [
SymbolKind.Class,
SymbolKind.Function,
SymbolKind.Method,
SymbolKind.Interface,
SymbolKind.Variable,
SymbolKind.Constant,
SymbolKind.Struct,
SymbolKind.Enum,
]
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
if (servers["pyright"]) {
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
delete servers["pyright"]
}
} else {
if (servers["ty"]) {
delete servers["ty"]
}
}
}
type LocInput = { file: string; line: number; character: number }
interface State {
clients: LSPClient.Info[]
servers: Record<string, LSPServer.Info>
broken: Set<string>
spawning: Map<string, Promise<LSPClient.Info | undefined>>
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly hasClients: (file: string) => Effect.Effect<boolean>
readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
readonly hover: (input: LocInput) => Effect.Effect<any>
readonly definition: (input: LocInput) => Effect.Effect<any[]>
readonly references: (input: LocInput) => Effect.Effect<any[]>
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]>
readonly workspaceSymbol: (query: string) => Effect.Effect<LSP.Symbol[]>
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () {
const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {}
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
} else {
for (const server of Object.values(LSPServer)) {
servers[server.id] = server
}
filterExperimentalServers(servers)
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name]
if (item.disabled) {
log.info(`LSP server ${name} is disabled`)
delete servers[name]
continue
}
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: { ...process.env, ...item.env },
}),
initialization: item.initialization,
}),
}
}
log.info("enabled LSP servers", {
serverIds: Object.values(servers)
.map((server) => server.id)
.join(", "),
})
}
const s: State = {
clients: [],
servers,
broken: new Set(),
spawning: new Map(),
}
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await Promise.all(s.clients.map((client) => client.shutdown()))
}),
)
return s
}),
)
const getClients = Effect.fnUntraced(function* (file: string) {
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root)
.then((value) => {
if (!value) s.broken.add(key)
return value
})
.catch((err) => {
s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id, root })
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
if (!client) return undefined
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (existing) {
await Process.stop(handle.process)
return existing
}
s.clients.push(client)
return client
}
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}
const inflight = s.spawning.get(root + server.id)
if (inflight) {
const client = await inflight
if (!client) continue
result.push(client)
continue
}
const task = schedule(server, root, root + server.id)
s.spawning.set(root + server.id, task)
void task.finally(() => {
if (s.spawning.get(root + server.id) === task) {
s.spawning.delete(root + server.id)
}
})
const client = await task
if (!client) continue
result.push(client)
void Bus.publish(Event.Updated, {})
}
return result
})
})
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
const clients = yield* getClients(file)
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
})
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
})
const init = Effect.fn("LSP.init")(function* () {
yield* InstanceState.get(state)
})
const status = Effect.fn("LSP.status")(function* () {
const s = yield* InstanceState.get(state)
const result: Status[] = []
for (const client of s.clients) {
result.push({
id: client.serverID,
name: s.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root),
status: "connected",
})
}
return result
})
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
}
return false
})
})
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
log.info("touching file", { file: input })
const clients = yield* getClients(input)
yield* Effect.promise(() =>
Promise.all(
clients.map(async (client) => {
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
}),
)
})
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
const results: Record<string, LSPClient.Diagnostic[]> = {}
const all = yield* runAll(async (client) => client.diagnostics)
for (const result of all) {
for (const [p, diags] of result.entries()) {
const arr = results[p] || []
arr.push(...diags)
results[p] = arr
}
}
return results
})
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
return yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/hover", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
})
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/definition", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
return results.flat().filter(Boolean)
})
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/references", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
context: { includeDeclaration: true },
})
.catch(() => []),
)
return results.flat().filter(Boolean)
})
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/implementation", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
return results.flat().filter(Boolean)
})
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
const file = fileURLToPath(uri)
const results = yield* run(file, (client) =>
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
)
return (results.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]).filter(Boolean)
})
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
const results = yield* runAll((client) =>
client.connection
.sendRequest("workspace/symbol", { query })
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
)
return results.flat() as LSP.Symbol[]
})
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => []),
)
return results.flat().filter(Boolean)
})
const callHierarchyRequest = Effect.fnUntraced(function* (
input: LocInput,
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
) {
const results = yield* run(input.file, async (client) => {
const items = (await client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => [])) as any[]
if (!items?.length) return []
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
})
return results.flat().filter(Boolean)
})
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
})
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
})
return Service.of({
init,
status,
hasClients,
touchFile,
diagnostics,
hover,
definition,
references,
implementation,
documentSymbol,
workspaceSymbol,
prepareCallHierarchy,
incomingCalls,
outgoingCalls,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export namespace Diagnostic {
const MAX_PER_FILE = 20
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
}
const severity = severityMap[diagnostic.severity || 1]
const line = diagnostic.range.start.line + 1
const col = diagnostic.range.start.character + 1
return `${severity} [${line}:${col}] ${diagnostic.message}`
}
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
}
export * as LSP from "./lsp"
export * as LSPClient from "./client"
export * as LSPServer from "./server"

View File

@@ -0,0 +1,535 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Log } from "../util"
import * as LSPClient from "./client"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import z from "zod"
import { Config } from "../config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
const log = Log.create({ service: "lsp" })
export const Event = {
Updated: BusEvent.define("lsp.updated", z.object({})),
}
export const Range = z
.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
})
.meta({
ref: "Range",
})
export type Range = z.infer<typeof Range>
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: Range,
}),
})
.meta({
ref: "Symbol",
})
export type Symbol = z.infer<typeof Symbol>
export const DocumentSymbol = z
.object({
name: z.string(),
detail: z.string().optional(),
kind: z.number(),
range: Range,
selectionRange: Range,
})
.meta({
ref: "DocumentSymbol",
})
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
export const Status = z
.object({
id: z.string(),
name: z.string(),
root: z.string(),
status: z.union([z.literal("connected"), z.literal("error")]),
})
.meta({
ref: "LSPStatus",
})
export type Status = z.infer<typeof Status>
enum SymbolKind {
File = 1,
Module = 2,
Namespace = 3,
Package = 4,
Class = 5,
Method = 6,
Property = 7,
Field = 8,
Constructor = 9,
Enum = 10,
Interface = 11,
Function = 12,
Variable = 13,
Constant = 14,
String = 15,
Number = 16,
Boolean = 17,
Array = 18,
Object = 19,
Key = 20,
Null = 21,
EnumMember = 22,
Struct = 23,
Event = 24,
Operator = 25,
TypeParameter = 26,
}
const kinds = [
SymbolKind.Class,
SymbolKind.Function,
SymbolKind.Method,
SymbolKind.Interface,
SymbolKind.Variable,
SymbolKind.Constant,
SymbolKind.Struct,
SymbolKind.Enum,
]
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
if (servers["pyright"]) {
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
delete servers["pyright"]
}
} else {
if (servers["ty"]) {
delete servers["ty"]
}
}
}
type LocInput = { file: string; line: number; character: number }
interface State {
clients: LSPClient.Info[]
servers: Record<string, LSPServer.Info>
broken: Set<string>
spawning: Map<string, Promise<LSPClient.Info | undefined>>
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly hasClients: (file: string) => Effect.Effect<boolean>
readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
readonly hover: (input: LocInput) => Effect.Effect<any>
readonly definition: (input: LocInput) => Effect.Effect<any[]>
readonly references: (input: LocInput) => Effect.Effect<any[]>
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>
readonly workspaceSymbol: (query: string) => Effect.Effect<Symbol[]>
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () {
const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {}
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
} else {
for (const server of Object.values(LSPServer)) {
servers[server.id] = server
}
filterExperimentalServers(servers)
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name]
if (item.disabled) {
log.info(`LSP server ${name} is disabled`)
delete servers[name]
continue
}
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: { ...process.env, ...item.env },
}),
initialization: item.initialization,
}),
}
}
log.info("enabled LSP servers", {
serverIds: Object.values(servers)
.map((server) => server.id)
.join(", "),
})
}
const s: State = {
clients: [],
servers,
broken: new Set(),
spawning: new Map(),
}
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await Promise.all(s.clients.map((client) => client.shutdown()))
}),
)
return s
}),
)
const getClients = Effect.fnUntraced(function* (file: string) {
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root)
.then((value) => {
if (!value) s.broken.add(key)
return value
})
.catch((err) => {
s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id, root })
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
if (!client) return undefined
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (existing) {
await Process.stop(handle.process)
return existing
}
s.clients.push(client)
return client
}
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}
const inflight = s.spawning.get(root + server.id)
if (inflight) {
const client = await inflight
if (!client) continue
result.push(client)
continue
}
const task = schedule(server, root, root + server.id)
s.spawning.set(root + server.id, task)
task.finally(() => {
if (s.spawning.get(root + server.id) === task) {
s.spawning.delete(root + server.id)
}
})
const client = await task
if (!client) continue
result.push(client)
Bus.publish(Event.Updated, {})
}
return result
})
})
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
const clients = yield* getClients(file)
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
})
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
})
const init = Effect.fn("LSP.init")(function* () {
yield* InstanceState.get(state)
})
const status = Effect.fn("LSP.status")(function* () {
const s = yield* InstanceState.get(state)
const result: Status[] = []
for (const client of s.clients) {
result.push({
id: client.serverID,
name: s.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root),
status: "connected",
})
}
return result
})
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
}
return false
})
})
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
log.info("touching file", { file: input })
const clients = yield* getClients(input)
yield* Effect.promise(() =>
Promise.all(
clients.map(async (client) => {
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
}),
)
})
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
const results: Record<string, LSPClient.Diagnostic[]> = {}
const all = yield* runAll(async (client) => client.diagnostics)
for (const result of all) {
for (const [p, diags] of result.entries()) {
const arr = results[p] || []
arr.push(...diags)
results[p] = arr
}
}
return results
})
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
return yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/hover", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
})
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/definition", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
return results.flat().filter(Boolean)
})
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/references", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
context: { includeDeclaration: true },
})
.catch(() => []),
)
return results.flat().filter(Boolean)
})
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/implementation", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => null),
)
return results.flat().filter(Boolean)
})
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
const file = fileURLToPath(uri)
const results = yield* run(file, (client) =>
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
)
return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
})
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
const results = yield* runAll((client) =>
client.connection
.sendRequest("workspace/symbol", { query })
.then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
)
return results.flat() as Symbol[]
})
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => []),
)
return results.flat().filter(Boolean)
})
const callHierarchyRequest = Effect.fnUntraced(function* (
input: LocInput,
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
) {
const results = yield* run(input.file, async (client) => {
const items = (await client.connection
.sendRequest("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
})
.catch(() => [])) as any[]
if (!items?.length) return []
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
})
return results.flat().filter(Boolean)
})
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
})
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
})
return Service.of({
init,
status,
hasClients,
touchFile,
diagnostics,
hover,
definition,
references,
implementation,
documentSymbol,
workspaceSymbol,
prepareCallHierarchy,
incomingCalls,
outgoingCalls,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export namespace Diagnostic {
const MAX_PER_FILE = 20
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
}
const severity = severityMap[diagnostic.severity || 1]
const line = diagnostic.range.start.line + 1
const col = diagnostic.range.start.character + 1
return `${severity} [${line}:${col}] ${diagnostic.message}`
}
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,5 @@ export { Config } from "./config"
export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util"
export { Database } from "./storage/db"
export { JsonMigration } from "./storage/json-migration"
export { Database } from "./storage"
export { JsonMigration } from "./storage"

View File

@@ -5,7 +5,7 @@ import { InstanceState } from "@/effect"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { Database, eq } from "@/storage"
import { zod } from "@/util/effect-zod"
import { Log } from "@/util"
import { withStatics } from "@/util/schema"

View File

@@ -93,7 +93,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const info = await getAuth()
if (info.type !== "oauth") return fetch(request, init)
const url = request instanceof URL ? request.href : request.toString()
const url = request instanceof URL ? request.href : typeof request === "string" ? request : request.url
const { isVision, isAgent } = iife(() => {
try {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body

View File

@@ -7,7 +7,7 @@ import {
printParseErrorCode,
} from "jsonc-parser"
import { ConfigPaths } from "@/config/paths"
import { ConfigPaths } from "@/config"
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"

View File

@@ -3,14 +3,14 @@ import { Format } from "../format"
import { LSP } from "../lsp"
import { File } from "../file"
import { Snapshot } from "../snapshot"
import { Project } from "./project"
import { Vcs } from "./vcs"
import * as Project from "./project"
import * as Vcs from "./vcs"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
import { ShareNext } from "@/share"
import * as Effect from "effect/Effect"
export const InstanceBootstrap = Effect.gen(function* () {

View File

@@ -0,0 +1,2 @@
export * as Vcs from "./vcs"
export * as Project from "./project"

View File

@@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { iife } from "@/util/iife"
import { Log } from "@/util"
import { LocalContext } from "../util"
import { Project } from "./project"
import * as Project from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
export interface InstanceContext {

View File

@@ -1,5 +1,5 @@
import z from "zod"
import { and, Database, eq } from "../storage/db"
import { and, Database, eq } from "../storage"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { Log } from "../util"
@@ -14,474 +14,469 @@ import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
export namespace Project {
const log = Log.create({ service: "project" })
const log = Log.create({ service: "project" })
export const Info = z
.object({
id: ProjectID.zod,
worktree: z.string(),
vcs: z.literal("git").optional(),
name: z.string().optional(),
icon: z
.object({
url: z.string().optional(),
override: z.string().optional(),
color: z.string().optional(),
})
.optional(),
commands: z
.object({
start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
})
.optional(),
time: z.object({
created: z.number(),
updated: z.number(),
initialized: z.number().optional(),
}),
sandboxes: z.array(z.string()),
})
.meta({
ref: "Project",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: BusEvent.define("project.updated", Info),
}
type Row = typeof ProjectTable.$inferSelect
export function fromRow(row: Row): Info {
const icon =
row.icon_url || row.icon_color
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined
return {
id: row.id,
worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined,
icon,
time: {
created: row.time_created,
updated: row.time_updated,
initialized: row.time_initialized ?? undefined,
},
sandboxes: row.sandboxes,
commands: row.commands ?? undefined,
}
}
export const UpdateInput = z.object({
projectID: ProjectID.zod,
export const Info = z
.object({
id: ProjectID.zod,
worktree: z.string(),
vcs: z.literal("git").optional(),
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
icon: z
.object({
url: z.string().optional(),
override: z.string().optional(),
color: z.string().optional(),
})
.optional(),
commands: z
.object({
start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
})
.optional(),
time: z.object({
created: z.number(),
updated: z.number(),
initialized: z.number().optional(),
}),
sandboxes: z.array(z.string()),
})
export type UpdateInput = z.infer<typeof UpdateInput>
.meta({
ref: "Project",
})
export type Info = z.infer<typeof Info>
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export const Event = {
Updated: BusEvent.define("project.updated", Info),
}
export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
type Row = typeof ProjectTable.$inferSelect
export function fromRow(row: Row): Info {
const icon =
row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined
return {
id: row.id,
worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined,
icon,
time: {
created: row.time_created,
updated: row.time_updated,
initialized: row.time_initialized ?? undefined,
},
sandboxes: row.sandboxes,
commands: row.commands ?? undefined,
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
export const UpdateInput = z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
type GitResult = { code: number; text: string; stderr: string }
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
}
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
const emitUpdated = (data: Info) =>
Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
project: data.id,
payload: { type: Event.Updated.type, properties: data },
}),
)
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
const emitUpdated = (data: Info) =>
Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
project: data.id,
payload: { type: Event.Updated.type, properties: data },
}),
)
const resolveGitPath = (cwd: string, name: string) => {
if (!name) return cwd
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = AppFileSystem.windowsPath(name)
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
const scope = yield* Scope.Scope
const resolveGitPath = (cwd: string, name: string) => {
if (!name) return cwd
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = AppFileSystem.windowsPath(name)
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.void),
)
})
const scope = yield* Scope.Scope
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.void),
)
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: fakeVcs,
}
}
let sandbox = pathSvc.dirname(dotgit)
const gitBinary = yield* Effect.sync(() => which("git"))
let id = yield* readCachedProjectId(dotgit)
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const worktree = (() => {
const common = resolveGitPath(sandbox, commonDir.text.trim())
return common === sandbox ? sandbox : pathSvc.dirname(common)
})()
if (id == null) {
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: fakeVcs,
}
// Phase 2: upsert
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
let sandbox = pathSvc.dirname(dotgit)
const gitBinary = yield* Effect.sync(() => which("git"))
let id = yield* readCachedProjectId(dotgit)
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const result: Info = {
...existing,
worktree: data.worktree,
vcs: data.vcs,
time: { ...existing.time, updated: Date.now() },
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
fs.exists(s).pipe(
Effect.orDie,
Effect.map((exists) => (exists ? s : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const worktree = (() => {
const common = resolveGitPath(sandbox, commonDir.text.trim())
return common === sandbox ? sandbox : pathSvc.dirname(common)
})()
if (id == null) {
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
})
// Phase 2: upsert
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
const result: Info = {
...existing,
worktree: data.worktree,
vcs: data.vcs,
time: { ...existing.time, updated: Date.now() },
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
fs.exists(s).pipe(
Effect.orDie,
Effect.map((exists) => (exists ? s : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d
.insert(ProjectTable)
.values({
id: result.id,
yield* db((d) =>
d
.insert(ProjectTable)
.values({
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
},
})
},
})
.run(),
)
if (data.id !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
}
if (data.id !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
}
yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
})
yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
})
const discover = Effect.fn("Project.discover")(function* (input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const discover = Effect.fn("Project.discover")(function* (input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const matches = yield* fs
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
.pipe(Effect.orDie)
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const matches = yield* fs
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
const base64 = Buffer.from(buffer).toString("base64")
const mime = AppFileSystem.mimeType(shortest)
const url = `data:${mime};base64,${base64}`
yield* update({ projectID: input.id, icon: { url } })
})
const list = Effect.fn("Project.list")(function* () {
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
})
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
return row ? fromRow(row) : undefined
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.pipe(Effect.orDie)
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
yield* emitUpdated(data)
return data
})
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
const base64 = Buffer.from(buffer).toString("base64")
const mime = AppFileSystem.mimeType(shortest)
const url = `data:${mime};base64,${base64}`
yield* update({ projectID: input.id, icon: { url } })
})
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
if (input.project.vcs === "git") return input.project
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
if (result.code !== 0) {
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
}
const { project } = yield* fromDirectory(input.directory)
return project
})
const list = Effect.fn("Project.list")(function* () {
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
})
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
return row ? fromRow(row) : undefined
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
data.sandboxes,
(dir) =>
fs.isDir(dir).pipe(
Effect.orDie,
Effect.map((ok) => (ok ? dir : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
yield* emitUpdated(data)
return data
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
if (input.project.vcs === "git") return input.project
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
if (result.code !== 0) {
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
}
const { project } = yield* fromDirectory(input.directory)
return project
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
})
return Service.of({
fromDirectory,
discover,
list,
get,
update,
initGit,
setInitialized,
sandboxes,
addSandbox,
removeSandbox,
})
}),
)
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
data.sandboxes,
(dir) =>
fs.isDir(dir).pipe(
Effect.orDie,
Effect.map((ok) => (ok ? dir : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
return Service.of({
fromDirectory,
discover,
list,
get,
update,
initGit,
setInitialized,
sandboxes,
addSandbox,
removeSandbox,
})
}),
export function list() {
return Database.use((db) =>
db
.select()
.from(ProjectTable)
.all()
.map((row) => fromRow(row)),
)
}
export function get(id: ProjectID): Info | undefined {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return undefined
return fromRow(row)
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
export function list() {
return Database.use((db) =>
db
.select()
.from(ProjectTable)
.all()
.map((row) => fromRow(row)),
)
}
export function get(id: ProjectID): Info | undefined {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return undefined
return fromRow(row)
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
}
}

View File

@@ -11,223 +11,221 @@ import { Log } from "@/util"
import { Instance } from "./instance"
import z from "zod"
export namespace Vcs {
const log = Log.create({ service: "vcs" })
const log = Log.create({ service: "vcs" })
const count = (text: string) => {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
if (Buffer.from(buf).includes(0)) return ""
return Buffer.from(buf).toString("utf8")
})
const nums = (list: Git.Stat[]) =>
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
const merge = (...lists: Git.Item[][]) => {
const out = new Map<string, Git.Item>()
lists.flat().forEach((item) => {
if (!out.has(item.file)) out.set(item.file, item)
})
return [...out.values()]
}
const files = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
) {
const base = ref ? yield* git.prefix(cwd) : ""
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
const next = yield* Effect.forEach(
list,
(item) =>
Effect.gen(function* () {
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
const stat = map.get(item.file)
return {
file: item.file,
patch: patch(item.file, before, after),
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies FileDiff
}),
{ concurrency: 8 },
)
return next.toSorted((a, b) => a.file.localeCompare(b.file))
})
const track = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
) {
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
return yield* files(fs, git, cwd, ref, list, nums(stats))
})
const compare = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string,
) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
return yield* files(
fs,
git,
cwd,
ref,
merge(
list,
extra.filter((item) => item.code === "??"),
),
nums(stats),
)
})
export const Mode = z.enum(["git", "branch"])
export type Mode = z.infer<typeof Mode>
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
z.object({
branch: z.string().optional(),
}),
),
}
export const Info = z
.object({
branch: z.string().optional(),
default_branch: z.string().optional(),
})
.meta({
ref: "VcsInfo",
})
export type Info = z.infer<typeof Info>
export const FileDiff = z
.object({
file: z.string(),
patch: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "VcsFileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
}
interface State {
current: string | undefined
root: Git.Base | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")(function* (ctx) {
if (ctx.project.vcs !== "git") {
return { current: undefined, root: undefined }
}
const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory)
})
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) =>
Effect.gen(function* () {
const next = yield* get()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
Effect.forkScoped,
)
return value
}),
)
return Service.of({
init: Effect.fn("Vcs.init")(function* () {
yield* InstanceState.get(state)
}),
branch: Effect.fn("Vcs.branch")(function* () {
return yield* InstanceState.use(state, (x) => x.current)
}),
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
return yield* InstanceState.use(state, (x) => x.root?.name)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
if (Instance.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(
fs,
git,
Instance.directory,
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
if (!ref) return []
return yield* compare(fs, git, Instance.directory, ref)
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
)
const count = (text: string) => {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
if (Buffer.from(buf).includes(0)) return ""
return Buffer.from(buf).toString("utf8")
})
const nums = (list: Git.Stat[]) =>
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
const merge = (...lists: Git.Item[][]) => {
const out = new Map<string, Git.Item>()
lists.flat().forEach((item) => {
if (!out.has(item.file)) out.set(item.file, item)
})
return [...out.values()]
}
const files = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
) {
const base = ref ? yield* git.prefix(cwd) : ""
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
const next = yield* Effect.forEach(
list,
(item) =>
Effect.gen(function* () {
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
const stat = map.get(item.file)
return {
file: item.file,
patch: patch(item.file, before, after),
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies FileDiff
}),
{ concurrency: 8 },
)
return next.toSorted((a, b) => a.file.localeCompare(b.file))
})
const track = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
) {
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
return yield* files(fs, git, cwd, ref, list, nums(stats))
})
const compare = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string,
) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
return yield* files(
fs,
git,
cwd,
ref,
merge(
list,
extra.filter((item) => item.code === "??"),
),
nums(stats),
)
})
export const Mode = z.enum(["git", "branch"])
export type Mode = z.infer<typeof Mode>
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
z.object({
branch: z.string().optional(),
}),
),
}
export const Info = z
.object({
branch: z.string().optional(),
default_branch: z.string().optional(),
})
.meta({
ref: "VcsInfo",
})
export type Info = z.infer<typeof Info>
export const FileDiff = z
.object({
file: z.string(),
patch: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "VcsFileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
}
interface State {
current: string | undefined
root: Git.Base | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")(function* (ctx) {
if (ctx.project.vcs !== "git") {
return { current: undefined, root: undefined }
}
const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory)
})
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) =>
Effect.gen(function* () {
const next = yield* get()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
Effect.forkScoped,
)
return value
}),
)
return Service.of({
init: Effect.fn("Vcs.init")(function* () {
yield* InstanceState.get(state)
}),
branch: Effect.fn("Vcs.branch")(function* () {
return yield* InstanceState.use(state, (x) => x.current)
}),
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
return yield* InstanceState.use(state, (x) => x.root?.name)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
if (Instance.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(
fs,
git,
Instance.directory,
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
if (!ref) return []
return yield* compare(fs, git, Instance.directory, ref)
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
)

View File

@@ -2,7 +2,7 @@ import type { ModelMessage } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "."
import type * as Provider from "./provider"
import type { ModelsDev } from "./models"
import { iife } from "@/util/iife"
import { Flag } from "@/flag/flag"
@@ -274,7 +274,7 @@ export namespace ProviderTransform {
// Check for empty base64 image data
if (part.type === "image") {
const imageStr = part.image.toString()
const imageStr = String(part.image)
if (imageStr.startsWith("data:")) {
const match = imageStr.match(/^data:([^;]+);base64,(.*)$/)
if (match && (!match[2] || match[2].length === 0)) {
@@ -286,7 +286,7 @@ export namespace ProviderTransform {
}
}
const mime = part.type === "image" ? part.image.toString().split(";")[0].replace("data:", "") : part.mediaType
const mime = part.type === "image" ? String(part.image).split(";")[0].replace("data:", "") : part.mediaType
const filename = part.type === "file" ? part.filename : undefined
const modality = mimeToModality(mime)
if (!modality) return part

View File

@@ -346,7 +346,7 @@ export const layer = Layer.effect(
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
},
onClose: () => {
log.info("client disconnected from session", { id })

View File

@@ -1,6 +1,6 @@
import { resolver } from "hono-openapi"
import z from "zod"
import { NotFoundError } from "../storage/db"
import { NotFoundError } from "../storage"
export const ERRORS = {
400: {

View File

@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"
import { Database, inArray } from "@/storage/db"
import { Database, inArray } from "@/storage"
import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"

View File

@@ -2,10 +2,10 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { ProviderID, ModelID } from "../../provider/schema"
import { ToolRegistry } from "../../tool/registry"
import { ToolRegistry } from "../../tool"
import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { Project } from "../../project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { Config } from "../../config"

View File

@@ -3,7 +3,7 @@ import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unsta
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Observability } from "@/effect/observability"
import { Observability } from "@/effect"
import { memoMap } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"

View File

@@ -6,7 +6,7 @@ import z from "zod"
import { Format } from "../../format"
import { TuiRoutes } from "./tui"
import { Instance } from "../../project/instance"
import { Vcs } from "../../project/vcs"
import { Vcs } from "../../project"
import { Agent } from "../../agent/agent"
import { Skill } from "../../skill"
import { Global } from "../../global"

View File

@@ -2,7 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { Project } from "../../project"
import z from "zod"
import { ProjectID } from "../../project/schema"
import { errors } from "../error"

View File

@@ -6,7 +6,7 @@ import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
import { NotFoundError } from "../../storage"
import { errors } from "../error"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {

View File

@@ -9,7 +9,7 @@ import { SessionPrompt } from "../../session/prompt"
import { SessionRunState } from "@/session/run-state"
import { SessionCompaction } from "../../session/compaction"
import { SessionRevert } from "../../session/revert"
import { SessionShare } from "@/share/session"
import { SessionShare } from "@/share"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"

View File

@@ -2,7 +2,7 @@ import z from "zod"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { SyncEvent } from "@/sync"
import { Database, asc, and, not, or, lte, eq } from "@/storage/db"
import { Database, asc, and, not, or, lte, eq } from "@/storage"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import { Log } from "@/util"

View File

@@ -1,6 +1,6 @@
import { Provider } from "../provider"
import { NamedError } from "@opencode-ai/shared/util/error"
import { NotFoundError } from "../storage/db"
import { NotFoundError } from "../storage"
import { Session } from "../session"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import type { ErrorHandler, MiddlewareHandler } from "hono"

View File

@@ -3,7 +3,7 @@ import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session"
import { SessionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { Database, eq } from "@/storage"
export function initProjectors() {
SyncEvent.init({

View File

@@ -37,9 +37,9 @@ export const UIRoutes = (): Hono =>
}
} else {
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
raw: c.req.raw,
headers: {
...c.req.raw.headers,
...Object.fromEntries(c.req.raw.headers.entries()),
host: "app.opencode.ai",
},
})

View File

@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Session } from "."
import * as Session from "./session"
import { SessionID, MessageID, PartID } from "./schema"
import { Provider } from "../provider"
import { MessageV2 } from "./message-v2"
@@ -11,7 +11,7 @@ import { SessionProcessor } from "./processor"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config"
import { NotFoundError } from "@/storage/db"
import { NotFoundError } from "@/storage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"

View File

@@ -277,11 +277,10 @@ export namespace LLM {
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
if (evt.properties.requestID === id) void evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {

View File

@@ -6,7 +6,7 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "../sync"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
@@ -15,7 +15,7 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect } from "effect"
import { EffectLogger } from "@/effect/logger"
import { EffectLogger } from "@/effect"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {

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