Merge branch 'dev' into pr-18335

Bring the PR up to date with the latest server, workspace, and UI changes from dev while keeping the PR's GitLab dependency and PTY merge resolutions.
This commit is contained in:
Dax Raad
2026-03-26 18:40:41 -04:00
472 changed files with 22789 additions and 11572 deletions
@@ -0,0 +1,28 @@
import z from "zod"
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"
export function initProjectors() {
SyncEvent.init({
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: id,
info: Session.fromRow(row),
}
}
return data
},
})
}
initProjectors()
+8 -8
View File
@@ -6,7 +6,6 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { lazy } from "../../util/lazy"
import { AsyncQueue } from "../../util/queue"
import { Instance } from "@/project/instance"
const log = Log.create({ service: "server" })
@@ -30,6 +29,7 @@ export const EventRoutes = lazy(() =>
}),
async (c) => {
log.info("event connected")
c.header("Cache-Control", "no-cache, no-transform")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
@@ -53,13 +53,6 @@ export const EventRoutes = lazy(() =>
)
}, 10_000)
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
const stop = () => {
if (done) return
done = true
@@ -69,6 +62,13 @@ export const EventRoutes = lazy(() =>
log.info("event disconnected")
}
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
stream.onAbort(stop)
try {
@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.create.schema),
validator("json", Worktree.CreateInput.optional()),
async (c) => {
const body = c.req.valid("json")
const worktree = await Worktree.create(body)
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.remove.schema),
validator("json", Worktree.RemoveInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.remove(body)
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.reset.schema),
validator("json", Worktree.ResetInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.reset(body)
+158 -45
View File
@@ -1,8 +1,9 @@
import { Hono } from "hono"
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
@@ -16,6 +17,56 @@ const log = Log.create({ service: "server" })
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
unsub()
q.push(null)
log.info("global event disconnected")
}
const unsub = subscribe(q)
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
})
}
export const GlobalRoutes = lazy(() =>
new Hono()
.get(
@@ -67,57 +118,62 @@ export const GlobalRoutes = lazy(() =>
}),
async (c) => {
log.info("global event connected")
c.header("Cache-Control", "no-cache, no-transform")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
return streamEvents(c, (q) => {
async function handler(event: any) {
q.push(JSON.stringify(event))
}
GlobalBus.on("event", handler)
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
GlobalBus.off("event", handler)
q.push(null)
log.info("event disconnected")
}
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
return () => GlobalBus.off("event", handler)
})
},
)
.get(
"/sync-event",
describeRoute({
summary: "Subscribe to global sync events",
description: "Get global sync events",
operationId: "global.sync-event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(
z
.object({
payload: SyncEvent.payloads(),
})
.meta({
ref: "SyncEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global sync event connected")
c.header("Cache-Control", "no-cache, no-transform")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamEvents(c, (q) => {
return SyncEvent.subscribeAll(({ def, event }) => {
// TODO: don't pass def, just pass the type (and it should
// be versioned)
q.push(
JSON.stringify({
payload: {
...event,
type: SyncEvent.versionedType(def.type, def.version),
},
}),
)
})
})
},
)
@@ -195,5 +251,62 @@ export const GlobalRoutes = lazy(() =>
})
return c.json(true)
},
)
.post(
"/upgrade",
describeRoute({
summary: "Upgrade opencode",
description: "Upgrade opencode to the specified version or latest if not specified.",
operationId: "global.upgrade",
responses: {
200: {
description: "Upgrade result",
content: {
"application/json": {
schema: resolver(
z.union([
z.object({
success: z.literal(true),
version: z.string(),
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]),
),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
target: z.string().optional(),
}),
),
async (c) => {
const method = await Installation.method()
if (method === "unknown") {
return c.json({ success: false, error: "Unknown installation method" }, 400)
}
const target = c.req.valid("json").target || (await Installation.latest(method))
const result = await Installation.upgrade(method, target)
.then(() => ({ success: true as const, version: target }))
.catch((e) => ({ success: false as const, error: e instanceof Error ? e.message : String(e) }))
if (result.success) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return c.json(result)
}
return c.json(result, 500)
},
),
)
@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { PermissionNext } from "@/permission"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod,
}),
),
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.reply({
await Permission.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(PermissionNext.Request.array()),
schema: resolver(Permission.Request.array()),
},
},
},
},
}),
async (c) => {
const permissions = await PermissionNext.list()
const permissions = await Permission.list()
return c.json(permissions)
},
),
@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.update.schema.omit({ projectID: true })),
validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
@@ -9,6 +9,9 @@ import { ProviderID } from "../../provider/schema"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Log } from "../../util/log"
const log = Log.create({ service: "server" })
export const ProviderRoutes = lazy(() =>
new Hono()
+17 -7
View File
@@ -27,7 +27,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
async (c) => {
return c.json(Pty.list())
return c.json(await Pty.list())
},
)
.post(
@@ -74,7 +74,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
const info = await Pty.get(c.req.valid("param").ptyID)
if (!info) {
throw new NotFoundError({ message: "Session not found" })
}
@@ -149,7 +149,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket((c) => {
upgradeWebSocket(async (c) => {
const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => {
const value = c.req.query("cursor")
@@ -158,8 +158,8 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
let handler: Awaited<ReturnType<typeof Pty.connect>>
if (!(await Pty.get(id))) throw new Error("Session not found")
type Socket = {
readyState: number
@@ -175,17 +175,27 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return typeof (value as { readyState?: unknown }).readyState === "number"
}
const pending: string[] = []
let ready = false
return {
onOpen(_event, ws) {
async onOpen(_event, ws) {
const socket = ws.raw
if (!isSocket(socket)) {
ws.close()
return
}
handler = Pty.connect(id, socket, cursor)
handler = await Pty.connect(id, socket, cursor)
ready = true
for (const msg of pending) handler?.onMessage(msg)
pending.length = 0
},
onMessage(event) {
if (typeof event.data !== "string") return
if (!ready) {
pending.push(event.data)
return
}
handler?.onMessage(event.data)
},
onClose() {
+17 -9
View File
@@ -14,11 +14,13 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Bus } from "../../bus"
import { NamedError } from "@opencode-ai/util/error"
const log = Log.create({ service: "server" })
@@ -88,8 +90,8 @@ export const SessionRoutes = lazy(() =>
},
}),
async (c) => {
const result = SessionStatus.list()
return c.json(result)
const result = await SessionStatus.list()
return c.json(Object.fromEntries(result))
},
)
.get(
@@ -279,14 +281,14 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
let session = await Session.get(sessionID)
if (updates.title !== undefined) {
session = await Session.setTitle({ sessionID, title: updates.title })
await Session.setTitle({ sessionID, title: updates.title })
}
if (updates.time?.archived !== undefined) {
session = await Session.setArchived({ sessionID, time: updates.time.archived })
await Session.setArchived({ sessionID, time: updates.time.archived })
}
const session = await Session.get(sessionID)
return c.json(session)
},
)
@@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() =>
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID })
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
})
},
)
@@ -1010,10 +1018,10 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod,
}),
),
validator("json", z.object({ response: PermissionNext.Reply })),
validator("json", z.object({ response: Permission.Reply })),
async (c) => {
const params = c.req.valid("param")
PermissionNext.reply({
Permission.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
+69 -76
View File
@@ -1,9 +1,8 @@
import { streamSSE } from "hono/streaming"
import { createHash } from "node:crypto"
import { Log } from "../util/log"
import { Bus } from "../bus"
import { BusEvent } from "../bus/bus-event"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { compress } from "hono/compress"
import { cors } from "hono/cors"
import { proxy } from "hono/proxy"
import { basicAuth } from "hono/basic-auth"
@@ -15,14 +14,12 @@ import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill"
import { Skill } from "../skill"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { Command } from "../command"
import { Global } from "../global"
import { WorkspaceContext } from "../control-plane/workspace-context"
import { WorkspaceID } from "../control-plane/schema"
import { ProviderID } from "../provider/schema"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
@@ -48,10 +45,16 @@ import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { initProjectors } from "./projectors"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
initProjectors()
export namespace Server {
export type Listener = {
hostname: string
@@ -60,6 +63,21 @@ export namespace Server {
stop: (close?: boolean) => Promise<void>
}
const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
const zipped = compress()
const skipCompress = (path: string, method: string) => {
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
return false
}
export const Default = lazy(() => create({}).app)
function create(opts: { cors?: string[] }) {
@@ -114,6 +132,7 @@ export namespace Server {
})
.use(
cors({
maxAge: 86_400,
origin(input) {
if (!input) return
@@ -138,6 +157,10 @@ export namespace Server {
},
}),
)
.use((c, next) => {
if (skipCompress(c.req.path, c.req.method)) return next()
return zipped(c, next)
})
.route("/global", GlobalRoutes())
.put(
"/auth/:providerID",
@@ -163,7 +186,7 @@ export namespace Server {
providerID: ProviderID.zod,
}),
),
validator("json", Auth.Info),
validator("json", Auth.Info.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
@@ -203,7 +226,6 @@ export namespace Server {
)
.use(async (c, next) => {
if (c.req.path === "/log") return next()
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
@@ -215,20 +237,14 @@ export namespace Server {
})(),
)
return WorkspaceContext.provide({
workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined,
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() {
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() {
return next()
},
})
return next()
},
})
})
.use(WorkspaceRouterMiddleware)
.get(
"/doc",
openAPIRouteHandler(app, {
@@ -251,6 +267,7 @@ export namespace Server {
}),
),
)
.use(WorkspaceRouterMiddleware)
.route("/project", ProjectRoutes())
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
@@ -341,7 +358,7 @@ export namespace Server {
},
}),
async (c) => {
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
const branch = await Vcs.branch()
return c.json({
branch,
})
@@ -507,65 +524,41 @@ export namespace Server {
return c.json(await Format.status())
},
)
.get(
"/event",
describeRoute({
summary: "Subscribe to events",
description: "Get events",
operationId: "event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(BusEvent.payloads()),
},
},
.all("/*", async (c) => {
const embeddedWebUI = await embeddedUIPromise
const path = c.req.path
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404)
const file = Bun.file(match)
if (await file.exists()) {
c.header("Content-Type", file.type)
if (file.type.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(await file.arrayBuffer())
} else {
return c.json({ error: "Not Found" }, 404)
}
} else {
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
},
}),
async (c) => {
log.info("event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
type: "server.connected",
properties: {},
}),
})
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
if (event.type === Bus.InstanceDisposed.type) {
stream.close()
}
})
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
type: "server.heartbeat",
properties: {},
}),
})
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
unsub()
resolve()
log.info("event disconnected")
})
})
})
},
)
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
}
}) as unknown as Hono
return {
app: route as Hono,