Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
import { Effect } from "effect"
import { Server } from "../../server/server"
import { ServerDiscovery } from "@/cli/server-discovery"
import { effectCmd } from "../effect-cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"

export const ServeCommand = effectCmd({
command: "serve",
builder: (yargs) => withNetworkOptions(yargs),
builder: (yargs) =>
withNetworkOptions(yargs).option("discoverable", {
type: "boolean",
describe: "write this server to the local discovery file for default TUI startup",
default: false,
}),
describe: "starts a headless opencode server",
// Server loads instances per-request via x-opencode-directory header — no
// need for an ambient project InstanceContext at startup.
instance: false,
handler: Effect.fn("Cli.serve")(function* (args) {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
handler: (args) =>
Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
if (discovery) {
yield* discovery.write(server.url)
process.on("exit", ServerDiscovery.removeSync)
}
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)

yield* Effect.never
}),
yield* Effect.never.pipe(
Effect.ensuring(
discovery
? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
: Effect.void,
),
)
}).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
})
14 changes: 14 additions & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { ServerAuth } from "@/server/auth"
import { ServerDiscovery } from "@/cli/server-discovery"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
Expand Down Expand Up @@ -197,16 +199,26 @@ export const TuiThreadCommand = cmd({
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const discovered = external ? undefined : await ServerDiscovery.find()

const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
headers: ServerAuth.headers(),
events: undefined,
}
: discovered
? {
url: discovered,
fetch: undefined,
headers: ServerAuth.headers(),
events: undefined,
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
headers: undefined,
events: createEventSource(client),
}

Expand All @@ -216,6 +228,7 @@ export const TuiThreadCommand = cmd({
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers,
})
} catch (error) {
UI.error(errorMessage(error))
Expand All @@ -239,6 +252,7 @@ export const TuiThreadCommand = cmd({
config,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers,
events: transport.events,
args: {
continue: args.continue,
Expand Down
112 changes: 112 additions & 0 deletions packages/opencode/src/cli/server-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
export * as ServerDiscovery from "./server-discovery"

import { makeRuntime } from "@/effect/run-service"
import { ServerAuth } from "@/server/auth"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { readFileSync, unlinkSync } from "fs"
import path from "path"

export const file = path.join(Global.Path.state, "server.json")

const Entry = Schema.Struct({
url: Schema.String,
pid: Schema.Number,
})
type Entry = typeof Entry.Type
const decodeEntry = Schema.decodeUnknownOption(Entry)

export interface Interface {
readonly write: (url: URL) => Effect.Effect<void>
readonly remove: () => Effect.Effect<void>
readonly find: () => Effect.Effect<string | undefined>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/CliServerDiscovery") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service

const read = Effect.fn("CliServerDiscovery.read")(function* () {
const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
return Option.getOrUndefined(decodeEntry(entry))
})

const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
const entry = yield* read()
if (entry?.pid !== process.pid) return
yield* fs.remove(file).pipe(Effect.ignore)
})

const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
const current = yield* read()
if (current?.pid !== entry.pid || current.url !== entry.url) return
yield* fs.remove(file).pipe(Effect.ignore)
})

return Service.of({
write: Effect.fn("CliServerDiscovery.write")(function* (url) {
yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
}),
remove,
find: Effect.fn("CliServerDiscovery.find")(function* () {
const entry = yield* read()
if (!entry) return undefined
const url = yield* healthy(entry.url)
if (url) return url
yield* removeStale(entry)
}),
})
}),
)

export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

const { runPromise } = makeRuntime(Service, defaultLayer)

export const find = () => runPromise((discovery) => discovery.find())

export function removeSync() {
const entry = readSync()
if (entry?.pid !== process.pid) return
try {
unlinkSync(file)
} catch {}
}

function readSync() {
try {
return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
} catch {
return undefined
}
}

function healthy(input: string) {
return Effect.tryPromise({
try: async () => {
const url = new URL(input)
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
const response = await fetch(new URL("/global/health", url), {
headers: ServerAuth.headers(),
signal: AbortSignal.timeout(1000),
})
if (!response.ok) return undefined
const body = (await response.json()) as unknown
if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
return url.toString()
}
},
catch: () => undefined,
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
}

function localURL(url: URL) {
const result = new URL(url)
if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
if (result.hostname === "::") result.hostname = "::1"
return result
}
Loading