Merge pull request #11028 from IamCoder18/cli-server-hostname-url-display

feat(server,daemon): display local and network URLs when binding to 0.0.0.0
This commit is contained in:
Catriel Müller
2026-06-19 13:53:28 -03:00
committed by GitHub
10 changed files with 161 additions and 68 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Display local and network URLs when the server binds to 0.0.0.0
+10 -1
View File
@@ -18,7 +18,16 @@ export const ServeCommand = effectCmd({
}
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
console.log(`kilo server listening on http://${server.hostname}:${server.port}`) // kilocode_change
// kilocode_change start
const urls = server.urls
console.log(`kilo server listening on ${urls.bind}`)
if (urls.network) {
console.log(` Local: ${urls.local}`)
console.log(` Network: ${urls.network}`)
}
// kilocode_change end
// kilocode_change start - graceful signal shutdown
// yield* Effect.never
+16 -53
View File
@@ -6,29 +6,6 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change
import open from "open"
import { networkInterfaces } from "os"
function getNetworkIPs() {
const nets = networkInterfaces()
const results: string[] = []
for (const name of Object.keys(nets)) {
const net = nets[name]
if (!net) continue
for (const netInfo of net) {
// Skip internal and non-IPv4 addresses
if (netInfo.internal || netInfo.family !== "IPv4") continue
// Skip Docker bridge networks (typically 172.x.x.x)
if (netInfo.address.startsWith("172.")) continue
results.push(netInfo.address)
}
}
return results
}
export const WebCommand = effectCmd({
command: "web",
@@ -47,39 +24,25 @@ export const WebCommand = effectCmd({
UI.println(UI.logo(" "))
UI.empty()
if (opts.hostname === "0.0.0.0") {
// Show localhost for local access
const localhostUrl = `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
// kilocode_change start
const urls = server.urls
// Show network IPs for remote access
const networkIPs = getNetworkIPs()
if (networkIPs.length > 0) {
for (const ip of networkIPs) {
UI.println(
UI.Style.TEXT_INFO_BOLD + " Network access: ",
UI.Style.TEXT_NORMAL,
`http://${ip}:${server.port}`,
)
}
}
if (opts.mdns) {
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
`${opts.mdnsDomain}:${server.port}`,
)
}
// Open localhost in browser
open(localhostUrl).catch(() => {})
} else {
const displayUrl = server.url.toString()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
open(displayUrl).catch(() => {})
UI.println(UI.Style.TEXT_INFO_BOLD + " Local: ", UI.Style.TEXT_NORMAL, urls.local)
if (urls.network) {
UI.println(UI.Style.TEXT_INFO_BOLD + " Network: ", UI.Style.TEXT_NORMAL, urls.network)
}
if (opts.mdns) {
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
`${opts.mdnsDomain}:${server.port}`,
)
}
open(urls.local).catch(() => {})
// kilocode_change end
// kilocode_change start - graceful signal shutdown
yield* Effect.promise(
() =>
@@ -1,6 +1,7 @@
import open from "open"
import { cmd } from "@/cli/cmd/cmd"
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { serverUrls } from "@/kilocode/cli/server-urls"
import { AppRuntime } from "@/effect/app-runtime"
import { Daemon } from "@/kilocode/daemon/daemon"
import { warnPort } from "@/kilocode/cli/port-warning"
@@ -48,10 +49,15 @@ export const KiloConsoleCommand = cmd({
const state = daemon.result.state
if (!state) throw new Error("Kilo daemon did not provide connection state")
const url = publicUrl(state)
const urls = state.urls ?? serverUrls(state.hostname, state.port)
const consoleLocal = `${urls.local}/console`
const consoleNetwork = urls.network ? `${urls.network}/console` : undefined
await launch(browserUrl(state)).catch((err) => {
console.warn(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`)
})
console.log(`Kilo Console: ${url}`)
console.log("Kilo Console:")
console.log(` Local: ${consoleLocal}`)
if (consoleNetwork) console.log(` Network: ${consoleNetwork}`)
},
})
@@ -1,6 +1,7 @@
import type { Argv } from "yargs"
import { cmd } from "@/cli/cmd/cmd"
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { serverUrls } from "@/kilocode/cli/server-urls"
import { AppRuntime } from "@/effect/app-runtime"
import { Daemon } from "@/kilocode/daemon/daemon"
import { warnPort } from "@/kilocode/cli/port-warning"
@@ -19,6 +20,7 @@ function safe(input: Daemon.State | undefined) {
hostname: input.hostname,
port: input.port,
url: input.url,
urls: input.urls,
username: input.username,
version: input.version,
startedAt: input.startedAt,
@@ -47,7 +49,13 @@ function print(input: Daemon.Status, json?: boolean) {
return
}
console.log(`kilo daemon running`)
console.log(`url: ${input.state?.url}`)
if (input.state?.urls) {
const urls = input.state.urls
console.log(`local: ${urls.local}`)
if (urls.network) console.log(`network: ${urls.network}`)
} else {
console.log(`url: ${input.state?.url}`)
}
console.log(`pid: ${input.state?.pid}`)
console.log(`version: ${input.health?.version ?? input.state?.version}`)
console.log(`auth: enabled`)
@@ -0,0 +1,38 @@
// kilocode_change - new file
import { isIP } from "net"
import { networkInterfaces } from "os"
export function getNetworkIPs() {
const nets = networkInterfaces()
const results: string[] = []
for (const name of Object.keys(nets)) {
const net = nets[name]
if (!net) continue
for (const netInfo of net) {
if (netInfo.internal || netInfo.family !== "IPv4") continue
results.push(netInfo.address)
}
}
return results
}
function format(hostname: string, port: number) {
const url = new URL("http://localhost")
url.hostname = isIP(hostname) === 6 ? `[${hostname}]` : hostname
url.port = String(port)
return url.origin
}
export function serverUrls(hostname: string, port: number) {
const bind = format(hostname, port)
const local = hostname === "0.0.0.0" ? format("localhost", port) : hostname === "::" ? format("::1", port) : bind
const ip = hostname === "0.0.0.0" ? getNetworkIPs()[0] : undefined
return {
local,
network: ip ? format(ip, port) : undefined,
bind,
}
}
@@ -9,6 +9,7 @@ import { Flock } from "@opencode-ai/core/util/flock"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { serverUrls } from "@/kilocode/cli/server-urls"
export namespace Daemon {
const username = "kilo"
@@ -30,6 +31,13 @@ export namespace Daemon {
hostname: z.string(),
port: z.number().int().positive(),
url: z.string(),
urls: z
.object({
local: z.string(),
network: z.string().optional(),
bind: z.string(),
})
.optional(),
username: z.string(),
password: z.string(),
token: z.string(),
@@ -205,6 +213,7 @@ export namespace Daemon {
hostname: ready.hostname,
port: ready.port,
url: `http://${host(ready.hostname)}:${ready.port}`,
urls: serverUrls(ready.hostname, ready.port),
username,
password,
token,
+10
View File
@@ -2,6 +2,7 @@ import "./init-projectors"
import { NodeHttpServer } from "@effect/platform-node"
import * as Log from "@opencode-ai/core/util/log"
import { serverUrls } from "@/kilocode/cli/server-urls" // kilocode_change
import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
@@ -24,6 +25,13 @@ export type Listener = {
hostname: string
port: number
url: URL
// kilocode_change start
urls: {
local: string
network?: string
bind: string
}
// kilocode_change end
stop: (close?: boolean) => Promise<void>
}
@@ -79,6 +87,7 @@ export async function listen(opts: ListenOptions): Promise<Listener> {
hostname: listener.hostname,
port: listener.port,
url: listener.url,
urls: listener.urls, // kilocode_change
stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined),
}
}
@@ -96,6 +105,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
hostname: opts.hostname,
port: address.port,
url: listenerUrl,
urls: serverUrls(opts.hostname, address.port), // kilocode_change
stop: yield* makeStop(state, unpublishMdns),
}
},
@@ -8,9 +8,12 @@ import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode run (non-interactive subprocess)", () => {
// kilocode_change start
// Keep full CLI subprocesses serial within this file; the test runner already
// executes files in parallel, and nested concurrency exhausts Windows CI.
// Happy path: prompt completes, output reaches stdout, process exits 0.
// If this fails, all the others likely will too — debug here first.
cliIt.concurrent(
cliIt.live(
"exits 0 and writes the response to stdout on a successful prompt",
({ llm, opencode }) =>
Effect.gen(function* () {
@@ -21,33 +24,36 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
60_000,
)
// kilocode_change end
// kilocode_change start
// Regression for #27371: an unknown model used to hang the process forever
// waiting on a session.status === idle event that never arrived. The fix
// makes the SDK call surface an error promptly so the process exits nonzero.
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
// would expire the timeout and produce a different (signal-killed) failure.
cliIt.concurrent(
// makes the SDK call surface an error promptly so the process exits 1.
// A harness timeout produces synthetic exit code -1, so the exact assertion
// distinguishes the intended failure from a signal-killed process.
cliIt.live(
"exits nonzero promptly when the model is unknown (regression for #27371)",
({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.run("say hi", {
model: "test/nonexistent-model",
timeoutMs: 15_000,
timeoutMs: 30_000,
})
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(15_000)
opencode.expectExit(result, 1)
}),
30_000,
60_000,
)
// kilocode_change end
// kilocode_change start
// Locks in the current behavior: when the LLM stream errors mid-response
// (the prompt was accepted, then the upstream provider failed), opencode
// emits a session.error event and the process exits 0 today.
//
// This is debatable — a future cleanup might flip it to exit 1. If you're
// changing this expectation, do it deliberately and say so in the PR.
cliIt.concurrent(
cliIt.live(
"mid-stream LLM error still exits 0 today (contract lock-in)",
({ llm, opencode }) =>
Effect.gen(function* () {
@@ -57,11 +63,13 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
60_000,
)
// kilocode_change end
// kilocode_change start
// --format json puts one JSON object per line on stdout for each emitted
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
// shape so a future event-emit change has to update this expectation.
cliIt.concurrent(
cliIt.live(
"--format json emits parseable line-delimited JSON to stdout",
({ llm, opencode }) =>
Effect.gen(function* () {
@@ -81,4 +89,5 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
60_000,
)
// kilocode_change end
})
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { explicitNetworkOptions } from "../../../../src/cli/network"
import { getNetworkIPs, serverUrls } from "../../../../src/kilocode/cli/server-urls"
import { Daemon } from "../../../../src/kilocode/daemon/daemon"
function opts(input: Partial<Daemon.Network> = {}): Daemon.Options {
@@ -30,6 +31,41 @@ function state(input: Partial<Daemon.Network> = {}) {
})
}
describe("server URL display", () => {
test("advertises a network URL only for wildcard binds", () => {
expect(serverUrls("127.0.0.1", 4096)).toStrictEqual({
local: "http://127.0.0.1:4096",
network: undefined,
bind: "http://127.0.0.1:4096",
})
expect(serverUrls("192.168.1.50", 4096)).toStrictEqual({
local: "http://192.168.1.50:4096",
network: undefined,
bind: "http://192.168.1.50:4096",
})
const ip = getNetworkIPs()[0]
expect(serverUrls("0.0.0.0", 4096)).toStrictEqual({
local: "http://localhost:4096",
network: ip ? `http://${ip}:4096` : undefined,
bind: "http://0.0.0.0:4096",
})
})
test("formats IPv6 bind URLs without advertising IPv4 interfaces", () => {
expect(serverUrls("::1", 4096)).toStrictEqual({
local: "http://[::1]:4096",
network: undefined,
bind: "http://[::1]:4096",
})
expect(serverUrls("::", 4096)).toStrictEqual({
local: "http://[::1]:4096",
network: undefined,
bind: "http://[::]:4096",
})
})
})
describe("console daemon startup", () => {
test("detects every explicit network option form", () => {
expect(