Compare commits

...

7 Commits

145 changed files with 2567 additions and 1712 deletions

View File

@@ -4,7 +4,13 @@
// 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
"no-unassigned-vars": "off"
"no-unassigned-vars": "off",
// SolidJS tracks reactive deps by reading properties inside createEffect
"no-unused-expressions": "off",
// Intentional control char matching (ANSI escapes, null byte sanitization)
"no-control-regex": "off",
// SST and plugin tools require triple-slash references
"triple-slash-reference": "off"
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
}

View File

@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
})
connected = true
break
} catch (e) {}
} catch {}
await sleep(300)
} while (retry++ < 30)
@@ -561,7 +561,7 @@ async function subscribeSessionEvents() {
if (evt.properties.info.id !== session.id) continue
session = evt.properties.info
}
} catch (e) {
} catch {
// Ignore parse errors
}
}
@@ -576,7 +576,7 @@ async function subscribeSessionEvents() {
async function summarize(response: string) {
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
} catch {
if (isScheduleEvent()) {
return "Scheduled task changes"
}

View File

@@ -1,5 +1,5 @@
import { SECRET } from "./secret"
import { domain, shortDomain } from "./stage"
import { shortDomain } from "./stage"
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")

View File

@@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows)
this._allRowSeparators = new Array<string>(rows)
this._allRows = Array.from<string>({ length: rows })
this._allRowSeparators = Array.from<string>({ length: rows })
this._rowIndex = 0
this._currentRow = ""

View File

@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/shared/util/path"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"

View File

@@ -63,6 +63,7 @@ export function createRefreshQueue(input: QueueInput) {
}
} finally {
running = false
// oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused
if (input.paused()) return
if (root || queued.size) schedule()
}

View File

@@ -704,7 +704,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
const active = new Set(visibleSessionDirs())
for (const directory of [...prefetchedByDir.keys()]) {
for (const directory of prefetchedByDir.keys()) {
if (active.has(directory)) continue
prefetchedByDir.delete(directory)
}

View File

@@ -1,4 +1,4 @@
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"

View File

@@ -1,5 +1,4 @@
import { action, useSubmission } from "@solidjs/router"
import dock from "../asset/lander/dock.png"
import { Resource } from "@opencode-ai/console-resource"
import { Show } from "solid-js"
import { useI18n } from "~/context/i18n"

View File

@@ -47,7 +47,7 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 0,
}).format(githubData()?.stars!)
}).format(githubData()?.stars)
: config.github.starsFormatted.compact,
)

View File

@@ -0,0 +1 @@
export {}

View File

@@ -1,7 +1,7 @@
import { Title } from "@solidjs/meta"
import { createAsync, query, useParams } from "@solidjs/router"
import { createSignal, For, Show } from "solid-js"
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
import { useI18n } from "~/context/i18n"

View File

@@ -1,5 +1,5 @@
import "./index.css"
import { createAsync, query, redirect } from "@solidjs/router"
import { createAsync, query } from "@solidjs/router"
import { Title, Meta } from "@solidjs/meta"
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
//import { HttpHeader } from "@solidjs/start"

View File

@@ -1,5 +1,5 @@
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
import { For, Show, createEffect } from "solid-js"
import { For, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js"

View File

@@ -1,5 +1,5 @@
import "./index.css"
import { createAsync, query, redirect } from "@solidjs/router"
import { createAsync, query } from "@solidjs/router"
import { Title, Meta } from "@solidjs/meta"
//import { HttpHeader } from "@solidjs/start"
import zenLogoLight from "../../asset/zen-ornate-light.svg"

View File

@@ -345,7 +345,7 @@ export async function handler(
logger.metric({
"error.cause2": JSON.stringify(error.cause),
})
} catch (e) {}
} catch {}
}
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.

View File

@@ -153,7 +153,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
let json
try {
json = JSON.parse(data.slice(6))
} catch (e) {
} catch {
return
}

View File

@@ -48,7 +48,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
let json
try {
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
} catch (e) {
} catch {
return
}

View File

@@ -49,7 +49,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
let json
try {
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
} catch (e) {
} catch {
return
}
@@ -289,7 +289,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
index: 0,
message: {
role: "assistant" as const,
...(content.length > 0 && content.some((c) => c.type === "text")
...(content.some((c) => c.type === "text")
? {
content: content
.filter((c) => c.type === "text")
@@ -297,7 +297,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
.join(""),
}
: {}),
...(content.length > 0 && content.some((c) => c.type === "tool_use")
...(content.some((c) => c.type === "tool_use")
? {
tool_calls: content
.filter((c) => c.type === "tool_use")

View File

@@ -36,7 +36,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
let json
try {
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
} catch (e) {
} catch {
return
}

View File

@@ -1,7 +1,5 @@
import { subscribe } from "diagnostics_channel"
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Database, eq } from "../src/drizzle/index.js"
import { BillingTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]

View File

@@ -1,12 +1,10 @@
import { Billing } from "../src/billing.js"
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { BillingTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { BlackData } from "../src/black.js"
import { Actor } from "../src/actor.js"
const plan = "200"
const couponID = "JAIr0Pe1"

View File

@@ -1,7 +1,5 @@
import { subscribe } from "diagnostics_channel"
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Database, eq } from "../src/drizzle/index.js"
import { BillingTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]

View File

@@ -1,4 +1,4 @@
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
import { Database, eq, and, sql, inArray, isNull } from "../src/drizzle/index.js"
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { AuthTable } from "../src/schema/auth.sql.js"

View File

@@ -0,0 +1 @@
export {}

View File

@@ -48,7 +48,7 @@ export namespace Log {
function use() {
try {
return ctx.use()
} catch (e) {
} catch {
return { tags: {} }
}
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, afterAll } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Share } from "../../src/core/share"
import { Storage } from "../../src/core/storage"
import { Identifier } from "@opencode-ai/shared/util/identifier"

View File

@@ -107,7 +107,7 @@ if (!Script.preview) {
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
} catch (e) {
} catch {
continue
}
}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { z } from "zod"
import { Config } from "../src/config/config"
import { Config } from "../src/config"
import { TuiConfig } from "../src/config/tui"
function generate(schema: z.ZodType) {

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bun
/**
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
*
* Usage:
* bun script/unwrap-namespace.ts src/bus/index.ts
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
*
* What it does:
* 1. Reads the file and finds the `export namespace Foo { ... }` block
* (uses ast-grep for accurate AST-based boundary detection)
* 2. Removes the namespace wrapper and dedents the body
* 3. If the file is index.ts, renames it to <lowercase-name>.ts
* 4. Creates/updates index.ts with `export * as Foo from "./<file>"`
* 5. Prints the import rewrite commands to run across the codebase
*
* Does NOT auto-rewrite imports — prints the commands so you can review them.
*
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
*/
import path from "path"
import fs from "fs"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const filePath = args.find((a) => !a.startsWith("--"))
if (!filePath) {
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run]")
process.exit(1)
}
const absPath = path.resolve(filePath)
if (!fs.existsSync(absPath)) {
console.error(`File not found: ${absPath}`)
process.exit(1)
}
const src = fs.readFileSync(absPath, "utf-8")
const lines = src.split("\n")
// Use ast-grep to find the namespace boundaries accurately.
// This avoids false matches from braces in strings, templates, comments, etc.
const astResult = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" },
)
if (astResult.exitCode !== 0) {
console.error("ast-grep failed:", astResult.stderr.toString())
process.exit(1)
}
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
text: string
range: { start: { line: number; column: number }; end: { line: number; column: number } }
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
}>
if (matches.length === 0) {
console.error("No `export namespace Foo { ... }` found in file")
process.exit(1)
}
if (matches.length > 1) {
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
console.error("Namespaces found:")
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
process.exit(1)
}
const match = matches[0]
const nsName = match.metaVariables.single.NAME.text
const nsLine = match.range.start.line // 0-indexed
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
console.log(`Found: export namespace ${nsName} { ... }`)
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
// Build the new file content:
// 1. Everything before the namespace declaration (imports, etc.)
// 2. The namespace body, dedented by one level (2 spaces)
// 3. Everything after the closing brace (rare, but possible)
const before = lines.slice(0, nsLine)
const body = lines.slice(nsLine + 1, closeLine)
const after = lines.slice(closeLine + 1)
// Dedent: remove exactly 2 leading spaces from each line
const dedented = body.map((line) => {
if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2)
return line // don't touch lines that aren't indented (shouldn't happen)
})
const newContent = [...before, ...dedented, ...after].join("\n")
// Figure out file naming
const dir = path.dirname(absPath)
const basename = path.basename(absPath, ".ts")
const isIndex = basename === "index"
// The implementation file name (lowercase namespace name if currently index.ts)
const implName = isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename
const implFile = path.join(dir, `${implName}.ts`)
const indexFile = path.join(dir, "index.ts")
// The barrel line
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
console.log("")
if (isIndex) {
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
} else {
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
}
console.log("")
if (dryRun) {
console.log("--- DRY RUN ---")
console.log("")
console.log(`=== ${implName}.ts (first 30 lines) ===`)
newContent
.split("\n")
.slice(0, 30)
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
console.log(" ...")
console.log("")
console.log(`=== index.ts ===`)
console.log(` ${barrelLine.trim()}`)
} else {
// Write the implementation file
if (isIndex) {
// Rename: write new content to implFile, then overwrite index.ts with barrel
fs.writeFileSync(implFile, newContent)
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
console.log(`Wrote index.ts (barrel)`)
} else {
// Rewrite in place, create index.ts
fs.writeFileSync(absPath, newContent)
if (fs.existsSync(indexFile)) {
// Append to existing barrel
const existing = fs.readFileSync(indexFile, "utf-8")
if (!existing.includes(`export * as ${nsName}`)) {
fs.appendFileSync(indexFile, barrelLine)
console.log(`Appended to existing index.ts`)
} else {
console.log(`index.ts already has ${nsName} export`)
}
} else {
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote index.ts (barrel)`)
}
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
}
}
// Print the import rewrite guidance
const relDir = path.relative(path.resolve("src"), dir)
console.log("")
console.log("=== Import rewrites ===")
console.log("")
if (!isIndex) {
// Non-index files: imports like "../provider/provider" need to become "../provider"
const oldTail = `${relDir}/${basename}`
console.log(`# Find all imports to rewrite:`)
console.log(`rg 'from.*${oldTail}' src/ --files-with-matches`)
console.log("")
// Auto-rewrite with sed (safe: only rewrites the import path, not other occurrences)
console.log("# Auto-rewrite (review diff afterward):")
console.log(`rg -l 'from.*${oldTail}' src/ | xargs sed -i '' 's|${oldTail}"|${relDir}"|g'`)
console.log("")
console.log("# What changes:")
console.log(`# import { ${nsName} } from ".../${oldTail}"`)
console.log(`# import { ${nsName} } from ".../${relDir}"`)
} else {
console.log("# File was index.ts — import paths already resolve correctly.")
console.log("# No import rewrites needed!")
}
console.log("")
console.log("=== Verify ===")
console.log("")
console.log("bun typecheck # from packages/opencode")
console.log("bun run test # run tests")

View File

@@ -0,0 +1,444 @@
# Namespace → flat export migration
Migrate `export namespace` to the `export * as` / flat-export pattern used by
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
conventions, LLM-friendliness for future migrations.
## What changes and what doesn't
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
What changes is **how** the namespace is constructed — the TypeScript
`export namespace` keyword is replaced by `export * as` in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import `{ Provider }` don't notice.
Import paths actually get **nicer**. Today most consumers import from the
explicit file (`"../provider/provider"`). After the migration, each module has a
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
```ts
// BEFORE — points at the file directly
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
```
## Why this matters right now
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript `export namespace` bodies.
### The problem in one sentence
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
`google-auth-library`, and every other top-level import in that 1709-line file.
### Why `export namespace` defeats tree-shaking
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
```js
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
```
This is **opaque to static analysis**. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every `import` statement at the top of
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
### What `export * as` does differently
`export * as Provider from "./provider"` compiles to a static re-export. The
bundler knows the exact shape of `Provider` at compile time — it's the named
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
### Concrete impact
The worst import chain in the codebase:
```
src/index.ts (entry point)
└── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines)
│ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers
│ ├── google-auth-library
│ ├── gitlab-ai-provider, venice-ai-sdk-provider
│ └── fuzzysort, remeda, etc.
├── { Config } from config/config.ts (1663 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
```
All of this gets pulled in to check `.isInstance()` on 6 error classes — code
that needs maybe 200 bytes total. This inflates the binary, increases startup
memory, and slows down initial module evaluation.
### Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
Google's auth library allocates objects, closures, and prototype chains that
persist for the lifetime of the process. Most CLI commands never use a provider
at all.
## What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
### 1. Each module is a separate file with flat named exports
```ts
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
```
### 2. Barrel file uses `export * as` (not `export namespace`)
```ts
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
bundler knows the **exact shape** at compile time — it's the static export list
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
nothing can be dropped.
### 3. `sideEffects: []` and deep imports
```jsonc
// package.json
{ "sideEffects": [] }
```
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
### 4. Errors as flat exports, not class declarations
```ts
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
```
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
## The plan
The core migration is **Phase 1** — convert `export namespace` to
`export * as`. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
lower-risk warmup step that can be done before or after the main conversion, and
it provides extra resilience against bundler edge cases. But the big win comes
from Phase 1.
### Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
2. In the namespace file, re-export from the errors file to maintain backward
compatibility:
```ts
// provider/provider.ts — inside the namespace
export { ModelNotFoundError, InitError } from "./errors"
```
3. Update `cli/error.ts` (and any other light consumers) to import directly:
```ts
// BEFORE
import { Provider } from "../provider/provider"
Provider.ModelNotFoundError.isInstance(input)
// AFTER
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
ProviderModelNotFoundError.isInstance(input)
```
**Files to split (Phase 0):**
| Current file | New errors file | Errors to extract |
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
### Phase 1: The real migration — `export namespace` → `export * as`
This is the phase that actually fixes tree-shaking. For each module:
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
keep all the members as top-level `export const` / `export function` / etc.
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` →
`bus/bus.ts`), so the barrel can take `index.ts`.
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
The file structure change for a module that's currently a single file:
```
# BEFORE
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
```
And the code change is purely removing the wrapper:
```ts
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter:
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
`Provider.layer` works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
**Once this is done, you don't need to break anything into subfiles for
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
depends on `NamedError` + `zod` + the schema file, and drops
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
`export * as` gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
**Order of conversion** (by risk / size, do small modules first):
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
### Phase 2: Build configuration
After the module structure supports tree-shaking:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
`"sideEffects": false`) — this is safe because our services use explicit
layer composition, not import-time side effects.
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
insufficient, evaluate whether the compiled binary path needs an esbuild
pre-pass.
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
— these are factory functions that return classes, and bundlers may not know
they're side-effect-free without the annotation.
## Automation
The transformation is scripted. From `packages/opencode`:
```bash
bun script/unwrap-namespace.ts <file> [--dry-run]
```
The script uses ast-grep for accurate AST-based namespace boundary detection
(no false matches from braces in strings/templates/comments), then:
1. Removes the `export namespace Foo {` line and its closing `}`
2. Dedents the body by one indent level (2 spaces)
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new
`index.ts` barrel
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts`
5. Prints the exact commands to find and rewrite import paths
### Walkthrough: converting a module
Using `Provider` as an example:
```bash
# 1. Preview what will change
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
# 2. Apply the transformation
bun script/unwrap-namespace.ts src/provider/provider.ts
# 3. Rewrite import paths (script prints the exact command)
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
# 4. Verify
bun typecheck
bun run test
```
**What changes on disk:**
```
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
```
**What changes in consumer code:**
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
```
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.)
stays identical.
### Two cases the script handles
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`)
- Rewrites the file in place (unwrap + dedent)
- Creates `provider/index.ts` as the barrel
- Import paths change: `"../provider/provider"` → `"../provider"`
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
- Renames `index.ts` → `bus.ts` (kebab-case of namespace name)
- Creates new `index.ts` as the barrel
- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts`
## Do I need to split errors/schemas into subfiles?
**No.** Once you do the `export * as` conversion, the bundler can tree-shake
individual exports within the file. If `cli/error.ts` only accesses
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
doesn't reference `createAnthropic` and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
organization** — smaller files are easier to read and review. But it's not
required for tree-shaking. The `export * as` conversion alone is sufficient.
The one case where subfile splitting provides extra tree-shake value is if an
imported package has module-level side effects that the bundler can't prove are
unused. In practice this is rare — most npm packages are side-effect-free — and
adding `"sideEffects": []` to package.json handles the common cases.
## Scope
| Metric | Count |
| ----------------------------------------------- | --------------- |
| Files with `export namespace` | 106 |
| Total namespace declarations | 118 (12 nested) |
| Files with `NamedError.create` inside namespace | 15 |
| Total error classes to extract | ~30 |
| Files using `export * as` today | 0 |
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
## Rules for new code
Going forward:
- **No new `export namespace`**. Use a file with flat named exports and
`export * as` in the barrel.
- Keep the service, layer, errors, schemas, and runtime wiring together in one
file if you want — that's fine now. The `export * as` barrel makes everything
individually shakeable regardless of file structure.
- 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.

View File

@@ -43,7 +43,7 @@ import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"

View File

@@ -1,4 +1,4 @@
import { Config } from "../config/config"
import { Config } from "../config"
import z from "zod"
import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"

View File

@@ -0,0 +1,192 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { Log } from "../util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}

View File

@@ -1,12 +1,12 @@
import { EventEmitter } from "events"
export type GlobalEvent = {
directory?: string
project?: string
workspace?: string
payload: any
}
export const GlobalBus = new EventEmitter<{
event: [
{
directory?: string
project?: string
workspace?: string
payload: any
},
]
event: [GlobalEvent]
}>()

View File

@@ -1,194 +1 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { Log } from "../util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Bus {
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}
}
export * as Bus from "./bus"

View File

@@ -1,5 +1,5 @@
import { EOL } from "os"
import { Config } from "../../../config/config"
import { Config } from "../../../config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"

View File

@@ -1,10 +1,9 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file"
import { Ripgrep } from "@/file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Ripgrep } from "@/file/ripgrep"
const FileSearchCommand = cmd({
command: "search <query>",
@@ -17,11 +16,7 @@ const FileSearchCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
}),
)
const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query })))
process.stdout.write(results.join(EOL) + EOL)
})
},
@@ -38,11 +33,7 @@ const FileReadCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(args.path))
}),
)
const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path)))
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
})
},
@@ -54,11 +45,7 @@ const FileStatusCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const status = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status()))
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
})
},
@@ -75,11 +62,7 @@ const FileListCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(args.path))
}),
)
const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path)))
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
})
},
@@ -95,8 +78,10 @@ const FileTreeCommand = cmd({
default: process.cwd(),
}),
async handler(args) {
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
console.log(JSON.stringify(files, null, 2))
await bootstrap(process.cwd(), async () => {
const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2))
})
},
})

View File

@@ -5,7 +5,6 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",

View File

@@ -1,4 +1,5 @@
import { EOL } from "os"
import { Effect, Stream } from "effect"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance"
@@ -21,7 +22,10 @@ const TreeCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
const tree = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
)
process.stdout.write(tree + EOL)
})
},
})
@@ -45,14 +49,21 @@ const FilesCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files: string[] = []
for await (const file of await Ripgrep.files({
cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined,
})) {
files.push(file)
if (args.limit && files.length >= args.limit) break
}
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg
.files({
cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined,
})
.pipe(
Stream.take(args.limit ?? Infinity),
Stream.runCollect,
Effect.map((c) => [...c]),
)
}),
)
process.stdout.write(files.join(EOL) + EOL)
})
},

View File

@@ -297,7 +297,7 @@ export const ExportCommand = cmd({
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
} catch (error) {
} catch {
UI.error(`Session not found: ${sessionID!}`)
process.exit(1)
}

View File

@@ -362,7 +362,7 @@ export const GithubInstallCommand = cmd({
retries++
await sleep(1000)
} while (true)
} while (true) // oxlint-disable-line no-constant-condition
s.stop("Installed GitHub app")
@@ -931,7 +931,7 @@ export const GithubRunCommand = cmd({
async function summarize(response: string) {
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
} catch {
const title = issueEvent
? issueEvent.issue.title
: (payload as PullRequestReviewCommentEvent).pull_request.title

View File

@@ -7,7 +7,7 @@ import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
import path from "path"

View File

@@ -7,7 +7,7 @@ import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"

View File

@@ -2,9 +2,6 @@ import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project/project"
import { Installation } from "../../installation"
export const ServeCommand = cmd({
command: "serve",

View File

@@ -23,7 +23,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { ProjectProvider } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
@@ -115,6 +115,7 @@ export function tui(input: {
events?: EventSource
}) {
// promise to prevent immediate exit
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()

View File

@@ -1,7 +1,7 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup, onMount } from "solid-js"
import { onCleanup } from "solid-js"
export function DialogThemeList() {
const theme = useTheme()

View File

@@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"

View File

@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID } from "@/session/schema"
import z from "zod"

View File

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

View File

@@ -863,7 +863,7 @@ export function Session() {
)
await Clipboard.copy(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch (error) {
} catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
}
dialog.clear()
@@ -925,7 +925,7 @@ export function Session() {
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
}
} catch (error) {
} catch {
toast.show({ message: "Failed to export session", variant: "error" })
}
dialog.clear()
@@ -1010,7 +1010,7 @@ export function Session() {
),
}
})
} catch (error) {
} catch {
return []
}
})

View File

@@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"

View File

@@ -5,7 +5,7 @@ import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { GlobalBus } from "@/bus/global"
import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"

View File

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

View File

@@ -1,5 +1,5 @@
import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "../config/config"
import { Config } from "../config"
import { AppRuntime } from "@/effect/app-runtime"
const options = {

View File

@@ -1,5 +1,5 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"

View File

@@ -5,7 +5,7 @@ import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
import { Config } from "../config/config"
import { Config } from "../config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import { Log } from "../util/log"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export * as Config from "./config"

View File

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

View File

@@ -2,7 +2,7 @@ import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { Config } from "./config"
import { Config } from "."
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"

View File

@@ -0,0 +1,37 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) {
if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted"))
return new Promise<void>((resolve, reject) => {
const abort = () => {
cleanup()
reject(input.signal?.reason ?? new Error("Request aborted"))
}
const handler = (event: GlobalEvent) => {
try {
if (!input.fn(event)) return
cleanup()
resolve()
} catch (error) {
cleanup()
reject(error)
}
}
const cleanup = () => {
clearTimeout(timeout)
GlobalBus.off("event", handler)
input.signal?.removeEventListener("abort", abort)
}
const timeout = setTimeout(() => {
cleanup()
reject(new Error("Timed out waiting for global event"))
}, input.timeout)
GlobalBus.on("event", handler)
input.signal?.addEventListener("abort", abort, { once: true })
})
}

View File

@@ -19,7 +19,7 @@ export const WorkspaceContext = {
get workspaceID() {
try {
return context.use().workspaceID
} catch (err) {
} catch {
return undefined
}
},

View File

@@ -1,7 +1,7 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, asc, eq } from "@/storage/db"
import { Database, asc, eq, inArray } from "@/storage/db"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
@@ -22,6 +22,8 @@ import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { EventSequenceTable } from "@/sync/event.sql"
import { waitEvent } from "./util"
export namespace Workspace {
export const Info = WorkspaceInfo.meta({
@@ -114,6 +116,17 @@ export namespace Workspace {
startSync(info)
await waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
})
return info
})
@@ -285,10 +298,15 @@ export namespace Workspace {
return spaces
}
export const get = fn(WorkspaceID.zod, async (id) => {
function lookup(id: WorkspaceID) {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
const space = fromRow(row)
return fromRow(row)
}
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
startSync(space)
return space
})
@@ -320,12 +338,18 @@ export namespace Workspace {
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
if (status === "error") {
aborts.delete(id)
}
GlobalBus.emit("event", {
directory: "global",
workspace: id,
@@ -340,6 +364,52 @@ export namespace Workspace {
return [...connections.values()]
}
function synced(state: Record<string, number>) {
const ids = Object.keys(state)
if (ids.length === 0) return true
const done = Object.fromEntries(
Database.use((db) =>
db
.select({
id: EventSequenceTable.aggregate_id,
seq: EventSequenceTable.seq,
})
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, ids))
.all(),
).map((row) => [row.id, row.seq]),
) as Record<string, number>
return ids.every((id) => {
return (done[id] ?? -1) >= state[id]
})
}
export async function isSyncing(workspaceID: WorkspaceID) {
return aborts.has(workspaceID)
}
export async function waitForSync(workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) {
if (synced(state)) return
try {
await waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
})
} catch (error) {
if (signal?.aborted) throw signal.reason ?? new Error("Request aborted")
throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`)
}
}
const log = Log.create({ service: "workspace-sync" })
function route(url: string | URL, path: string) {
@@ -353,6 +423,7 @@ export namespace Workspace {
async function syncWorkspace(space: Info, signal: AbortSignal) {
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
@@ -364,7 +435,7 @@ export namespace Workspace {
headers: target.headers,
signal,
}).catch((err: unknown) => {
setStatus(space.id, "error")
setStatus(space.id, "error", err instanceof Error ? err.message : String(err))
log.info("failed to connect to global sync", {
workspace: space.name,
@@ -374,8 +445,9 @@ export namespace Workspace {
})
if (!res || !res.ok || !res.body) {
log.info("failed to connect to global sync", { workspace: space.name })
setStatus(space.id, "error")
const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}`
log.info("failed to connect to global sync", { workspace: space.name, error })
setStatus(space.id, "error", error)
await sleep(1000)
continue
}
@@ -414,22 +486,29 @@ export namespace Workspace {
}
}
function startSync(space: Info) {
async function startSync(space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
if (space.type === "worktree") {
void Filesystem.exists(space.directory!).then((exists) => {
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
if (aborts.has(space.id)) return
const abort = new AbortController()
aborts.set(space.id, abort)
if (aborts.has(space.id)) return true
setStatus(space.id, "disconnected")
const abort = new AbortController()
aborts.set(space.id, abort)
void syncWorkspace(space, abort.signal).catch((error) => {
aborts.delete(space.id)
setStatus(space.id, "error", String(error))
log.warn("workspace listener failed", {
workspaceID: space.id,

View File

@@ -6,7 +6,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
@@ -47,31 +47,13 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import * as Effect from "effect/Effect"
// Adjusts the default Config layer to ensure that plugins are always initialised before
// any other layers read the current config
const ConfigWithPluginPriority = Layer.effect(
Config.Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
return {
...config,
get: () => Effect.andThen(plugin.init(), config.get),
getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal),
getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState),
}
}),
).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer)))
export const AppLayer = Layer.mergeAll(
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
ConfigWithPluginPriority,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,

View File

@@ -1,4 +1,3 @@
import { sep } from "node:path"
import { Glob } from "@opencode-ai/shared/util/glob"
export namespace FileIgnore {

View File

@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Git } from "@/git"
import { Effect, Layer, Context } from "effect"
@@ -653,26 +653,4 @@ export namespace File {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
}

View File

@@ -4,7 +4,7 @@ import { fileURLToPath } from "url"
import z from "zod"
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { ripgrep } from "ripgrep"
import { makeRuntime } from "@/effect/run-service"
import { Filesystem } from "@/util/filesystem"
import { Log } from "@/util/log"
@@ -572,18 +572,4 @@ export namespace Ripgrep {
)
export const defaultLayer = layer
const { runPromise } = makeRuntime(Service, defaultLayer)
export function files(input: FilesInput) {
return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input)))
}
export function tree(input: TreeInput) {
return runPromise((svc) => svc.tree(input))
}
export function search(input: SearchInput) {
return runPromise((svc) => svc.search(input))
}
}

View File

@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, Scope, Context } from "effect"
import { Cause, Effect, Layer, Context } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
@@ -12,7 +12,7 @@ import { Flag } from "@/flag/flag"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { Config } from "../config/config"
import { Config } from "../config"
import { FileIgnore } from "./ignore"
import { Protected } from "./protected"
import { Log } from "../util/log"

View File

@@ -74,7 +74,6 @@ export namespace Flag {
Config.withDefault(false),
)
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
@@ -84,6 +83,9 @@ export namespace Flag {
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
export const OPENCODE_WORKSPACE_ID = process.env["OPENCODE_WORKSPACE_ID"]
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
function number(key: string) {
const value = process.env[key]
if (!value) return undefined

View File

@@ -5,8 +5,7 @@ import { InstanceState } from "@/effect/instance-state"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Config } from "../config"
import { Log } from "../util/log"
import * as Formatter from "./formatter"

View File

@@ -53,6 +53,6 @@ if (version !== CACHE_VERSION) {
}),
),
)
} catch (e) {}
} catch {}
await Filesystem.write(path.join(Global.Path.cache, "version"), CACHE_VERSION)
}

View File

@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Log } from "../util/log"

View File

@@ -6,7 +6,7 @@ import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { LSPServer } from "./server"
import z from "zod"
import { Config } from "../config/config"
import { Config } from "../config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util/process"

View File

@@ -826,7 +826,7 @@ export namespace LSPServer {
if (cargoTomlContent.includes("[workspace]")) {
return currentDir
}
} catch (err) {
} catch {
// File doesn't exist or can't be read, continue searching up
}

View File

@@ -9,7 +9,7 @@ import {
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Config } from "../config"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/shared/util/error"
import z from "zod/v4"

View File

@@ -1,4 +1,4 @@
export { Config } from "./config/config"
export { Config } from "./config"
export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util/log"

View File

@@ -630,7 +630,7 @@ export namespace Patch {
type: "delete",
content,
})
} catch (error) {
} catch {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(`Failed to read file for deletion: ${deletePath}`),

View File

@@ -1,9 +1,8 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { InstanceState } from "@/effect/instance-state"
import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"

View File

@@ -1,10 +1,8 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Log } from "../util/log"
import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import { OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"

View File

@@ -5,7 +5,7 @@ import type {
PluginModule,
WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Config } from "../config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"

View File

@@ -1,4 +1,4 @@
import { Config } from "@/config/config"
import { Config } from "@/config"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,

View File

@@ -15,6 +15,7 @@ import * as Effect from "effect/Effect"
export const InstanceBootstrap = Effect.gen(function* () {
Log.Default.info("bootstrapping", { directory: Instance.directory })
yield* Plugin.Service.use((svc) => svc.init())
yield* Effect.all(
[
LSP.Service,

View File

@@ -1,7 +1,7 @@
import z from "zod"
import os from "os"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
import { Config } from "../config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"

View File

@@ -793,6 +793,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
fetch: this.config.fetch,
})
// oxlint-disable-next-line no-this-alias -- needed for closure scope inside generator
const self = this
let finishReason: {

View File

@@ -0,0 +1,81 @@
import type { MiddlewareHandler } from "hono"
import { Database, inArray } from "@/storage/db"
import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import { Log } from "@/util/log"
const HEADER = "x-opencode-sync"
type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
}
export function diff(prev: State, next: State) {
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
return Object.fromEntries(
[...ids]
.map((id) => [id, next[id] ?? -1] as const)
.filter(([id, seq]) => {
return (prev[id] ?? -1) !== seq
}),
) as State
}
export function parse(headers: Headers) {
const raw = headers.get(HEADER)
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch (err) {
return
}
if (!data || typeof data !== "object") return
return Object.fromEntries(
Object.entries(data).filter(([id, seq]) => {
return typeof id === "string" && Number.isInteger(seq)
}),
) as State
}
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
log.info("waiting for state", {
workspaceID,
state,
})
await Workspace.waitForSync(workspaceID, state, signal)
log.info("state fully synced", {
workspaceID,
state,
})
}
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
const prev = load()
await next()
const current = diff(prev, load())
if (Object.keys(current).length > 0) {
log.info("header", {
diff: current,
})
c.res.headers.set(HEADER, JSON.stringify(current))
}
}

View File

@@ -1,13 +1,12 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { Provider } from "../../provider/provider"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
import { jsonRequest } from "./trace"
export const ConfigRoutes = lazy(() =>

View File

@@ -4,7 +4,6 @@ import { describeRoute, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Log } from "@/util/log"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { Bus } from "@/bus"
import { AsyncQueue } from "../../util/queue"

View File

@@ -8,7 +8,7 @@ import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { ConsoleState } from "../../config/console-state"
import { Account, AccountID, OrgID } from "../../account"
import { AppRuntime } from "../../effect/app-runtime"

View File

@@ -12,7 +12,7 @@ import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { errors } from "../error"
const log = Log.create({ service: "server" })

View File

@@ -2,7 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error"
import { lazy } from "../../util/lazy"

View File

@@ -6,6 +6,7 @@ import { Workspace } from "@/control-plane/workspace"
import { ServerProxy } from "../proxy"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Flag } from "@/flag/flag"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -68,10 +69,10 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const sessionWorkspaceID = await getSessionWorkspace(url)
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
if (!workspaceID || url.pathname.startsWith("/console") || OPENCODE_WORKSPACE) {
if (OPENCODE_WORKSPACE) {
if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) {
if (Flag.OPENCODE_WORKSPACE_ID) {
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(OPENCODE_WORKSPACE),
workspaceID: WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID),
async fn() {
return Instance.provide({
directory,
@@ -148,6 +149,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
headers.delete("x-opencode-workspace")
const req = new Request(c.req.raw, { headers })
return ServerProxy.http(proxyURL, target.headers, req)
return ServerProxy.http(proxyURL, target.headers, req, workspace.id)
}
}

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "../../config/config"
import { Config } from "../../config"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { ProviderAuth } from "../../provider/auth"

View File

@@ -1,4 +1,4 @@
import { Hono, type MiddlewareHandler } from "hono"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"

View File

@@ -1,6 +1,9 @@
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Log } from "@/util/log"
import * as Fence from "./fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
const hop = new Set([
"connection",
@@ -101,12 +104,27 @@ const app = (upgrade: UpgradeWebSocket) =>
export namespace ServerProxy {
const log = Log.Default.clone().tag("service", "server-proxy")
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request) {
export async function http(
url: string | URL,
extra: HeadersInit | undefined,
req: Request,
workspaceID: WorkspaceID,
) {
console.log("proxy http request", {
method: req.method,
request: req.url,
url: String(url),
})
if (!Workspace.isSyncing(workspaceID)) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
return fetch(
new Request(url, {
method: req.method,
@@ -116,21 +134,26 @@ export namespace ServerProxy {
signal: req.signal,
}),
).then((res) => {
const sync = Fence.parse(res.headers)
const next = new Headers(res.headers)
next.delete("content-encoding")
next.delete("content-length")
console.log("proxy http response", {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
return done.then(async () => {
console.log("proxy http response", {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
})
})
})
}

View File

@@ -4,9 +4,11 @@ import { adapter } from "#hono"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { FenceMiddleware } from "./fence"
import { InstanceRoutes } from "./instance"
import { initProjectors } from "./projectors"
import { Log } from "@/util/log"
import { Flag } from "@/flag/flag"
import { ControlPlaneRoutes } from "./control"
import { UIRoutes } from "./ui"
@@ -30,6 +32,22 @@ export namespace Server {
function create(opts: { cors?: string[] }) {
const app = new Hono()
const runtime = adapter.create(app)
if (Flag.OPENCODE_WORKSPACE_ID) {
return {
app: app
.onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.use(FenceMiddleware)
.route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
runtime,
}
}
return {
app: app
.onError(ErrorMiddleware)

View File

@@ -2,7 +2,6 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Session } from "."
import { SessionID, MessageID, PartID } from "./schema"
import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import z from "zod"
@@ -11,7 +10,7 @@ import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"

View File

@@ -2,7 +2,7 @@ import os from "os"
import path from "path"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"

View File

@@ -6,7 +6,7 @@ import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, json
import { mergeDeep, pipe } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { Instance } from "@/project/instance"
import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"

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