Skip to content

Commit 4a1084a

Browse files
committed
feat(server): publish on-disk listener registry for bound TCP servers
Server.listen now writes <xdg-state>/opencode/instances/<pid>.json {pid,url,hostname,port,startedAt} when it binds a socket and removes it on stop, sweeping records of dead pids. Local plugins (oh-my-openagent cross-project mailbox) read their own pid's record as ground truth for 'this process is externally reachable' plus the real bound URL, replacing fragile HTTP self-probes and the localhost:4096 client placeholder.
1 parent e66ba53 commit 4a1084a

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
import { Global } from "@opencode-ai/core/global"
4+
5+
/**
6+
* On-disk registry of live TCP listeners, one JSON record per process.
7+
*
8+
* Written when `Server.listen` binds a real socket (serve / web / --port / TUI worker),
9+
* removed when the listener stops. External consumers (e.g. the oh-my-openagent
10+
* cross-project mailbox) use the presence of a record for their own pid to decide
11+
* whether this opencode process is externally reachable, and read `url` for the
12+
* real bound address instead of guessing ports.
13+
*
14+
* Everything here is best-effort: registry failures must never break the server.
15+
*/
16+
17+
export interface ListenerRecord {
18+
pid: number
19+
url: string
20+
hostname: string
21+
port: number
22+
startedAt: number
23+
}
24+
25+
function registryDir(): string {
26+
return path.join(Global.Path.state, "instances")
27+
}
28+
29+
function recordPath(pid: number): string {
30+
return path.join(registryDir(), `${pid}.json`)
31+
}
32+
33+
function isPidAlive(pid: number): boolean {
34+
try {
35+
process.kill(pid, 0)
36+
return true
37+
} catch {
38+
return false
39+
}
40+
}
41+
42+
function sweepStaleRecords(dir: string): void {
43+
let entries: string[]
44+
try {
45+
entries = fs.readdirSync(dir)
46+
} catch {
47+
return
48+
}
49+
for (const entry of entries) {
50+
const match = /^(\d+)\.json$/.exec(entry)
51+
if (!match) continue
52+
const pid = Number(match[1])
53+
if (pid === process.pid || isPidAlive(pid)) continue
54+
try {
55+
fs.rmSync(path.join(dir, entry), { force: true })
56+
} catch {
57+
// best-effort sweep; a locked or already-removed file is fine
58+
}
59+
}
60+
}
61+
62+
export function publishListener(input: { url: URL; hostname: string; port: number }): void {
63+
try {
64+
const dir = registryDir()
65+
fs.mkdirSync(dir, { recursive: true })
66+
sweepStaleRecords(dir)
67+
const record: ListenerRecord = {
68+
pid: process.pid,
69+
url: input.url.toString(),
70+
hostname: input.hostname,
71+
port: input.port,
72+
startedAt: Date.now(),
73+
}
74+
const tmp = path.join(dir, `.${process.pid}.${Date.now()}.tmp`)
75+
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
76+
fs.renameSync(tmp, recordPath(process.pid))
77+
} catch {
78+
// registry is advisory only
79+
}
80+
}
81+
82+
export function unpublishListener(): void {
83+
try {
84+
fs.rmSync(recordPath(process.pid), { force: true })
85+
} catch {
86+
// registry is advisory only
87+
}
88+
}
89+
90+
// Finalizers do not run on signal-driven exits, so also unpublish on process exit;
91+
// records surviving a hard kill are swept by the next publishListener call.
92+
process.on("exit", unpublishListener)
93+
94+
export * as ListenerRegistry from "./listener-registry"

packages/opencode/src/server/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { HttpApiApp } from "./routes/instance/httpapi/server"
1111
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
1212
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
1313
import { PublicApi } from "./routes/instance/httpapi/public"
14+
import { ListenerRegistry } from "./listener-registry"
1415
import type { CorsOptions } from "@opencode-ai/server/cors"
1516
import { lazy } from "@/util/lazy"
1617

@@ -87,6 +88,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
8788
const listenerUrl = makeURL(opts.hostname, address.port)
8889
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
8990
url = listenerUrl
91+
ListenerRegistry.publishListener({ url: listenerUrl, hostname: opts.hostname, port: address.port })
9092

9193
return {
9294
hostname: opts.hostname,
@@ -178,6 +180,7 @@ function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>, list
178180
Effect.ensuring(
179181
Effect.sync(() => {
180182
if (url === listenerUrl) url = undefined
183+
ListenerRegistry.unpublishListener()
181184
}),
182185
),
183186
),

0 commit comments

Comments
 (0)