Merge pull request #11138 from IamCoder18/cli-console-restart-on-port-mismatch

fix(cli): restart daemon when console requested host/port don't match
This commit is contained in:
Catriel Müller
2026-06-12 13:33:36 -03:00
committed by GitHub
7 changed files with 190 additions and 25 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Restart the daemon when `kilo console` or `kilo daemon start` receives explicit network options that don't match the running daemon, instead of silently ignoring the requested settings.
+28 -4
View File
@@ -33,6 +33,27 @@ const options = {
export type NetworkOptions = InferredOptionTypes<typeof options>
// kilocode_change start
const flags = [
["port", "--port"],
["hostname", "--hostname"],
["mdns", "--mdns"],
["mdnsDomain", "--mdns-domain"],
["cors", "--cors"],
] as const
export type NetworkOption = (typeof flags)[number][0]
export function explicitNetworkOptions(argv = process.argv) {
const index = argv.indexOf("--")
const args = index === -1 ? argv : argv.slice(0, index)
return flags.flatMap(([name, flag]) =>
args.some((arg) => arg === flag || arg.startsWith(`${flag}=`) || (name === "mdns" && arg === "--no-mdns"))
? [name]
: [],
)
}
// kilocode_change end
export function withNetworkOptions<T>(yargs: Argv<T>) {
return yargs.options(options)
}
@@ -42,10 +63,13 @@ export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(func
})
export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
// kilocode_change start
const explicit = explicitNetworkOptions()
const portExplicitlySet = explicit.includes("port")
const hostnameExplicitlySet = explicit.includes("hostname")
const mdnsExplicitlySet = explicit.includes("mdns")
const mdnsDomainExplicitlySet = explicit.includes("mdnsDomain")
// kilocode_change end
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"])
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
@@ -1,6 +1,6 @@
import open from "open"
import { cmd } from "@/cli/cmd/cmd"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { AppRuntime } from "@/effect/app-runtime"
import { Daemon } from "@/kilocode/daemon/daemon"
import { warnPort } from "@/kilocode/cli/port-warning"
@@ -43,8 +43,9 @@ export const KiloConsoleCommand = cmd({
handler: async (args) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
const result = await Daemon.start(opts)
const state = result.state
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
if (daemon.restarted) console.warn("Restarted the Kilo daemon to apply the requested network options")
const state = daemon.result.state
if (!state) throw new Error("Kilo daemon did not provide connection state")
const url = publicUrl(state)
@@ -1,6 +1,6 @@
import type { Argv } from "yargs"
import { cmd } from "@/cli/cmd/cmd"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { AppRuntime } from "@/effect/app-runtime"
import { Daemon } from "@/kilocode/daemon/daemon"
import { warnPort } from "@/kilocode/cli/port-warning"
@@ -62,12 +62,19 @@ const StartCommand = cmd({
handler: async (args) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
const result = await Daemon.start(opts)
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
const result = daemon.result
if (args.json) {
print(result, true)
return
}
console.log(result.reused ? "kilo daemon already running" : "kilo daemon started")
console.log(
result.reused
? "kilo daemon already running"
: daemon.restarted
? "kilo daemon restarted"
: "kilo daemon started",
)
print(result)
},
})
+54 -13
View File
@@ -15,6 +15,16 @@ export namespace Daemon {
const lock = "kilocode-daemon"
export const PortRange = { start: 4097, end: 4116 } as const
export const Network = z.object({
hostname: z.string(),
port: z.number().int().nonnegative(),
mdns: z.boolean(),
mdnsDomain: z.string(),
cors: z.array(z.string()).transform((items) => [...new Set(items)].sort()),
})
export type Network = z.infer<typeof Network>
export type NetworkOption = keyof Network
export const State = z.object({
pid: z.number().int().positive(),
hostname: z.string(),
@@ -26,6 +36,7 @@ export namespace Daemon {
version: z.string(),
startedAt: z.string(),
log: z.string(),
options: Network.optional(),
})
export type State = z.infer<typeof State>
@@ -44,12 +55,7 @@ export namespace Daemon {
})
export type Status = z.infer<typeof Status>
export type Options = {
hostname: string
port: number
mdns?: boolean
mdnsDomain?: string
cors?: string[]
export type Options = Network & {
command?: string[]
env?: NodeJS.ProcessEnv
timeout?: number
@@ -60,6 +66,11 @@ export namespace Daemon {
reused: boolean
}
export type Ensure = {
result: Start
restarted: boolean
}
export type Stop = Status & {
stopped: boolean
}
@@ -157,13 +168,31 @@ export namespace Daemon {
return { running: true, stale: false, state, health: probe, file: file() }
}
export async function start(input: Options): Promise<Start> {
export function matches(state: State, input: Options, explicit: readonly NetworkOption[]) {
const options = Network.parse(input)
return explicit.every((name) => {
if (name === "hostname") return state.hostname === options.hostname
if (name === "port") return options.port === 0 || state.port === options.port
if (name === "mdns" && state.hostname !== options.hostname) return false
if (!state.options) return false
if (name === "cors") return state.options.cors.join("\n") === options.cors.join("\n")
return state.options[name] === options[name]
})
}
async function run(input: Options, explicit: readonly NetworkOption[] = [], force = false): Promise<Ensure> {
return await Flock.withLock(
lock,
async () => {
const current = await status()
if (current.running) return { ...current, started: false, reused: true }
if (current.stale && current.state) await terminate(current.state.pid, true)
const restarted = current.running && !!current.state && (force || !matches(current.state, input, explicit))
if (current.running && !restarted) {
return { result: { ...current, started: false, reused: true }, restarted: false }
}
if (current.state && (current.stale || restarted)) {
await terminate(current.state.pid, current.stale)
if (alive(current.state.pid)) await terminate(current.state.pid, true)
}
await clear()
const password = "kilo"
const token = auth(password)
@@ -182,15 +211,24 @@ export namespace Daemon {
version: InstallationVersion,
startedAt: new Date().toISOString(),
log: out,
options: Network.parse(input),
}
await write(state)
const next = await status()
return { ...next, started: true, reused: false, state }
return { result: { ...next, started: true, reused: false, state }, restarted }
},
{ dir: path.join(root(), "locks"), timeoutMs: 15_000, staleMs: 30_000 },
)
}
export async function start(input: Options): Promise<Start> {
return (await run(input)).result
}
export async function ensure(input: Options, explicit: readonly NetworkOption[]): Promise<Ensure> {
return await run(input, explicit)
}
export async function stop(): Promise<Stop> {
return await Flock.withLock(
lock,
@@ -209,8 +247,7 @@ export namespace Daemon {
}
export async function restart(input: Options): Promise<Start> {
await stop()
return await start(input)
return (await run(input, [], true)).result
}
export function command(
@@ -248,6 +285,7 @@ export namespace Daemon {
async function port(input: Options) {
if (input.port !== 0) return input.port
if (input.env?.KILO_TEST_DAEMON_EPHEMERAL_PORT) return 0
const ports = Array.from({ length: PortRange.end - PortRange.start + 1 }, (_, index) => PortRange.start + index)
const free = await Promise.any(
ports.map((item) =>
@@ -289,7 +327,10 @@ export namespace Daemon {
})
const failure = new Promise<never>((_, reject) => child.once("error", reject))
child.unref()
return await Promise.race([wait(out, child.pid, input.timeout ?? 10_000), failure])
return await Promise.race([wait(out, child.pid, input.timeout ?? 10_000), failure]).catch(async (err) => {
if (child.pid && alive(child.pid)) await terminate(child.pid, true)
throw err
})
} finally {
await Promise.all([stdout.close(), stderr.close()])
}
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test"
import { explicitNetworkOptions } from "../../../../src/cli/network"
import { Daemon } from "../../../../src/kilocode/daemon/daemon"
function opts(input: Partial<Daemon.Network> = {}): Daemon.Options {
return {
hostname: "127.0.0.1",
port: 4097,
mdns: false,
mdnsDomain: "kilo.local",
cors: [],
...input,
}
}
function state(input: Partial<Daemon.Network> = {}) {
const options = Daemon.Network.parse(opts(input))
return Daemon.State.parse({
pid: 1,
hostname: options.hostname,
port: options.port,
url: `http://${options.hostname}:${options.port}`,
username: "kilo",
password: "kilo",
token: "token",
version: "test",
startedAt: new Date(0).toISOString(),
log: "/tmp/daemon.log",
options,
})
}
describe("console daemon startup", () => {
test("detects every explicit network option form", () => {
expect(
explicitNetworkOptions([
"kilo",
"console",
"--port=4321",
"--hostname",
"0.0.0.0",
"--no-mdns",
"--mdns-domain=test.local",
"--cors",
"https://example.com",
]),
).toStrictEqual(["port", "hostname", "mdns", "mdnsDomain", "cors"])
expect(explicitNetworkOptions(["kilo", "console", "--", "--port=4321"])).toStrictEqual([])
})
test("matches every explicit network option", () => {
const current = state({ mdns: true, cors: ["https://b.example", "https://a.example"] })
const input = opts({
port: current.port,
mdns: true,
cors: ["https://a.example", "https://b.example", "https://a.example"],
})
expect(Daemon.matches(current, input, ["port", "hostname", "mdns", "mdnsDomain", "cors"])).toBe(true)
})
test("treats an explicit auto port as compatible", () => {
expect(Daemon.matches(state(), opts({ port: 0 }), ["port"])).toBe(true)
})
test("supports daemon state written before network options were persisted", () => {
const current = { ...state(), options: undefined }
expect(Daemon.matches(current, opts(), ["hostname", "port"])).toBe(true)
expect(Daemon.matches(current, opts(), ["mdns"])).toBe(false)
expect(Daemon.matches(current, opts(), ["mdnsDomain"])).toBe(false)
expect(Daemon.matches(current, opts(), ["cors"])).toBe(false)
})
test("rejects each mismatched explicit network option", () => {
const current = state()
expect(Daemon.matches(current, opts({ hostname: "0.0.0.0" }), ["hostname"])).toBe(false)
expect(Daemon.matches(current, opts({ port: current.port + 1 }), ["port"])).toBe(false)
expect(Daemon.matches(current, opts({ mdns: true }), ["mdns"])).toBe(false)
expect(Daemon.matches(current, opts({ mdnsDomain: "test.local" }), ["mdnsDomain"])).toBe(false)
expect(Daemon.matches(current, opts({ cors: ["https://example.com"] }), ["cors"])).toBe(false)
const mdns = state({ mdns: true })
expect(Daemon.matches(mdns, opts({ hostname: "0.0.0.0", mdns: true }), ["mdns"])).toBe(false)
})
})
@@ -32,6 +32,7 @@ function dirs(root: string) {
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_STATE_HOME: path.join(root, "xdg-state"),
XDG_CACHE_HOME: path.join(root, "xdg-cache"),
KILO_TEST_DAEMON_EPHEMERAL_PORT: "1",
}
}
@@ -124,8 +125,7 @@ describe("daemon manager", () => {
expect(started.running).toBe(true)
expect(started.state?.pid).toBeGreaterThan(0)
expect(started.state?.token).toBeTruthy()
expect(started.state?.port).toBeGreaterThanOrEqual(Daemon.PortRange.start)
expect(started.state?.port).toBeLessThanOrEqual(Daemon.PortRange.end)
expect(started.state?.port).toBeGreaterThan(0)
const blocked = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`)
expect(blocked.status).toBe(401)