Skip to content

Commit fdfdc53

Browse files
Apply PR #27805: Discover running serve instances from TUI
2 parents 1fac325 + 26f64f2 commit fdfdc53

3 files changed

Lines changed: 154 additions & 10 deletions

File tree

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,42 @@
11
import { Effect } from "effect"
22
import { Server } from "../../server/server"
3+
import { ServerDiscovery } from "@/cli/server-discovery"
34
import { effectCmd } from "../effect-cmd"
45
import { withNetworkOptions, resolveNetworkOptions } from "../network"
56
import { Flag } from "@opencode-ai/core/flag/flag"
67

78
export const ServeCommand = effectCmd({
89
command: "serve",
9-
builder: (yargs) => withNetworkOptions(yargs),
10+
builder: (yargs) =>
11+
withNetworkOptions(yargs).option("discoverable", {
12+
type: "boolean",
13+
describe: "write this server to the local discovery file for default TUI startup",
14+
default: false,
15+
}),
1016
describe: "starts a headless opencode server",
1117
// Server loads instances per-request via x-opencode-directory header — no
1218
// need for an ambient project InstanceContext at startup.
1319
instance: false,
14-
handler: Effect.fn("Cli.serve")(function* (args) {
15-
if (!Flag.OPENCODE_SERVER_PASSWORD) {
16-
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
17-
}
18-
const opts = yield* resolveNetworkOptions(args)
19-
const server = yield* Effect.promise(() => Server.listen(opts))
20-
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
20+
handler: (args) =>
21+
Effect.gen(function* () {
22+
if (!Flag.OPENCODE_SERVER_PASSWORD) {
23+
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
24+
}
25+
const opts = yield* resolveNetworkOptions(args)
26+
const server = yield* Effect.promise(() => Server.listen(opts))
27+
const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
28+
if (discovery) {
29+
yield* discovery.write(server.url)
30+
process.on("exit", ServerDiscovery.removeSync)
31+
}
32+
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
2133

22-
yield* Effect.never
23-
}),
34+
yield* Effect.never.pipe(
35+
Effect.ensuring(
36+
discovery
37+
? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
38+
: Effect.void,
39+
),
40+
)
41+
}).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
2442
})

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { errorMessage } from "@/util/error"
99
import { withTimeout } from "@/util/timeout"
1010
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
1111
import { Filesystem } from "@/util/filesystem"
12+
import { ServerAuth } from "@/server/auth"
13+
import { ServerDiscovery } from "@/cli/server-discovery"
1214
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
1315
import type { EventSource } from "./context/sdk"
1416
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
@@ -197,16 +199,26 @@ export const TuiThreadCommand = cmd({
197199
network.mdns ||
198200
network.port !== 0 ||
199201
network.hostname !== "127.0.0.1"
202+
const discovered = external ? undefined : await ServerDiscovery.find()
200203

201204
const transport = external
202205
? {
203206
url: (await client.call("server", network)).url,
204207
fetch: undefined,
208+
headers: ServerAuth.headers(),
205209
events: undefined,
206210
}
211+
: discovered
212+
? {
213+
url: discovered,
214+
fetch: undefined,
215+
headers: ServerAuth.headers(),
216+
events: undefined,
217+
}
207218
: {
208219
url: "http://opencode.internal",
209220
fetch: createWorkerFetch(client),
221+
headers: undefined,
210222
events: createEventSource(client),
211223
}
212224

@@ -216,6 +228,7 @@ export const TuiThreadCommand = cmd({
216228
sessionID: args.session,
217229
directory: cwd,
218230
fetch: transport.fetch,
231+
headers: transport.headers,
219232
})
220233
} catch (error) {
221234
UI.error(errorMessage(error))
@@ -239,6 +252,7 @@ export const TuiThreadCommand = cmd({
239252
config,
240253
directory: cwd,
241254
fetch: transport.fetch,
255+
headers: transport.headers,
242256
events: transport.events,
243257
args: {
244258
continue: args.continue,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
export * as ServerDiscovery from "./server-discovery"
2+
3+
import { makeRuntime } from "@/effect/run-service"
4+
import { ServerAuth } from "@/server/auth"
5+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
6+
import { Global } from "@opencode-ai/core/global"
7+
import { Context, Effect, Layer, Option, Schema } from "effect"
8+
import { readFileSync, unlinkSync } from "fs"
9+
import path from "path"
10+
11+
export const file = path.join(Global.Path.state, "server.json")
12+
13+
const Entry = Schema.Struct({
14+
url: Schema.String,
15+
pid: Schema.Number,
16+
})
17+
type Entry = typeof Entry.Type
18+
const decodeEntry = Schema.decodeUnknownOption(Entry)
19+
20+
export interface Interface {
21+
readonly write: (url: URL) => Effect.Effect<void>
22+
readonly remove: () => Effect.Effect<void>
23+
readonly find: () => Effect.Effect<string | undefined>
24+
}
25+
26+
export class Service extends Context.Service<Service, Interface>()("@opencode/CliServerDiscovery") {}
27+
28+
export const layer = Layer.effect(
29+
Service,
30+
Effect.gen(function* () {
31+
const fs = yield* AppFileSystem.Service
32+
33+
const read = Effect.fn("CliServerDiscovery.read")(function* () {
34+
const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
35+
return Option.getOrUndefined(decodeEntry(entry))
36+
})
37+
38+
const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
39+
const entry = yield* read()
40+
if (entry?.pid !== process.pid) return
41+
yield* fs.remove(file).pipe(Effect.ignore)
42+
})
43+
44+
const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
45+
const current = yield* read()
46+
if (current?.pid !== entry.pid || current.url !== entry.url) return
47+
yield* fs.remove(file).pipe(Effect.ignore)
48+
})
49+
50+
return Service.of({
51+
write: Effect.fn("CliServerDiscovery.write")(function* (url) {
52+
yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
53+
}),
54+
remove,
55+
find: Effect.fn("CliServerDiscovery.find")(function* () {
56+
const entry = yield* read()
57+
if (!entry) return undefined
58+
const url = yield* healthy(entry.url)
59+
if (url) return url
60+
yield* removeStale(entry)
61+
}),
62+
})
63+
}),
64+
)
65+
66+
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
67+
68+
const { runPromise } = makeRuntime(Service, defaultLayer)
69+
70+
export const find = () => runPromise((discovery) => discovery.find())
71+
72+
export function removeSync() {
73+
const entry = readSync()
74+
if (entry?.pid !== process.pid) return
75+
try {
76+
unlinkSync(file)
77+
} catch {}
78+
}
79+
80+
function readSync() {
81+
try {
82+
return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
83+
} catch {
84+
return undefined
85+
}
86+
}
87+
88+
function healthy(input: string) {
89+
return Effect.tryPromise({
90+
try: async () => {
91+
const url = new URL(input)
92+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
93+
const response = await fetch(new URL("/global/health", url), {
94+
headers: ServerAuth.headers(),
95+
signal: AbortSignal.timeout(1000),
96+
})
97+
if (!response.ok) return undefined
98+
const body = (await response.json()) as unknown
99+
if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
100+
return url.toString()
101+
}
102+
},
103+
catch: () => undefined,
104+
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
105+
}
106+
107+
function localURL(url: URL) {
108+
const result = new URL(url)
109+
if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
110+
if (result.hostname === "::") result.hostname = "::1"
111+
return result
112+
}

0 commit comments

Comments
 (0)