Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-foreground-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Obtain approval for the required major version bump

For scripts or launchers that invoke kimi web expecting it to return after starting a daemon, this change makes the command block until the foreground server is interrupted, so it is an incompatible behavior-semantic change. The root AGENTS.md explicitly requires such changes to use a major bump only after user confirmation; publishing this as minor would unexpectedly break those callers. Obtain that confirmation and change the bump to major, or preserve the previous default behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged that this is a behavior-semantics change — that is intentional and was explicitly discussed before opening the PR. The maintainers' decision for this release line is to keep the bump at minor while flagging the break prominently instead of withholding the change behind a major: the changeset entry is now prefixed with Breaking and spells out both the new default (foreground until Ctrl+C) and the mitigation for scripts/launchers (--background restores the previous immediate-return daemon behavior). Anyone calling kimi web non-interactively gets a documented, one-flag migration path. @codex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

---

**Breaking**: `kimi web` and the TUI `/web` command now run the server in the foreground by default instead of backgrounding a daemon — the command stays attached to the terminal until Ctrl+C instead of returning immediately. Pass `--background` in scripts or launchers to keep the previous background behavior; `kimi server run` is unchanged. An already-running server is reused as-is across upgrades (a version mismatch is pointed out in the output); run `kimi server kill` once after upgrading to restart on the new version.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ export async function runShell(
}
removeCrashHandlers();
restoreStty();
if (tui.exitForegroundTask !== undefined) {
// `/web` foreground mode: the TUI has shut down cleanly; hand the
// terminal to the foreground server instead of exiting. The task runs
// until the server stops (Ctrl+C), then this process exits.
await tui.exitForegroundTask(exitCode);
return;
}
process.exit(exitCode);
};
try {
Expand Down
46 changes: 31 additions & 15 deletions apps/kimi-code/src/cli/sub/server/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export interface EnsureDaemonResult {
readonly host: string;
/** Port the running daemon is actually listening on (from the lock). */
readonly port: number;
/**
* CLI version that started the reused server, recorded in its lock. Absent
* for freshly-spawned servers (same version as this CLI) and for locks
* written by older builds.
*/
readonly hostVersion?: string;
}

/** Path of the daemon log file (shared with the OS-service log location). */
Expand Down Expand Up @@ -295,6 +301,26 @@ function sleep(ms: number): Promise<void> {
});
}

/**
* Return an already-live, healthy daemon's connection info, or `undefined`
* when no reusable server holds the lock. Used by `ensureDaemon` (step 1) and
* by foreground-mode `kimi web`, which opens the running server instead of
* failing to bind its port.
*/
export async function findReusableDaemon(): Promise<EnsureDaemonResult | undefined> {
const existing = getLiveLock();
if (!existing) return undefined;
const origin = serverOrigin(lockConnectHost(existing), existing.port);
if (!(await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS))) return undefined;
return {
origin,
reused: true,
host: existing.host ?? DEFAULT_SERVER_HOST,
port: existing.port,
hostVersion: existing.host_version,
};
}

/**
* Ensure a daemon is running and return its origin. Non-blocking for the
* caller beyond the short health wait — the server itself keeps running in a
Expand All @@ -306,21 +332,11 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL;

// 1. Reuse an already-live daemon if one holds the lock.
const existing = getLiveLock();
if (existing) {
const origin = serverOrigin(lockConnectHost(existing), existing.port);
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
return {
origin,
reused: true,
host: existing.host ?? DEFAULT_SERVER_HOST,
port: existing.port,
};
}
// Live pid but not responding (wedged or mid-boot failure). Fall through
// and spawn: if it is truly wedged our child loses the lock race and we
// reconnect below; if it died, stale takeover lets our child claim it.
}
const reusable = await findReusableDaemon();
if (reusable) return reusable;
// A live lock pid that is not responding (wedged or mid-boot failure) falls
// through to spawn: if it is truly wedged our child loses the lock race and
// we reconnect below; if it died, stale takeover lets our child claim it.

// 2. No reusable daemon — pick a free port and spawn one detached.
const port = await resolveDaemonPort(host, preferred);
Expand Down
139 changes: 119 additions & 20 deletions apps/kimi-code/src/cli/sub/server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
* terminal attached until SIGINT/SIGTERM. OS-managed background operation
* (launchd / systemd / schtasks) lives in `kimi server install` + `kimi server start`.
*
* `kimi web` is an alias of this command with `--open` defaulted to `true`,
* registered in `./web-alias.ts`.
* `kimi web` is an alias of this command (registered in `./web-alias.ts`) with
* two flipped defaults: `--open` defaults to `true`, and it runs in the
* foreground by default — pass `--background` to get the daemon behavior of
* `kimi server run`.
*/

import { join } from 'node:path';
Expand Down Expand Up @@ -37,7 +39,7 @@ import {
isLoopbackHost,
splitTokenFragment,
} from './access-urls';
import { ensureDaemon, type EnsureDaemonResult } from './daemon';
import { ensureDaemon, findReusableDaemon, type EnsureDaemonResult } from './daemon';
import { type NetworkAddress } from './networks';
import {
DEFAULT_FOREGROUND_LOG_LEVEL,
Expand Down Expand Up @@ -68,6 +70,12 @@ export interface RunCliOptions extends ServerCliOptions {
open?: boolean;
/** Run the server in-process instead of spawning a background daemon. */
foreground?: boolean;
/**
* Run as a background daemon and return once healthy. Only registered on
* `kimi web` (where foreground is the default); `kimi server run` has no
* such flag because background is already its default.
*/
background?: boolean;
}

export interface StartForegroundHooks {
Expand All @@ -84,12 +92,20 @@ export interface RunCommandDeps {
host?: string;
/** Port the running daemon is actually listening on (from the lock). */
port?: number;
/** CLI version that started the reused server (from its lock), if recorded. */
hostVersion?: string;
}>;
/** Foreground runner; defaults to the real in-process runner when omitted. */
startServerForeground?: (
options: ParsedServerOptions,
hooks?: StartForegroundHooks,
) => Promise<never>;
/**
* Probe for an already-live, healthy daemon. Used by foreground-mode
* `kimi web` to reuse a running server instead of failing to bind its port.
* Defaults to the real lock-based probe when omitted.
*/
findReusableDaemon?: () => Promise<EnsureDaemonResult | undefined>;
openUrl(url: string): void;
/**
* Best-effort read of the server's persistent bearer token. When it returns
Expand Down Expand Up @@ -120,8 +136,12 @@ export function buildWebUrl(origin: string, token: string): string {
}

/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */
export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command {
return cmd
export function buildRunCommand(
cmd: Command,
options: { defaultOpen: boolean; defaultForeground?: boolean },
): Command {
const defaultForeground = options.defaultForeground === true;
cmd
.option(
'--port <port>',
`Bind port (default ${DEFAULT_SERVER_PORT})`,
Expand Down Expand Up @@ -171,9 +191,19 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
)
.option(
'--foreground',
'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).',
defaultForeground
? 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (default; pass --background to run as a daemon instead).'
: 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).',
false,
)
);
if (defaultForeground) {
cmd.option(
'--background',
'Run the server as a background daemon and return once it is healthy, releasing this terminal.',
false,
);
}
return cmd
.option(
options.defaultOpen ? '--no-open' : '--open',
options.defaultOpen
Expand All @@ -192,34 +222,50 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
)
.action(async (opts: RunCliOptions) => {
try {
await handleRunCommand(opts);
await handleRunCommand(opts, undefined, { defaultForeground });
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}

export interface RunCommandConfig {
/**
* True for the `kimi web` alias: foreground becomes the default and
* `--background` opts back into the daemon. `kimi server run` leaves this
* false, keeping its background default and plain `--foreground` semantics.
*/
defaultForeground?: boolean;
}

export async function handleRunCommand(
opts: RunCliOptions,
deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS,
config: RunCommandConfig = {},
): Promise<void> {
const parsed = parseServerOptions(opts);
if (parsed.daemon) {
await startServerDaemon(parsed);
return;
}
const foreground =
opts.foreground === true || (config.defaultForeground === true && opts.background !== true);
// Foreground is always keep-alive: a server attached to the operator's
// terminal must never idle-kill itself. Background daemons respect the
// derived `--keep-alive` flag.
const runOptions: ParsedServerOptions =
opts.foreground === true ? { ...parsed, keepAlive: true } : parsed;
const runOptions: ParsedServerOptions = foreground ? { ...parsed, keepAlive: true } : parsed;
// Resolve the persistent token once: it is printed in the ready banner and
// rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to
// the plain origin / no token line when unavailable. When auth is bypassed,
// the token is meaningless and is intentionally NOT shown or carried in the
// opened URL.
const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => {
const writeReady = (result: {
origin: string;
reused?: boolean;
host?: string;
hostVersion?: string;
}): void => {
const { origin } = result;
const host = result.host ?? parsed.host;
// When a daemon is reused, this command's flags were NOT applied to the
Expand All @@ -230,27 +276,52 @@ export async function handleRunCommand(
// mode; we conservatively assume non-bypass on reuse.)
const effectiveBypass = result.reused === true ? false : parsed.dangerousBypassAuth;
const token = effectiveBypass ? undefined : deps.resolveToken?.();
// True only when this process actually hosts the server: it stops with
// Ctrl+C. A reused daemon lives elsewhere and still needs `server kill`.
const attachedForeground = foreground && result.reused !== true;
let output = '';
if (result.reused === true) {
// A daemon was already running, so this command's --host/--port/etc. did
// not start a new one. Say so loudly, then print the actual running
// server's URLs (using its real bind host, not the requested one).
output += formatReuseNotice(origin);
// The reused server may predate an upgrade of this CLI: it keeps
// serving its own bundled web UI / API, so surface the mismatch.
if (result.hostVersion !== undefined && result.hostVersion !== getVersion()) {
output += formatServerUpgradeNotice(result.hostVersion);
}
}
output +=
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
? formatReadyBanner(origin, host, {
token,
networkAddresses: deps.networkAddresses,
dangerousBypassAuth: effectiveBypass,
foreground: attachedForeground,
})
: formatReadyLine(origin, token, effectiveBypass);
: formatReadyLine(origin, token, effectiveBypass, attachedForeground);
deps.stdout.write(output);
if (opts.open === true) {
deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin);
}
};
if (opts.foreground === true) {
if (foreground) {
if (config.defaultForeground === true) {
// `kimi web` defaults to foreground, but binding the port while a server
// is already running would fail — reuse it the way the daemon path does:
// print the reuse notice and open the browser, then let this command exit.
const probe = deps.findReusableDaemon ?? findReusableDaemon;
const existing = await probe();
if (existing !== undefined) {
writeReady({
origin: existing.origin,
reused: true,
host: existing.host,
hostVersion: existing.hostVersion,
});
return;
}
}
const run = deps.startServerForeground ?? startServerForeground;
await run(runOptions, {
onReady: (origin) => {
Expand All @@ -271,13 +342,26 @@ function formatReuseNotice(origin: string): string {
);
}

/**
* Shown after the reuse notice when the running server was started by a
* different CLI version: it keeps serving its own bundled web UI/API, so the
* user may want to restart it onto the version they just installed.
*/
function formatServerUpgradeNotice(runningVersion: string): string {
return (
`${chalk.hex(darkColors.warning)('Server version mismatch')}: the running server is ` +
`${runningVersion}, this CLI is ${getVersion()} — restarting picks up the new version.\n`
);
}

function formatReadyLine(
origin: string,
token: string | undefined,
dangerousBypassAuth = false,
foreground = false,
): string {
const notice = dangerousBypassAuth
? `${formatDangerNoticeLines().join('\n')}\n`
? `${formatDangerNoticeLines({ foreground }).join('\n')}\n`
: '';
return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`;
}
Expand All @@ -287,13 +371,16 @@ function formatReadyLine(
* disables the bearer-token gate. Shared by the full ready banner and the
* compact one-line output so the warning always shows regardless of log level.
*/
function formatDangerNoticeLines(): string[] {
function formatDangerNoticeLines(opts: { foreground?: boolean } = {}): string[] {
const danger = (text: string): string => chalk.hex(darkColors.error)(text);
const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text);
// A foreground server stops with Ctrl+C; only a background daemon needs the
// separate `kimi server kill` command.
const stopAction = opts.foreground === true ? 'press Ctrl+C' : 'run kimi server kill';
return [
` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`,
` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`,
` ${danger(`If you are unsure, run `)}${dangerBold('kimi server kill')}${danger(' now to stop this process.')}`,
` ${danger('If you are unsure, ')}${dangerBold(stopAction)}${danger(' now to stop this process.')}`,
];
}

Expand Down Expand Up @@ -520,9 +607,19 @@ interface FormatReadyBannerOptions {
networkAddresses?: NetworkAddress[];
/** When true, render a red danger notice (auth is disabled). */
dangerousBypassAuth?: boolean;
/**
* True when this process hosts the server attached to the terminal: the
* Stop hint becomes Ctrl+C instead of `kimi server kill`.
*/
foreground?: boolean;
}

function formatReadyBanner(
/**
* Render the ready banner shown when a server starts (or is reused) with the
* default log level. Exported for `/web` in the TUI, which prints the same
* banner when it hands the terminal over to a foreground server.
*/
export function formatReadyBanner(
origin: string,
host: string,
opts: FormatReadyBannerOptions = {},
Expand Down Expand Up @@ -554,7 +651,7 @@ function formatReadyBanner(
if (opts.dangerousBypassAuth === true) {
// Red, impossible-to-miss notice: the bearer-token gate is off, so anyone
// who can reach this port gets full session / filesystem / shell access.
lines.push(...formatDangerNoticeLines(), '');
lines.push(...formatDangerNoticeLines({ foreground: opts.foreground === true }), '');
}

// Access links.
Expand All @@ -578,9 +675,11 @@ function formatReadyBanner(
lines.push('');
}

// Auxiliary controls last.
// Auxiliary controls last. A foreground server is stopped by interrupting
// the terminal; only a detached daemon needs the `kill` subcommand.
const stopHint = opts.foreground === true ? 'Ctrl+C' : 'kimi server kill';
lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`);
lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`);
lines.push(` ${label('Stop: ')}${muted(stopHint)}`);
lines.push('');
return lines.join('\n');
}
Expand Down
Loading
Loading