Merge pull request #11478 from Kilo-Org/talented-yttrium

feat(cli): improve daemon stop workflow
This commit is contained in:
Catriel Müller
2026-06-20 09:33:27 -03:00
committed by GitHub
9 changed files with 427 additions and 89 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Support stopping the daemon with `kilo console stop` and keeping console or daemon commands attached with `--foreground`
+1 -1
View File
@@ -25,7 +25,7 @@
| `kilo session` | manage sessions |
| `kilo remote` | enable remote connection for real-time session relay |
| `kilo daemon` | manage the local kilo daemon |
| `kilo console` | open the local Kilo Console |
| `kilo console` | open or stop the local Kilo Console |
| `kilo db` | database tools |
| `kilo config` | configuration tools |
| `kilo plugin <module>` | install plugin and update config |
@@ -891,14 +891,22 @@ Options:
manage the local kilo daemon
Commands:
kilo daemon start the local kilo daemon [default]
kilo daemon start start the local kilo daemon
kilo daemon status show local kilo daemon status
kilo daemon stop stop the local kilo daemon
kilo daemon restart restart the local kilo daemon
Options:
--help Show help [boolean]
--version Show version number [boolean]
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--json print daemon details as JSON [boolean]
-f, --foreground keep the command active until interrupted [boolean]
```
### kilo daemon start
@@ -907,14 +915,15 @@ Options:
start the local kilo daemon
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--json print daemon details as JSON [boolean]
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--json print daemon details as JSON [boolean]
-f, --foreground keep the command active until interrupted [boolean]
```
### kilo daemon status
@@ -945,29 +954,46 @@ Options:
restart the local kilo daemon
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--json print daemon details as JSON [boolean]
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--json print daemon details as JSON [boolean]
-f, --foreground keep the command active until interrupted [boolean]
```
## kilo console
```
open the local Kilo Console
open or stop the local Kilo Console
Commands:
kilo console open the local Kilo Console [default]
kilo console stop stop the local kilo daemon
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
-f, --foreground keep the command active until interrupted [boolean]
```
### kilo console stop
```
stop the local kilo daemon
Options:
--help Show help [boolean]
--version Show version number [boolean]
--json print daemon details as JSON [boolean]
```
## kilo db
@@ -177,7 +177,7 @@ Remote HTTP proxy responses can include sync fence metadata. Router waits for ma
## Daemon lifecycle
`kilo daemon start|status|stop|restart` manage detached local `kilo serve` child. `kilo console` calls same start path, so it reuses healthy daemon instead of spawning second process.
`kilo daemon start|status|stop|restart` manage a detached local `kilo serve` child, with bare `kilo daemon` equivalent to `kilo daemon start`. `kilo console` calls the same start path, so it reuses a healthy daemon instead of spawning a second process, while `kilo console stop` aliases `kilo daemon stop`.
| Area | Behavior |
|---|---|
@@ -185,6 +185,7 @@ Remote HTTP proxy responses can include sync fence metadata. Router waits for ma
| Log file | `${Global.Path.log}/daemon.log`, created with mode `0600` |
| Port allocation | For `--port 0`, scans `4097..4116` and chooses available port |
| Child process | Detached `kilo serve --hostname <host> --port <port>` process |
| Foreground mode | `--foreground` / `-f` keeps the invoking command attached; SIGINT, SIGTERM, or SIGHUP stops only the daemon identity it started or reused |
| Health | Probes authenticated `/global/health` with 2 second timeout |
| Reuse | Reuses daemon only when process is alive, health succeeds, and installed version matches |
| Cleanup | Terminates stale process when present, clears stale state, then starts replacement |
@@ -1,4 +1,5 @@
import open from "open"
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"
@@ -6,6 +7,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Daemon } from "@/kilocode/daemon/daemon"
import { warnPort } from "@/kilocode/cli/port-warning"
import { hasDisplay } from "@/kilocode/cli/cmd/tui/util/display"
import { StopCommand } from "@/kilocode/cli/cmd/daemon"
function browserUrl(state: Daemon.State) {
const url = new URL("/console", state.url)
@@ -34,31 +36,56 @@ async function launch(url: string) {
})
}
export const KiloConsoleCommand = cmd({
command: "console",
const OpenCommand = cmd({
command: "$0",
describe: "open the local Kilo Console",
builder: (yargs) => withNetworkOptions(yargs),
builder: (yargs) =>
withNetworkOptions(yargs).option("foreground", {
alias: "f",
describe: "keep the command active until interrupted",
type: "boolean",
}),
handler: async (args) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
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 run = async (signal?: AbortSignal) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
const state = daemon.result.state
if (!state) throw new Error("Kilo daemon did not provide connection state")
if (signal?.aborted) return state
if (daemon.restarted) console.warn("Restarted the Kilo daemon to apply the requested network options")
const urls = state.urls ?? serverUrls(state.hostname, state.port)
const consoleLocal = `${urls.local}/console`
const consoleNetwork = urls.network ? `${urls.network}/console` : undefined
const urls = state.urls ?? serverUrls(state.hostname, state.port)
const consoleLocal = `${urls.local}/console`
const consoleNetwork = urls.network ? `${urls.network}/console` : undefined
if (hasDisplay()) {
await launch(browserUrl(state)).catch((err) => {
console.warn(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`)
})
} else {
console.warn("No display detected; open the Kilo Console URL manually")
if (hasDisplay()) {
await launch(browserUrl(state)).catch((err) => {
console.warn(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`)
})
} else {
console.warn("No display detected; open the Kilo Console URL manually")
}
console.log("Kilo Console:")
console.log(` Local: ${consoleLocal}`)
if (consoleNetwork) console.log(` Network: ${consoleNetwork}`)
return state
}
console.log("Kilo Console:")
console.log(` Local: ${consoleLocal}`)
if (consoleNetwork) console.log(` Network: ${consoleNetwork}`)
if (!args.foreground) {
await run()
return
}
await Daemon.foreground(async (signal) => {
const state = await run(signal)
if (!signal.aborted) console.log("Press Ctrl+C to stop the Kilo daemon.")
return state
})
},
})
export const KiloConsoleCommand = cmd({
command: "console",
describe: "open or stop the local Kilo Console",
builder: (yargs: Argv) => yargs.command(OpenCommand).command(StopCommand).demandCommand(),
handler: async () => {},
})
@@ -1,7 +1,6 @@
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"
@@ -13,6 +12,14 @@ function withJson<T>(yargs: Argv<T>) {
})
}
function withForeground<T>(yargs: Argv<T>) {
return yargs.option("foreground", {
alias: "f",
describe: "keep the command active until interrupted",
type: "boolean",
})
}
function safe(input: Daemon.State | undefined) {
if (!input) return undefined
return {
@@ -63,29 +70,51 @@ function print(input: Daemon.Status, json?: boolean) {
console.log(`log: ${input.state?.log}`)
}
const StartCommand = cmd({
command: "start",
describe: "start the local kilo daemon",
builder: (yargs) => withJson(withNetworkOptions(yargs)),
handler: async (args) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
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"
: daemon.restarted
? "kilo daemon restarted"
: "kilo daemon started",
)
print(result)
},
})
async function hold(enabled: boolean, json: boolean, run: (signal?: AbortSignal) => Promise<Daemon.State>) {
if (!enabled) {
await run()
return
}
await Daemon.foreground(async (signal) => {
const state = await run(signal)
if (!signal.aborted && !json) console.log("Press Ctrl+C to stop the Kilo daemon.")
return state
})
}
function start(command: string) {
return cmd({
command,
describe: "start the local kilo daemon",
builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))),
handler: async (args) => {
await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
const result = daemon.result
const state = result.state
if (!state) throw new Error("Kilo daemon did not provide process state")
if (signal?.aborted) return state
if (args.json) print(result, true)
if (!args.json) {
console.log(
result.reused
? "kilo daemon already running"
: daemon.restarted
? "kilo daemon restarted"
: "kilo daemon started",
)
print(result)
}
return state
})
},
})
}
const DefaultCommand = start("$0")
const StartCommand = start("start")
const StatusCommand = cmd({
command: "status",
@@ -96,7 +125,7 @@ const StatusCommand = cmd({
},
})
const StopCommand = cmd({
export const StopCommand = cmd({
command: "stop",
describe: "stop the local kilo daemon",
builder: (yargs) => withJson(yargs),
@@ -113,17 +142,22 @@ const StopCommand = cmd({
const RestartCommand = cmd({
command: "restart",
describe: "restart the local kilo daemon",
builder: (yargs) => withJson(withNetworkOptions(yargs)),
builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))),
handler: async (args) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
const result = await Daemon.restart(opts)
if (args.json) {
print(result, true)
return
}
console.log("kilo daemon restarted")
print(result)
await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => {
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
warnPort(opts.port)
const result = await Daemon.restart(opts)
const state = result.state
if (!state) throw new Error("Kilo daemon did not provide process state")
if (signal?.aborted) return state
if (args.json) print(result, true)
if (!args.json) {
console.log("kilo daemon restarted")
print(result)
}
return state
})
},
})
@@ -131,6 +165,12 @@ export const DaemonCommand = cmd({
command: "daemon",
describe: "manage the local kilo daemon",
builder: (yargs: Argv) =>
yargs.command(StartCommand).command(StatusCommand).command(StopCommand).command(RestartCommand).demandCommand(),
yargs
.command(DefaultCommand)
.command(StartCommand)
.command(StatusCommand)
.command(StopCommand)
.command(RestartCommand)
.demandCommand(),
handler: async () => {},
})
@@ -83,6 +83,8 @@ export namespace Daemon {
stopped: boolean
}
export type Identity = Pick<State, "pid" | "startedAt">
function root() {
return process.env.KILO_TEST_DAEMON_STATE_DIR ?? Global.Path.state
}
@@ -238,12 +240,12 @@ export namespace Daemon {
return await run(input, explicit)
}
export async function stop(): Promise<Stop> {
export async function stop(expected?: Identity): Promise<Stop> {
return await Flock.withLock(
lock,
async () => {
const current = await status()
if (!current.state) return { ...current, stopped: false }
if (!current.state || (expected && !same(current.state, expected))) return { ...current, stopped: false }
if (alive(current.state.pid)) {
await terminate(current.state.pid, false)
if (alive(current.state.pid)) await terminate(current.state.pid, true)
@@ -255,6 +257,31 @@ export namespace Daemon {
)
}
export async function foreground(start: (signal: AbortSignal) => Promise<Identity>) {
const ctl = new AbortController()
const interrupt = new AbortController()
const done = Promise.withResolvers<"signal">()
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const
const quit = () => {
interrupt.abort()
done.resolve("signal")
}
for (const signal of signals) process.once(signal, quit)
try {
const expected = await start(interrupt.signal)
if (interrupt.signal.aborted) {
await stop(expected)
return
}
const result = await Promise.race([done.promise, watch(expected, ctl.signal)])
if (result === "signal") await stop(expected)
} finally {
ctl.abort()
for (const signal of signals) process.off(signal, quit)
}
}
export async function restart(input: Options): Promise<Start> {
return (await run(input, [], true)).result
}
@@ -380,8 +407,36 @@ export namespace Daemon {
return { hostname: match[1], port: Number(match[2]) }
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
function same(state: State, expected: Identity) {
return state.pid === expected.pid && state.startedAt === expected.startedAt
}
async function watch(expected: Identity, signal: AbortSignal): Promise<"daemon"> {
while (!signal.aborted) {
const state = await read()
if (!state || !same(state, expected) || !alive(expected.pid)) return "daemon"
await sleep(250, signal)
}
return "daemon"
}
function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve) => {
const done = () => {
signal?.removeEventListener("abort", cancel)
resolve()
}
const timer = setTimeout(done, ms)
const cancel = () => {
clearTimeout(timer)
done()
}
if (signal?.aborted) {
cancel()
return
}
signal?.addEventListener("abort", cancel, { once: true })
})
}
async function terminate(pid: number, force: boolean) {
@@ -49,6 +49,50 @@ function opts(root: string): Daemon.Options {
}
}
function cli(args: string[], env: NodeJS.ProcessEnv = {}) {
return Bun.spawn([process.execPath, "--conditions=browser", path.join(process.cwd(), "src/index.ts"), ...args], {
cwd: process.cwd(),
env: {
...process.env,
...env,
KILO_CONFIG_CONTENT: '{"experimental":{"openTelemetry":false}}',
KILO_DISABLE_PROJECT_CONFIG: "1",
KILO_DISABLE_AUTOUPDATE: "1",
KILO_DISABLE_MODELS_FETCH: "1",
KILO_AUTH_CONTENT: "{}",
KILO_PURE: "1",
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
}
function capture(stream: ReadableStream<Uint8Array>, match: string) {
const ready = Promise.withResolvers<void>()
const text = (async () => {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
while (true) {
const part = await reader.read()
if (part.done) break
chunks.push(decoder.decode(part.value, { stream: true }))
if (chunks.join("").includes(match)) ready.resolve()
}
chunks.push(decoder.decode())
return chunks.join("")
})()
return { ready: ready.promise, text }
}
async function deadline<T>(promise: Promise<T>, timeout: number) {
const expired = Symbol("expired")
const result = await Promise.race([promise, Bun.sleep(timeout).then(() => expired)])
if (result === expired) throw new Error(`Timed out after ${timeout}ms`)
return result
}
describe("daemon manager", () => {
test("reports not running without daemon state", async () => {
await using tmp = await tmpdir()
@@ -156,6 +200,128 @@ describe("daemon manager", () => {
expect(restarted.status).toBe(200)
}, 20_000)
test("does not let a foreground owner stop a replacement daemon", async () => {
await using tmp = await tmpdir()
const input = opts(tmp.path)
const first = await Daemon.start(input)
const state = first.state
if (!state) throw new Error("Daemon did not provide process state")
const waiting = Daemon.foreground(async () => state)
const second = await Daemon.restart(input)
await deadline(waiting, 5_000)
const stopped = await Daemon.stop(state)
const current = await Daemon.status()
expect(stopped.stopped).toBe(false)
expect(current.running).toBe(true)
expect(current.state?.pid).toBe(second.state?.pid)
expect(current.state?.pid).not.toBe(state.pid)
}, 30_000)
test.skipIf(process.platform === "win32")(
"records foreground interrupts while startup is pending",
async () => {
await using tmp = await tmpdir()
const input = opts(tmp.path)
await Daemon.start(input)
const ready = path.join(tmp.path, "foreground-ready")
const release = path.join(tmp.path, "foreground-release")
const source = path.join(process.cwd(), "src/kilocode/daemon/daemon.ts")
const script = `
import { Daemon } from ${JSON.stringify(source)}
await Daemon.foreground(async () => {
await Bun.write(${JSON.stringify(ready)}, "ready")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
const state = await Daemon.read()
if (!state) throw new Error("Daemon did not provide process state")
return state
})
`
const proc = Bun.spawn([process.execPath, "--conditions=browser", "-e", script], {
cwd: process.cwd(),
env: { ...process.env, ...input.env },
stdout: "pipe",
stderr: "pipe",
})
const stdout = new Response(proc.stdout).text()
const stderr = new Response(proc.stderr).text()
try {
await deadline(
(async () => {
while (!(await Bun.file(ready).exists())) await Bun.sleep(10)
})(),
5_000,
)
proc.kill("SIGINT")
await Bun.write(release, "release")
expect(await deadline(proc.exited, 10_000)).toBe(0)
expect((await Daemon.status()).running).toBe(false)
await Promise.all([stdout, stderr])
} finally {
if (proc.exitCode === null) proc.kill("SIGKILL")
await proc.exited
await Daemon.stop()
}
},
25_000,
)
test("supports console stop as a daemon stop alias", async () => {
await using tmp = await tmpdir()
const input = opts(tmp.path)
await Daemon.start(input)
const proc = cli(["console", "stop"], input.env)
const [code, stdout, stderr] = await Promise.all([
deadline(proc.exited, 20_000),
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
expect(code).toBe(0)
expect(stdout).toContain("kilo daemon stopped")
expect(stderr).not.toContain("Could not open browser automatically")
expect((await Daemon.status()).running).toBe(false)
}, 30_000)
test.skipIf(process.platform === "win32")(
"stops a foreground daemon on SIGINT",
async () => {
await using tmp = await tmpdir()
const env = dirs(tmp.path)
const proc = cli(["daemon", "-f", "--port", "0"], env)
const stdout = capture(proc.stdout, "Press Ctrl+C to stop the Kilo daemon.")
const stderr = new Response(proc.stderr).text()
try {
await deadline(
Promise.race([
stdout.ready,
stdout.text.then(() => {
throw new Error("Foreground daemon exited before becoming ready")
}),
]),
20_000,
)
const state = await Daemon.status()
expect(state.running).toBe(true)
expect(proc.exitCode).toBeNull()
proc.kill("SIGINT")
expect(await deadline(proc.exited, 10_000)).toBe(0)
expect((await Daemon.status()).running).toBe(false)
expect(await stdout.text).toContain("kilo daemon started")
await stderr
} finally {
if (proc.exitCode === null) proc.kill("SIGKILL")
await proc.exited
await Daemon.stop()
}
},
35_000,
)
test("daemon client does not start a daemon while attaching", async () => {
await using tmp = await tmpdir()
dirs(tmp.path)
@@ -24,6 +24,8 @@ import { PluginCommand } from "../../src/cli/cmd/plug"
import { DbCommand } from "../../src/cli/cmd/db"
import { HelpCommand } from "../../src/kilocode/help-command"
import { ProfileCommand } from "../../src/kilocode/cli/cmd/profile"
import { DaemonCommand } from "../../src/kilocode/cli/cmd/daemon"
import { KiloConsoleCommand } from "../../src/kilocode/cli/cmd/console"
// Stand-in for TuiThreadCommand — the real one imports @opentui/solid which
// doesn't resolve in the test environment. Only command/describe matter here.
@@ -72,6 +74,8 @@ const commands = [
ConfigCLICommand,
PluginCommand,
ProfileCommand,
DaemonCommand,
KiloConsoleCommand,
HelpCommand,
CompletionStub,
] as any[]
@@ -120,6 +124,20 @@ describe("kilo help <command>", () => {
expect(output).not.toContain("## kilo run")
expect(output).not.toContain("## kilo debug")
})
test("documents console stop and foreground mode", async () => {
const output = await generateHelp({ command: "console", format: "md", commands })
expect(output).toContain("kilo console stop")
expect(output).toContain("--foreground")
expect(output).toContain("-f")
})
test("documents daemon foreground mode", async () => {
const output = await generateHelp({ command: "daemon", format: "md", commands })
expect(output).toContain("kilo daemon start")
expect(output).toContain("--foreground")
expect(output).toContain("-f")
})
})
describe("edge cases", () => {