From 63294a4b2a6019461087a4b59e53dcb8c23c55c7 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Thu, 11 Jun 2026 12:44:36 -0600 Subject: [PATCH 1/7] fix(cli): restart daemon when console requested host/port don't match When `kilo console --port 4321` reuses an already-running daemon on a different port (e.g. 4097), the new options were silently ignored. Now `kilo console` checks if the running daemon matches the requested host/port, and restarts if they differ. The restart only occurs when explicit `--port` or `--hostname` options are provided via CLI, not when using default values. --- .../opencode/src/kilocode/cli/cmd/console.ts | 26 +++++- .../test/kilocode/cli/cmd/console.test.ts | 81 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/kilocode/cli/cmd/console.test.ts diff --git a/packages/opencode/src/kilocode/cli/cmd/console.ts b/packages/opencode/src/kilocode/cli/cmd/console.ts index 8c8f6a27e8ad..a5bf3c66ef91 100644 --- a/packages/opencode/src/kilocode/cli/cmd/console.ts +++ b/packages/opencode/src/kilocode/cli/cmd/console.ts @@ -35,15 +35,35 @@ async function launch(url: string) { }) } +function explicitNetworkOption(name: string) { + return process.argv.some((arg) => arg === name || arg.startsWith(`${name}=`)) +} + +export async function startDaemon(opts: Daemon.Options, restartOnMismatch = false) { + const result = await Daemon.start(opts) + let state = result.state + if (!state) throw new Error("Kilo daemon did not provide connection state") + + const portMismatch = opts.port !== 0 ? state.port !== opts.port : true + const hostnameMismatch = state.hostname !== opts.hostname + if (restartOnMismatch && result.reused && (hostnameMismatch || portMismatch)) { + console.warn(`Daemon running at ${state.hostname}:${state.port}; restarting with requested ${opts.hostname}:${opts.port}...`) + const fresh = await Daemon.restart(opts) + state = fresh.state + if (!state) throw new Error("Kilo daemon did not provide connection state") + } + + return state +} + export const KiloConsoleCommand = cmd({ command: "console", describe: "open the local Kilo Console", builder: (yargs) => withNetworkOptions(yargs), handler: async (args) => { const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) - const result = await Daemon.start(opts) - const state = result.state - if (!state) throw new Error("Kilo daemon did not provide connection state") + const restartOnMismatch = explicitNetworkOption("--port") || explicitNetworkOption("--hostname") + const state = await startDaemon(opts, restartOnMismatch) const url = publicUrl(state) await launch(browserUrl(state)).catch((err) => { diff --git a/packages/opencode/test/kilocode/cli/cmd/console.test.ts b/packages/opencode/test/kilocode/cli/cmd/console.test.ts new file mode 100644 index 000000000000..6054a30d96df --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/console.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { startDaemon } from "../../../../src/kilocode/cli/cmd/console" +import { Daemon } from "../../../../src/kilocode/daemon/daemon" +import { tmpdir } from "../../../fixture/fixture" + +const original = { + state: process.env.KILO_TEST_DAEMON_STATE_DIR, + log: process.env.KILO_TEST_DAEMON_LOG_DIR, + disabled: process.env.KILO_NO_DAEMON, +} + +afterEach(async () => { + await Daemon.stop().catch(() => undefined) + restore() +}) + +function restore() { + process.env.KILO_TEST_DAEMON_STATE_DIR = original.state + if (original.state === undefined) delete process.env.KILO_TEST_DAEMON_STATE_DIR + process.env.KILO_TEST_DAEMON_LOG_DIR = original.log + if (original.log === undefined) delete process.env.KILO_TEST_DAEMON_LOG_DIR + process.env.KILO_NO_DAEMON = original.disabled + if (original.disabled === undefined) delete process.env.KILO_NO_DAEMON +} + +function dirs(root: string) { + process.env.KILO_TEST_DAEMON_STATE_DIR = path.join(root, "state") + process.env.KILO_TEST_DAEMON_LOG_DIR = path.join(root, "log") + return { + XDG_DATA_HOME: path.join(root, "xdg-data"), + XDG_CONFIG_HOME: path.join(root, "xdg-config"), + XDG_STATE_HOME: path.join(root, "xdg-state"), + XDG_CACHE_HOME: path.join(root, "xdg-cache"), + } +} + +function opts(root: string): Daemon.Options { + return { + hostname: "127.0.0.1", + port: 0, + mdns: false, + mdnsDomain: "kilo.local", + cors: [], + command: [process.execPath, "--conditions=browser", path.join(process.cwd(), "src/index.ts")], + env: dirs(root), + timeout: 20_000, + } +} + +describe("console daemon startup", () => { + test("reuses a daemon when the requested host and port match", async () => { + await using tmp = await tmpdir() + const env = opts(tmp.path) + + const first = await startDaemon(env) + const reused = await startDaemon({ ...env, port: first.port }) + + expect(reused.pid).toBe(first.pid) + }, 20_000) + + test("does not restart when the requested port is the default auto port", async () => { + await using tmp = await tmpdir() + const env = opts(tmp.path) + + const first = await startDaemon(env) + const reused = await startDaemon({ ...env, port: 0 }) + + expect(reused.pid).toBe(first.pid) + }, 20_000) + + test("restarts a reused daemon when the requested port differs", async () => { + await using tmp = await tmpdir() + const env = opts(tmp.path) + const first = await startDaemon(env) + + const restarted = await startDaemon({ ...env, port: 0 }, true) + + expect(restarted.pid).not.toBe(first.pid) + }, 20_000) +}) From e35430580be89361304c4b599ccd7eeb62fce7c1 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Thu, 11 Jun 2026 20:52:43 -0600 Subject: [PATCH 2/7] chore: add changeset for console restart fix --- .changeset/fix-cli-console-port-mismatch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-cli-console-port-mismatch.md diff --git a/.changeset/fix-cli-console-port-mismatch.md b/.changeset/fix-cli-console-port-mismatch.md new file mode 100644 index 000000000000..8d6b32927195 --- /dev/null +++ b/.changeset/fix-cli-console-port-mismatch.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restart the daemon when `kilo console` is invoked with an explicit `--port` or `--hostname` that doesn't match the already-running daemon, instead of silently reusing the old connection. From 4b5e7e622976f7772c440bd45ed3c4b09592fc87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 12 Jun 2026 10:17:51 -0300 Subject: [PATCH 3/7] refactor: fix merge --- packages/opencode/src/kilocode/cli/cmd/console.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/console.ts b/packages/opencode/src/kilocode/cli/cmd/console.ts index 9de23459ebad..fd0556963610 100644 --- a/packages/opencode/src/kilocode/cli/cmd/console.ts +++ b/packages/opencode/src/kilocode/cli/cmd/console.ts @@ -48,7 +48,9 @@ export async function startDaemon(opts: Daemon.Options, restartOnMismatch = fals const portMismatch = opts.port !== 0 ? state.port !== opts.port : true const hostnameMismatch = state.hostname !== opts.hostname if (restartOnMismatch && result.reused && (hostnameMismatch || portMismatch)) { - console.warn(`Daemon running at ${state.hostname}:${state.port}; restarting with requested ${opts.hostname}:${opts.port}...`) + console.warn( + `Daemon running at ${state.hostname}:${state.port}; restarting with requested ${opts.hostname}:${opts.port}...`, + ) const fresh = await Daemon.restart(opts) state = fresh.state if (!state) throw new Error("Kilo daemon did not provide connection state") @@ -66,7 +68,7 @@ export const KiloConsoleCommand = cmd({ warnPort(opts.port) const restartOnMismatch = explicitNetworkOption("--port") || explicitNetworkOption("--hostname") const state = await startDaemon(opts, restartOnMismatch) - if (!state) throw new Error("Kilo daemon did not provide connection state") + const url = publicUrl(state) await launch(browserUrl(state)).catch((err) => { console.warn(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`) From 5413c807def1084eae231733c3e9e6ec1f27299f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 12 Jun 2026 11:32:16 -0300 Subject: [PATCH 4/7] refactor: refactor solution to include daemon command --- .changeset/fix-cli-console-port-mismatch.md | 2 +- packages/opencode/src/cli/network.ts | 32 ++++- .../opencode/src/kilocode/cli/cmd/console.ts | 31 +--- .../opencode/src/kilocode/cli/cmd/daemon.ts | 13 +- .../opencode/src/kilocode/daemon/daemon.ts | 66 +++++++-- .../test/kilocode/cli/cmd/console.test.ts | 135 +++++++++++++++--- 6 files changed, 212 insertions(+), 67 deletions(-) diff --git a/.changeset/fix-cli-console-port-mismatch.md b/.changeset/fix-cli-console-port-mismatch.md index 8d6b32927195..07eb5f1500dd 100644 --- a/.changeset/fix-cli-console-port-mismatch.md +++ b/.changeset/fix-cli-console-port-mismatch.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Restart the daemon when `kilo console` is invoked with an explicit `--port` or `--hostname` that doesn't match the already-running daemon, instead of silently reusing the old connection. +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. diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 31495f4cc722..c3d1424226bc 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -33,6 +33,27 @@ const options = { export type NetworkOptions = InferredOptionTypes +// 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(yargs: Argv) { 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) diff --git a/packages/opencode/src/kilocode/cli/cmd/console.ts b/packages/opencode/src/kilocode/cli/cmd/console.ts index fd0556963610..c3da1a385749 100644 --- a/packages/opencode/src/kilocode/cli/cmd/console.ts +++ b/packages/opencode/src/kilocode/cli/cmd/console.ts @@ -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" @@ -36,29 +36,6 @@ async function launch(url: string) { }) } -function explicitNetworkOption(name: string) { - return process.argv.some((arg) => arg === name || arg.startsWith(`${name}=`)) -} - -export async function startDaemon(opts: Daemon.Options, restartOnMismatch = false) { - const result = await Daemon.start(opts) - let state = result.state - if (!state) throw new Error("Kilo daemon did not provide connection state") - - const portMismatch = opts.port !== 0 ? state.port !== opts.port : true - const hostnameMismatch = state.hostname !== opts.hostname - if (restartOnMismatch && result.reused && (hostnameMismatch || portMismatch)) { - console.warn( - `Daemon running at ${state.hostname}:${state.port}; restarting with requested ${opts.hostname}:${opts.port}...`, - ) - const fresh = await Daemon.restart(opts) - state = fresh.state - if (!state) throw new Error("Kilo daemon did not provide connection state") - } - - return state -} - export const KiloConsoleCommand = cmd({ command: "console", describe: "open the local Kilo Console", @@ -66,8 +43,10 @@ export const KiloConsoleCommand = cmd({ handler: async (args) => { const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) warnPort(opts.port) - const restartOnMismatch = explicitNetworkOption("--port") || explicitNetworkOption("--hostname") - const state = await startDaemon(opts, restartOnMismatch) + 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) await launch(browserUrl(state)).catch((err) => { diff --git a/packages/opencode/src/kilocode/cli/cmd/daemon.ts b/packages/opencode/src/kilocode/cli/cmd/daemon.ts index 4561397c2477..70bde2822fcf 100644 --- a/packages/opencode/src/kilocode/cli/cmd/daemon.ts +++ b/packages/opencode/src/kilocode/cli/cmd/daemon.ts @@ -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) }, }) diff --git a/packages/opencode/src/kilocode/daemon/daemon.ts b/packages/opencode/src/kilocode/daemon/daemon.ts index 6bd0a60a3f14..c37ea30e50b4 100644 --- a/packages/opencode/src/kilocode/daemon/daemon.ts +++ b/packages/opencode/src/kilocode/daemon/daemon.ts @@ -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 + 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 @@ -44,12 +55,7 @@ export namespace Daemon { }) export type Status = z.infer - 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 { + function compatible(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 { 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 || !compatible(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 { + return (await run(input)).result + } + + export async function ensure(input: Options, explicit: readonly NetworkOption[]): Promise { + return await run(input, explicit) + } + export async function stop(): Promise { return await Flock.withLock( lock, @@ -209,8 +247,7 @@ export namespace Daemon { } export async function restart(input: Options): Promise { - await stop() - return await start(input) + return (await run(input, [], true)).result } export function command( @@ -289,7 +326,10 @@ export namespace Daemon { }) const failure = new Promise((_, 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()]) } diff --git a/packages/opencode/test/kilocode/cli/cmd/console.test.ts b/packages/opencode/test/kilocode/cli/cmd/console.test.ts index 6054a30d96df..0b8493c0e8c4 100644 --- a/packages/opencode/test/kilocode/cli/cmd/console.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/console.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" +import { createServer } from "net" import path from "path" -import { startDaemon } from "../../../../src/kilocode/cli/cmd/console" +import { explicitNetworkOptions } from "../../../../src/cli/network" import { Daemon } from "../../../../src/kilocode/daemon/daemon" import { tmpdir } from "../../../fixture/fixture" @@ -35,6 +36,14 @@ function dirs(root: string) { } } +function temp() { + return tmpdir({ + dispose: async () => { + await Daemon.stop().catch(() => undefined) + }, + }) +} + function opts(root: string): Daemon.Options { return { hostname: "127.0.0.1", @@ -48,34 +57,120 @@ function opts(root: string): Daemon.Options { } } +async function port() { + const server = createServer() + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + const address = server.address() + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))) + if (!address || typeof address === "string") throw new Error("Test server did not provide a port") + return address.port +} + describe("console daemon startup", () => { - test("reuses a daemon when the requested host and port match", async () => { - await using tmp = await tmpdir() - const env = opts(tmp.path) + 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([]) + }) - const first = await startDaemon(env) - const reused = await startDaemon({ ...env, port: first.port }) + test("reuses a daemon when explicit options match", async () => { + await using tmp = await temp() + const input = opts(tmp.path) + const first = await Daemon.start(input) + const state = first.state + if (!state) throw new Error("Daemon did not provide connection state") - expect(reused.pid).toBe(first.pid) + const daemon = await Daemon.ensure({ ...input, port: state.port }, [ + "port", + "hostname", + "mdns", + "mdnsDomain", + "cors", + ]) + + expect(daemon.restarted).toBe(false) + expect(daemon.result.state?.pid).toBe(state.pid) }, 20_000) - test("does not restart when the requested port is the default auto port", async () => { - await using tmp = await tmpdir() - const env = opts(tmp.path) + test("treats an explicit auto port as compatible", async () => { + await using tmp = await temp() + const input = opts(tmp.path) + const first = await Daemon.start(input) - const first = await startDaemon(env) - const reused = await startDaemon({ ...env, port: 0 }) + const daemon = await Daemon.ensure(input, ["port"]) - expect(reused.pid).toBe(first.pid) + expect(daemon.restarted).toBe(false) + expect(daemon.result.state?.pid).toBe(first.state?.pid) }, 20_000) - test("restarts a reused daemon when the requested port differs", async () => { - await using tmp = await tmpdir() - const env = opts(tmp.path) - const first = await startDaemon(env) + test("supports daemon state written before network options were persisted", async () => { + await using tmp = await temp() + const input = opts(tmp.path) + const first = await Daemon.start(input) + const state = first.state + if (!state) throw new Error("Daemon did not provide connection state") + await Bun.write(Daemon.file(), JSON.stringify({ ...state, options: undefined })) - const restarted = await startDaemon({ ...env, port: 0 }, true) + expect((await Daemon.read())?.options).toBeUndefined() + const matched = await Daemon.ensure({ ...input, port: state.port }, ["hostname", "port"]) + expect(matched.restarted).toBe(false) + expect(matched.result.state?.pid).toBe(state.pid) - expect(restarted.pid).not.toBe(first.pid) - }, 20_000) + const unknown = await Daemon.ensure(input, ["mdns"]) + expect(unknown.restarted).toBe(true) + expect(unknown.result.state?.pid).not.toBe(state.pid) + }, 30_000) + + test("restarts for each mismatched explicit network option", async () => { + await using tmp = await temp() + const initial = opts(tmp.path) + const first = await Daemon.start(initial) + const state = first.state + if (!state) throw new Error("Daemon did not provide connection state") + + const host = { ...initial, hostname: "0.0.0.0" } + const byHost = await Daemon.ensure(host, ["hostname"]) + expect(byHost.restarted).toBe(true) + expect(byHost.result.state?.pid).not.toBe(state.pid) + + const mdns = { ...host, mdns: true } + const byMdns = await Daemon.ensure(mdns, ["mdns"]) + expect(byMdns.restarted).toBe(true) + expect(byMdns.result.state?.pid).not.toBe(byHost.result.state?.pid) + + const loopback = await Daemon.restart({ ...mdns, hostname: "127.0.0.1" }) + const byMdnsHost = await Daemon.ensure(mdns, ["mdns"]) + expect(byMdnsHost.restarted).toBe(true) + expect(byMdnsHost.result.state?.pid).not.toBe(loopback.state?.pid) + + const domain = { ...mdns, mdnsDomain: "test.local" } + const byDomain = await Daemon.ensure(domain, ["mdnsDomain"]) + expect(byDomain.restarted).toBe(true) + expect(byDomain.result.state?.pid).not.toBe(byMdnsHost.result.state?.pid) + + const cors = { ...domain, cors: ["https://example.com"] } + const byCors = await Daemon.ensure(cors, ["cors"]) + expect(byCors.restarted).toBe(true) + expect(byCors.result.state?.pid).not.toBe(byDomain.result.state?.pid) + + const fixed = { ...cors, port: await port() } + const byPort = await Daemon.ensure(fixed, ["port"]) + expect(byPort.restarted).toBe(true) + expect(byPort.result.state?.pid).not.toBe(byCors.result.state?.pid) + expect(byPort.result.state?.port).toBe(fixed.port) + }, 60_000) }) From d8671cc393228a1bd73a31b80159e1bc22ea6257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 12 Jun 2026 11:45:37 -0300 Subject: [PATCH 5/7] refactor: improve flaky test --- .../opencode/src/kilocode/daemon/daemon.ts | 4 ++-- .../test/kilocode/cli/cmd/console.test.ts | 20 +------------------ 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/kilocode/daemon/daemon.ts b/packages/opencode/src/kilocode/daemon/daemon.ts index c37ea30e50b4..e01e8df2effa 100644 --- a/packages/opencode/src/kilocode/daemon/daemon.ts +++ b/packages/opencode/src/kilocode/daemon/daemon.ts @@ -168,7 +168,7 @@ export namespace Daemon { return { running: true, stale: false, state, health: probe, file: file() } } - function compatible(state: State, input: Options, explicit: readonly NetworkOption[]) { + 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 @@ -185,7 +185,7 @@ export namespace Daemon { lock, async () => { const current = await status() - const restarted = current.running && !!current.state && (force || !compatible(current.state, input, explicit)) + 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 } } diff --git a/packages/opencode/test/kilocode/cli/cmd/console.test.ts b/packages/opencode/test/kilocode/cli/cmd/console.test.ts index 0b8493c0e8c4..d25f100da229 100644 --- a/packages/opencode/test/kilocode/cli/cmd/console.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/console.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test" -import { createServer } from "net" import path from "path" import { explicitNetworkOptions } from "../../../../src/cli/network" import { Daemon } from "../../../../src/kilocode/daemon/daemon" @@ -57,18 +56,6 @@ function opts(root: string): Daemon.Options { } } -async function port() { - const server = createServer() - await new Promise((resolve, reject) => { - server.once("error", reject) - server.listen(0, "127.0.0.1", resolve) - }) - const address = server.address() - await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))) - if (!address || typeof address === "string") throw new Error("Test server did not provide a port") - return address.port -} - describe("console daemon startup", () => { test("detects every explicit network option form", () => { expect( @@ -102,6 +89,7 @@ describe("console daemon startup", () => { "cors", ]) + expect(Daemon.matches(state, { ...input, port: state.port + 1 }, ["port"])).toBe(false) expect(daemon.restarted).toBe(false) expect(daemon.result.state?.pid).toBe(state.pid) }, 20_000) @@ -166,11 +154,5 @@ describe("console daemon startup", () => { const byCors = await Daemon.ensure(cors, ["cors"]) expect(byCors.restarted).toBe(true) expect(byCors.result.state?.pid).not.toBe(byDomain.result.state?.pid) - - const fixed = { ...cors, port: await port() } - const byPort = await Daemon.ensure(fixed, ["port"]) - expect(byPort.restarted).toBe(true) - expect(byPort.result.state?.pid).not.toBe(byCors.result.state?.pid) - expect(byPort.result.state?.port).toBe(fixed.port) }, 60_000) }) From 680f50581ac53318d491d79d10bb2ee9bf9799fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 12 Jun 2026 12:23:11 -0300 Subject: [PATCH 6/7] refactor: fix test --- packages/opencode/src/kilocode/daemon/daemon.ts | 1 + packages/opencode/test/kilocode/cli/cmd/console.test.ts | 1 + packages/opencode/test/kilocode/daemon.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/daemon/daemon.ts b/packages/opencode/src/kilocode/daemon/daemon.ts index e01e8df2effa..db20c5a102f4 100644 --- a/packages/opencode/src/kilocode/daemon/daemon.ts +++ b/packages/opencode/src/kilocode/daemon/daemon.ts @@ -285,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) => diff --git a/packages/opencode/test/kilocode/cli/cmd/console.test.ts b/packages/opencode/test/kilocode/cli/cmd/console.test.ts index d25f100da229..1ddb15bc2c93 100644 --- a/packages/opencode/test/kilocode/cli/cmd/console.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/console.test.ts @@ -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", } } diff --git a/packages/opencode/test/kilocode/daemon.test.ts b/packages/opencode/test/kilocode/daemon.test.ts index bb53beebd03f..e474a9b66e5b 100644 --- a/packages/opencode/test/kilocode/daemon.test.ts +++ b/packages/opencode/test/kilocode/daemon.test.ts @@ -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) From e2b56eb92538456ba34dacaf6329be182a8e88ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Fri, 12 Jun 2026 13:12:24 -0300 Subject: [PATCH 7/7] refactor: fix test --- .../test/kilocode/cli/cmd/console.test.ts | 172 +++++------------- 1 file changed, 50 insertions(+), 122 deletions(-) diff --git a/packages/opencode/test/kilocode/cli/cmd/console.test.ts b/packages/opencode/test/kilocode/cli/cmd/console.test.ts index 1ddb15bc2c93..533b26378e35 100644 --- a/packages/opencode/test/kilocode/cli/cmd/console.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/console.test.ts @@ -1,62 +1,35 @@ -import { afterEach, describe, expect, test } from "bun:test" -import path from "path" +import { describe, expect, test } from "bun:test" import { explicitNetworkOptions } from "../../../../src/cli/network" import { Daemon } from "../../../../src/kilocode/daemon/daemon" -import { tmpdir } from "../../../fixture/fixture" -const original = { - state: process.env.KILO_TEST_DAEMON_STATE_DIR, - log: process.env.KILO_TEST_DAEMON_LOG_DIR, - disabled: process.env.KILO_NO_DAEMON, -} - -afterEach(async () => { - await Daemon.stop().catch(() => undefined) - restore() -}) - -function restore() { - process.env.KILO_TEST_DAEMON_STATE_DIR = original.state - if (original.state === undefined) delete process.env.KILO_TEST_DAEMON_STATE_DIR - process.env.KILO_TEST_DAEMON_LOG_DIR = original.log - if (original.log === undefined) delete process.env.KILO_TEST_DAEMON_LOG_DIR - process.env.KILO_NO_DAEMON = original.disabled - if (original.disabled === undefined) delete process.env.KILO_NO_DAEMON -} - -function dirs(root: string) { - process.env.KILO_TEST_DAEMON_STATE_DIR = path.join(root, "state") - process.env.KILO_TEST_DAEMON_LOG_DIR = path.join(root, "log") - return { - XDG_DATA_HOME: path.join(root, "xdg-data"), - 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", - } -} - -function temp() { - return tmpdir({ - dispose: async () => { - await Daemon.stop().catch(() => undefined) - }, - }) -} - -function opts(root: string): Daemon.Options { +function opts(input: Partial = {}): Daemon.Options { return { hostname: "127.0.0.1", - port: 0, + port: 4097, mdns: false, mdnsDomain: "kilo.local", cors: [], - command: [process.execPath, "--conditions=browser", path.join(process.cwd(), "src/index.ts")], - env: dirs(root), - timeout: 20_000, + ...input, } } +function state(input: Partial = {}) { + 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( @@ -75,85 +48,40 @@ describe("console daemon startup", () => { expect(explicitNetworkOptions(["kilo", "console", "--", "--port=4321"])).toStrictEqual([]) }) - test("reuses a daemon when explicit options match", async () => { - await using tmp = await temp() - const input = opts(tmp.path) - const first = await Daemon.start(input) - const state = first.state - if (!state) throw new Error("Daemon did not provide connection state") - - const daemon = await Daemon.ensure({ ...input, port: state.port }, [ - "port", - "hostname", - "mdns", - "mdnsDomain", - "cors", - ]) - - expect(Daemon.matches(state, { ...input, port: state.port + 1 }, ["port"])).toBe(false) - expect(daemon.restarted).toBe(false) - expect(daemon.result.state?.pid).toBe(state.pid) - }, 20_000) - - test("treats an explicit auto port as compatible", async () => { - await using tmp = await temp() - const input = opts(tmp.path) - const first = await Daemon.start(input) - - const daemon = await Daemon.ensure(input, ["port"]) - - expect(daemon.restarted).toBe(false) - expect(daemon.result.state?.pid).toBe(first.state?.pid) - }, 20_000) + 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"], + }) - test("supports daemon state written before network options were persisted", async () => { - await using tmp = await temp() - const input = opts(tmp.path) - const first = await Daemon.start(input) - const state = first.state - if (!state) throw new Error("Daemon did not provide connection state") - await Bun.write(Daemon.file(), JSON.stringify({ ...state, options: undefined })) - - expect((await Daemon.read())?.options).toBeUndefined() - const matched = await Daemon.ensure({ ...input, port: state.port }, ["hostname", "port"]) - expect(matched.restarted).toBe(false) - expect(matched.result.state?.pid).toBe(state.pid) - - const unknown = await Daemon.ensure(input, ["mdns"]) - expect(unknown.restarted).toBe(true) - expect(unknown.result.state?.pid).not.toBe(state.pid) - }, 30_000) + expect(Daemon.matches(current, input, ["port", "hostname", "mdns", "mdnsDomain", "cors"])).toBe(true) + }) - test("restarts for each mismatched explicit network option", async () => { - await using tmp = await temp() - const initial = opts(tmp.path) - const first = await Daemon.start(initial) - const state = first.state - if (!state) throw new Error("Daemon did not provide connection state") + test("treats an explicit auto port as compatible", () => { + expect(Daemon.matches(state(), opts({ port: 0 }), ["port"])).toBe(true) + }) - const host = { ...initial, hostname: "0.0.0.0" } - const byHost = await Daemon.ensure(host, ["hostname"]) - expect(byHost.restarted).toBe(true) - expect(byHost.result.state?.pid).not.toBe(state.pid) + test("supports daemon state written before network options were persisted", () => { + const current = { ...state(), options: undefined } - const mdns = { ...host, mdns: true } - const byMdns = await Daemon.ensure(mdns, ["mdns"]) - expect(byMdns.restarted).toBe(true) - expect(byMdns.result.state?.pid).not.toBe(byHost.result.state?.pid) + 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) + }) - const loopback = await Daemon.restart({ ...mdns, hostname: "127.0.0.1" }) - const byMdnsHost = await Daemon.ensure(mdns, ["mdns"]) - expect(byMdnsHost.restarted).toBe(true) - expect(byMdnsHost.result.state?.pid).not.toBe(loopback.state?.pid) + test("rejects each mismatched explicit network option", () => { + const current = state() - const domain = { ...mdns, mdnsDomain: "test.local" } - const byDomain = await Daemon.ensure(domain, ["mdnsDomain"]) - expect(byDomain.restarted).toBe(true) - expect(byDomain.result.state?.pid).not.toBe(byMdnsHost.result.state?.pid) + 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 cors = { ...domain, cors: ["https://example.com"] } - const byCors = await Daemon.ensure(cors, ["cors"]) - expect(byCors.restarted).toBe(true) - expect(byCors.result.state?.pid).not.toBe(byDomain.result.state?.pid) - }, 60_000) + const mdns = state({ mdns: true }) + expect(Daemon.matches(mdns, opts({ hostname: "0.0.0.0", mdns: true }), ["mdns"])).toBe(false) + }) })