diff --git a/.changeset/web-foreground-default.md b/.changeset/web-foreground-default.md new file mode 100644 index 0000000000..ddef27b144 --- /dev/null +++ b/.changeset/web-foreground-default.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +**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. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 47d97a828b..8726f77506 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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 { diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index e7aa3f14a0..9e8202a084 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -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). */ @@ -295,6 +301,26 @@ function sleep(ms: number): Promise { }); } +/** + * 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 { + 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 @@ -306,21 +332,11 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise; /** Foreground runner; defaults to the real in-process runner when omitted. */ startServerForeground?: ( options: ParsedServerOptions, hooks?: StartForegroundHooks, ) => Promise; + /** + * 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; openUrl(url: string): void; /** * Best-effort read of the server's persistent bearer token. When it returns @@ -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 ', `Bind port (default ${DEFAULT_SERVER_PORT})`, @@ -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 @@ -192,7 +222,7 @@ 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); @@ -200,26 +230,42 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) }); } +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 { 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 @@ -230,12 +276,20 @@ 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 @@ -243,14 +297,31 @@ export async function handleRunCommand( 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) => { @@ -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`; } @@ -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.')}`, ]; } @@ -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 = {}, @@ -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. @@ -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'); } diff --git a/apps/kimi-code/src/cli/sub/server/web-alias.ts b/apps/kimi-code/src/cli/sub/server/web-alias.ts index eb48318902..fa118bdefc 100644 --- a/apps/kimi-code/src/cli/sub/server/web-alias.ts +++ b/apps/kimi-code/src/cli/sub/server/web-alias.ts @@ -3,9 +3,10 @@ * * Shares the exact same code path as `kimi server run`: it is registered via * the same `buildRunCommand` builder (and therefore the same `handleRunCommand` - * handler, the same background-daemon flow, and the same ready banner) with - * `defaultOpen` flipped to `true`. The only difference from `server run` is - * that `web` opens the browser by default. + * handler and the same ready banner) with two flipped defaults — `defaultOpen` + * opens the browser, and `defaultForeground` runs the server in the foreground + * (this terminal stays attached until Ctrl+C) instead of backgrounding a + * daemon. `--background` opts back into the daemon behavior of `server run`. */ import type { Command } from 'commander'; @@ -16,7 +17,7 @@ export function registerWebAliasCommand(program: Command): void { buildRunCommand( program .command('web') - .description('Open the Kimi web UI (starts a background daemon if needed).'), - { defaultOpen: true }, + .description('Open the Kimi web UI (runs the server in the foreground by default).'), + { defaultOpen: true, defaultForeground: true }, ); } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index e1b3fb6168..1a68199581 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -137,6 +137,13 @@ export interface SlashCommandHost { // Dispatch stop(exitCode?: number): Promise; setExitOpenUrl(url: string): void; + /** + * Register a task that takes over the process after the TUI has shut down + * (instead of exiting): the runner awaits it and only exits when it returns. + * Used by `/web` foreground mode to keep the server attached to this + * terminal until Ctrl+C. + */ + setExitForegroundTask(task: (exitCode: number) => Promise): void; showHelpPanel(): void; createNewSession(): Promise; showSessionPicker(): Promise; @@ -366,7 +373,7 @@ async function handleBuiltInSlashCommand( await handleUndoCommand(host, args); return; case 'web': - await handleWebCommand(host); + await handleWebCommand(host, args); return; default: host.showError(`Unknown slash command: /${String(name)}`); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 037d2477a7..711f651709 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -393,7 +393,8 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'web', aliases: [], - description: 'Open the current session in the Web UI and exit the terminal', + description: + 'Open the current session in the Web UI (server runs in the foreground; --background to daemonize)', priority: 40, availability: 'always', }, diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts index 366860016e..24e41babe5 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -1,5 +1,11 @@ -import { ensureDaemon } from '#/cli/sub/server/daemon'; -import { tryResolveServerToken } from '#/cli/sub/server/shared'; +import chalk from 'chalk'; + +import { splitTokenFragment } from '#/cli/sub/server/access-urls'; +import { ensureDaemon, findReusableDaemon } from '#/cli/sub/server/daemon'; +import { formatReadyBanner, startServerForeground } from '#/cli/sub/server/run'; +import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/server/shared'; +import { getVersion } from '#/cli/version'; +import { darkColors } from '#/tui/theme/colors'; import { openUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; @@ -10,22 +16,27 @@ import type { SlashCommandHost } from './dispatch'; const WEB_CONFIRM = 'confirm'; const WEB_CANCEL = 'cancel'; +const WEB_BACKGROUND_FLAG = '--background'; /** * `/web` — hand the current session off to the browser. * - * Equivalent to `kimi server run` (ensures the background daemon is up) plus - * `kimi web` (opens the browser), but deep-linked to the active session and - * followed by shutting down this terminal UI. A confirmation step spells out - * the consequences and only proceeds when the user presses Enter on Continue. + * Default (foreground): the TUI shuts down and the Kimi server takes over this + * terminal in the foreground (`Ctrl+C` stops it), with the browser opened to + * the active session. `/web --background` instead ensures the background + * daemon is up, opens the browser, and releases the terminal — equivalent to + * `kimi web --background`. A server that is already running is reused in both + * modes. A confirmation step spells out the consequences and only proceeds + * when the user presses Enter on Continue. */ -export async function handleWebCommand(host: SlashCommandHost): Promise { +export async function handleWebCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } const sessionId = session.id; + const background = args.split(/\s+/).includes(WEB_BACKGROUND_FLAG); const confirmed = await new Promise((resolve) => { const picker = new ChoicePickerComponent({ @@ -35,8 +46,9 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { { value: WEB_CONFIRM, label: 'Continue', - description: - 'Start the Kimi server (background daemon if needed), open this session in your default browser, and exit the terminal UI.', + description: background + ? 'Start the Kimi server (background daemon if needed), open this session in your default browser, and exit the terminal UI.' + : 'Start the Kimi server in the foreground (this terminal stays attached; Ctrl+C stops it), open this session in your default browser, and exit the terminal UI.', }, { value: WEB_CANCEL, @@ -57,20 +69,103 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { if (!confirmed) return; host.showStatus('Starting Kimi server and opening web UI…'); - let origin: string; + + if (background) { + let origin: string; + let hostVersion: string | undefined; + try { + ({ origin, hostVersion } = await ensureDaemon({})); + } catch (error) { + host.showError(`Failed to start server: ${formatErrorMessage(error)}`); + return; + } + // Resolve the persistent token only after the daemon is up: a fresh server + // writes `server.token` on first boot, so reading it beforehand would miss + // first-time starts and the browser would hit the auth gate. Best-effort: + // fall back to the plain URL (and skip the token line) when unresolvable. + showServerVersionHint(host, hostVersion); + await openAndExit(host, sessionId, origin, tryResolveServerToken(getDataDir())); + return; + } + + // Foreground by default. A server that is already running can serve the web + // UI right away — reuse it instead of failing to bind its port. + let reused: { origin: string; hostVersion?: string } | undefined; try { - ({ origin } = await ensureDaemon({})); + reused = await findReusableDaemon(); } catch (error) { - host.showError(`Failed to start server: ${formatErrorMessage(error)}`); + host.showError(`Failed to probe the running server: ${formatErrorMessage(error)}`); + return; + } + if (reused !== undefined) { + showServerVersionHint(host, reused.hostVersion); + await openAndExit(host, sessionId, reused.origin, tryResolveServerToken(getDataDir())); return; } - // Resolve the persistent token so the opened browser auto-authenticates via - // the `#token=` fragment — matching the `kimi web` subcommand. Show the URL - // and token in green under the status line so they can be copied before the - // terminal exits. Best-effort: an older/never-started server has no token - // file, so we fall back to the plain URL and skip the token line. - const token = tryResolveServerToken(getDataDir()); + // No server is running: shut the TUI down and let the Kimi server take over + // this terminal in the foreground (the registered task runs after teardown, + // where `process.exit` would normally happen). The deep link is opened from + // the ready hook, once the server is actually listening, and the terminal + // shows the same ready banner as `kimi web` plus the session deep link. + host.setExitForegroundTask(async () => { + const runOptions = { ...parseServerOptions({}), keepAlive: true }; + try { + await startServerForeground(runOptions, { + onReady: (origin) => { + // Resolve the token here (after the server is listening) for the + // same first-boot reason as the daemon path above. + const token = tryResolveServerToken(getDataDir()); + const url = webSessionUrl(origin, sessionId, token); + process.stdout.write( + formatReadyBanner(origin, runOptions.host, { token, foreground: true }), + ); + process.stdout.write(`\n ${sessionLine(url)}\n`); + openUrl(url); + }, + }); + } catch (error) { + process.stderr.write(`Failed to start server: ${formatErrorMessage(error)}\n`); + process.exit(1); + } + }); + await host.stop(); +} + +/** Styled `Session:` line for the foreground handoff; the token fragment is + * dimmed like in the ready banner so the host/path stands out. */ +function sessionLine(url: string): string { + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const [base, frag] = splitTokenFragment(url); + return `${label('Session: ')}${accent(base)}${frag === '' ? '' : dim(frag)}`; +} + +/** + * Warn when the reused server was started by a different CLI version: it keeps + * serving its own bundled web UI/API until restarted. Mirrors the banner hint + * printed by `kimi web`. + */ +function showServerVersionHint(host: SlashCommandHost, hostVersion: string | undefined): void { + if (hostVersion === undefined || hostVersion === getVersion()) return; + host.showStatus( + `Running server is version ${hostVersion}, this CLI is ${getVersion()} — restart with kimi server kill to pick up the new version.`, + 'warning', + ); +} + +/** + * Open the session deep link in the browser, record it for the exit hints, + * and shut the TUI down. Used when the server is already running out of + * process (reused or freshly-spawned daemon), so exit frees the terminal. + */ +function openAndExit( + host: SlashCommandHost, + sessionId: string, + origin: string, + token: string | undefined, +): Promise { const url = webSessionUrl(origin, sessionId, token); host.showStatus(`open ${url}`, 'success'); if (token !== undefined) { @@ -78,7 +173,7 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { } openUrl(url); host.setExitOpenUrl(url); - await host.stop(); + return host.stop(); } /** diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a05165ca2b..cdd7353f01 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -356,6 +356,13 @@ export class KimiTUI { /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; + /** + * Task that takes over the process after the TUI shuts down, instead of + * exiting (`/web` foreground mode: the server keeps this terminal attached + * until Ctrl+C). Set via {@link setExitForegroundTask}. + */ + public exitForegroundTask: ((exitCode: number) => Promise) | undefined; + track(event: string, properties?: Parameters[1]): void { this.harness.track(event, properties); } @@ -1434,6 +1441,10 @@ export class KimiTUI { this.exitOpenUrl = url; } + setExitForegroundTask(task: (exitCode: number) => Promise): void { + this.exitForegroundTask = task; + } + async getStartupMcpMs(): Promise { const session = this.session; if (session === undefined) return 0; diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 300e3afd6b..bf2019a486 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -3,7 +3,8 @@ * * These tests don't actually start the server — they verify the parsed shape * (option flags, --open default) and that the `web` alias defers to the same - * underlying handler with `defaultOpen` flipped to true. + * underlying handler with `defaultOpen` and `defaultForeground` flipped to + * true (foreground by default, `--background` opting back into the daemon). * * Foreground startup behavior is exercised end-to-end in `server-e2e/`. */ @@ -65,6 +66,8 @@ describe('kimi server', () => { expect(longs).toContain('--foreground'); // run defaults to NOT opening the browser → option is the positive --open expect(longs).toContain('--open'); + // run stays background-by-default → no --background opt-out flag + expect(longs).not.toContain('--background'); }); it('`server install` exposes local-only service options', () => { @@ -83,7 +86,7 @@ describe('kimi server', () => { expect(longs).toContain('--json'); }); - it('the top-level `kimi web` alias is registered and defaults to opening the browser', () => { + it('the top-level `kimi web` alias is registered and defaults to opening the browser in the foreground', () => { const program = makeProgram(); const web = program.commands.find((c) => c.name() === 'web'); expect(web).toBeDefined(); @@ -92,6 +95,9 @@ describe('kimi server', () => { expect(longs).toContain('--no-open'); expect(longs).toContain('--host'); expect(longs).toContain('--port'); + // web defaults to foreground → --background opts back into the daemon + expect(longs).toContain('--foreground'); + expect(longs).toContain('--background'); }); }); @@ -501,6 +507,70 @@ describe('`kimi server run` background start', () => { expect(stdout).toContain(color.hex(darkColors.textMuted)('off')); }); + it('warns when the reused daemon was started by a different CLI version', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const { getVersion } = await import('#/cli/version'); + let stdout = ''; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + hostVersion: '0.0.0-test-old', + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('A server is already running'); + expect(plain).toContain('Server version mismatch'); + expect(plain).toContain('0.0.0-test-old'); + expect(plain).toContain(getVersion()); + }); + + it('stays quiet when the reused daemon runs the same CLI version', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const { getVersion } = await import('#/cli/version'); + let stdout = ''; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + hostVersion: getVersion(), + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('A server is already running'); + expect(plain).not.toContain('Server version mismatch'); + }); + it('prints a red danger notice and suppresses the token when auth is bypassed', async () => { const { handleRunCommand } = await import('#/cli/sub/server/run'); let stdout = ''; @@ -643,6 +713,42 @@ describe('`kimi server run --foreground`', () => { expect(plain).toContain('Kimi server ready'); expect(plain).toContain('http://127.0.0.1:58627/'); expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + // An attached-foreground server stops with Ctrl+C — `kimi server kill` + // from a second terminal would work, but the banner points at the obvious + // one: the terminal the user is looking at. + expect(plain).toContain('Stop:'); + expect(plain).toContain('Ctrl+C'); + expect(plain).not.toContain('kimi server kill'); + }); + + it('adapts the danger notice stop hint in attached-foreground mode', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { port: '58627', foreground: true, dangerousBypassAuth: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + startServerForeground: async (options, hooks) => { + void options; + hooks?.onReady?.('http://127.0.0.1:58627'); + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('DANGER: authentication is DISABLED'); + expect(plain).toContain('press Ctrl+C'); + expect(plain).not.toContain('kimi server kill'); }); }); @@ -1347,7 +1453,7 @@ describe('createIdleShutdownHandler', () => { }); }); -describe('kimi web (shares `server run` call stack)', () => { +describe('kimi web / `server run` (shares `server run` call stack, background default)', () => { it('prints the ready banner and opens the browser by default', async () => { const { handleRunCommand } = await import('#/cli/sub/server/run'); let stdout = ''; @@ -1409,6 +1515,176 @@ describe('kimi web (shares `server run` call stack)', () => { }); }); +describe('kimi web (foreground default)', () => { + it('runs in the foreground by default instead of spawning a daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + const startServerBackground = vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })); + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground, + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + findReusableDaemon: async () => undefined, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + { defaultForeground: true }, + ); + + expect(startServerBackground).not.toHaveBeenCalled(); + // Foreground is always keep-alive. + expect(foregroundOptions).toMatchObject({ port: 58627, keepAlive: true }); + }); + + it('`--background` restores the daemon behavior', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const startServerBackground = vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })); + const startServerForeground = vi.fn(); + const findReusableDaemon = vi.fn(async () => undefined); + + await handleRunCommand( + { port: '58627', background: true }, + { + startServerBackground, + startServerForeground, + findReusableDaemon, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + { defaultForeground: true }, + ); + + expect(startServerBackground).toHaveBeenCalledOnce(); + expect(startServerForeground).not.toHaveBeenCalled(); + // Background mode reuses daemons via ensureDaemon, not the foreground probe. + expect(findReusableDaemon).not.toHaveBeenCalled(); + }); + + it('`--foreground` stays foreground even when combined with the default', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', foreground: true }, + { + startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + findReusableDaemon: async () => undefined, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + { defaultForeground: true }, + ); + + expect(foregroundOptions).toMatchObject({ port: 58627 }); + }); + + it('reuses an already-running server instead of failing to bind the port', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + const startServerForeground = vi.fn(); + + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), + startServerForeground, + findReusableDaemon: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }), + resolveToken: () => 'tok', + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + { defaultForeground: true }, + ); + + expect(startServerForeground).not.toHaveBeenCalled(); + const plain = stripAnsi(stdout); + expect(plain).toContain('A server is already running'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok'); + // The reused server is a daemon living in another process — the Stop hint + // stays `kimi server kill`, not Ctrl+C. + expect(plain).toContain('Stop:'); + expect(plain).toContain('kimi server kill'); + // No hostVersion recorded/attached → no version mismatch line. + expect(plain).not.toContain('Server version mismatch'); + }); + + it('hints at a version mismatch when reusing a server from another CLI version', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), + startServerForeground: vi.fn(), + findReusableDaemon: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + hostVersion: '0.0.0-test-old', + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + { defaultForeground: true }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('Server version mismatch'); + expect(plain).toContain('0.0.0-test-old'); + }); + + it('`server run --foreground` never probes for a reusable daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const findReusableDaemon = vi.fn(async () => undefined); + + await handleRunCommand( + { port: '58627', foreground: true }, + { + startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), + startServerForeground: async () => undefined as unknown as never, + findReusableDaemon, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(findReusableDaemon).not.toHaveBeenCalled(); + }); +}); + function makeKillDeps(overrides: Partial = {}): { deps: KillCommandDeps; writes: string[]; diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index cbd6e5eec3..7301ce7155 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StartForegroundHooks } from '#/cli/sub/server/run'; import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; const mocks = vi.hoisted(() => ({ ensureDaemon: vi.fn(), + findReusableDaemon: vi.fn(), + startServerForeground: vi.fn(), tryResolveServerToken: vi.fn(), getDataDir: vi.fn(() => '/tmp/kimi-home'), openUrl: vi.fn(), @@ -13,7 +16,16 @@ const mocks = vi.hoisted(() => ({ vi.mock('#/cli/sub/server/daemon', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, ensureDaemon: mocks.ensureDaemon }; + return { + ...actual, + ensureDaemon: mocks.ensureDaemon, + findReusableDaemon: mocks.findReusableDaemon, + }; +}); + +vi.mock('#/cli/sub/server/run', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, startServerForeground: mocks.startServerForeground }; }); vi.mock('#/cli/sub/server/shared', async (importOriginal) => { @@ -36,6 +48,10 @@ type MountedPanel = { render: (width: number) => string[]; }; +function stripAnsi(text: string): string { + return text.replaceAll(/\[([0-9;]*)m/g, ''); +} + function makeHost() { let mountedPanel: MountedPanel | null = null; const host = { @@ -47,6 +63,7 @@ function makeHost() { }), restoreEditor: vi.fn(), setExitOpenUrl: vi.fn(), + setExitForegroundTask: vi.fn(), stop: vi.fn(async () => {}), } as unknown as SlashCommandHost & { showStatus: ReturnType; @@ -54,6 +71,7 @@ function makeHost() { mountEditorReplacement: ReturnType; restoreEditor: ReturnType; setExitOpenUrl: ReturnType; + setExitForegroundTask: ReturnType; stop: ReturnType; }; return { host, getMountedPanel: () => mountedPanel }; @@ -77,47 +95,241 @@ describe('handleWebCommand', () => { host: '127.0.0.1', port: 58627, }); + mocks.findReusableDaemon.mockResolvedValue(undefined); }); - it('shows the token in green and opens the deep link carrying the token fragment', async () => { - mocks.tryResolveServerToken.mockReturnValue('tok-1'); - const { host, getMountedPanel } = makeHost(); + describe('--background', () => { + it('shows the token in green and opens the deep link carrying the token fragment', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + const { host, getMountedPanel } = makeHost(); - const pending = handleWebCommand(host); - getMountedPanel()?.handleInput('\r'); - await pending; + const pending = handleWebCommand(host, '--background'); + getMountedPanel()?.handleInput('\r'); + await pending; - expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); - expect(host.showStatus).toHaveBeenCalledWith( - 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - 'success', - ); - expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); - expect(mocks.openUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - expect(host.setExitOpenUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - expect(host.stop).toHaveBeenCalledOnce(); + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + 'success', + ); + expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitOpenUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(mocks.ensureDaemon).toHaveBeenCalledOnce(); + expect(mocks.startServerForeground).not.toHaveBeenCalled(); + expect(host.setExitForegroundTask).not.toHaveBeenCalled(); + expect(host.stop).toHaveBeenCalledOnce(); + }); + + it('resolves the token after the daemon is up so first-time starts carry it', async () => { + // A fresh server writes `server.token` during startup; resolving any + // earlier (e.g. right after the confirm dialog) would miss it. + const callOrder: string[] = []; + mocks.ensureDaemon.mockImplementation(async () => { + callOrder.push('ensureDaemon'); + return { origin: 'http://127.0.0.1:58627', reused: false, host: '127.0.0.1', port: 58627 }; + }); + mocks.tryResolveServerToken.mockImplementation(() => { + callOrder.push('resolveToken'); + return 'tok-1'; + }); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host, '--background'); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(callOrder).toEqual(['ensureDaemon', 'resolveToken']); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + }); + + it('skips the token line and fragment when no token is available', async () => { + mocks.tryResolveServerToken.mockReturnValue(undefined); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host, '--background'); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1', + 'success', + ); + expect(host.showStatus).not.toHaveBeenCalledWith( + expect.stringContaining('Token:'), + 'success', + ); + expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + }); + + it('warns about a version mismatch when the reused daemon is from another CLI version', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + mocks.ensureDaemon.mockResolvedValue({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + hostVersion: '9.9.9-test-old', + }); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host, '--background'); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith( + expect.stringContaining('Running server is version 9.9.9-test-old'), + 'warning', + ); + }); + + it('describes the background daemon in the confirmation step', async () => { + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host, '--background'); + const rendered = getMountedPanel()?.render(120).join('\n') ?? ''; + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(rendered).toContain('background daemon'); + }); }); - it('skips the token line and fragment when no token is available', async () => { - mocks.tryResolveServerToken.mockReturnValue(undefined); - const { host, getMountedPanel } = makeHost(); + describe('default (foreground)', () => { + it('reuses an already-running server instead of starting a foreground one', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + mocks.findReusableDaemon.mockResolvedValue({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }); + const { host, getMountedPanel } = makeHost(); - const pending = handleWebCommand(host); - getMountedPanel()?.handleInput('\r'); - await pending; + const pending = handleWebCommand(host, ''); + getMountedPanel()?.handleInput('\r'); + await pending; - expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); - expect(host.showStatus).toHaveBeenCalledWith( - 'open http://127.0.0.1:58627/sessions/ses-1', - 'success', - ); - expect(host.showStatus).not.toHaveBeenCalledWith(expect.stringContaining('Token:'), 'success'); - expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); - expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitOpenUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.stop).toHaveBeenCalledOnce(); + expect(host.setExitForegroundTask).not.toHaveBeenCalled(); + expect(mocks.startServerForeground).not.toHaveBeenCalled(); + expect(mocks.ensureDaemon).not.toHaveBeenCalled(); + }); + + it('warns about a version mismatch when the reused server is from another CLI version', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + mocks.findReusableDaemon.mockResolvedValue({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + hostVersion: '9.9.9-test-old', + }); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host, ''); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith( + expect.stringContaining('Running server is version 9.9.9-test-old'), + 'warning', + ); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitForegroundTask).not.toHaveBeenCalled(); + }); + + it('registers a foreground exit task that starts the server and opens the deep link', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + let readyHooks: StartForegroundHooks | undefined; + mocks.startServerForeground.mockImplementation( + (_options: unknown, hooks?: StartForegroundHooks) => { + readyHooks = hooks; + return new Promise(() => {}); + }, + ); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host, ''); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(mocks.startServerForeground).not.toHaveBeenCalled(); + expect(host.setExitOpenUrl).not.toHaveBeenCalled(); + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(mocks.ensureDaemon).not.toHaveBeenCalled(); + expect(mocks.tryResolveServerToken).not.toHaveBeenCalled(); + expect(host.stop).toHaveBeenCalledOnce(); + expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); + + // Run the exit task the way run-shell's onExit would: it starts the + // foreground server; the ready hook prints and opens the deep link. + const task = host.setExitForegroundTask.mock.calls[0]?.[0] as ( + exitCode: number, + ) => Promise; + const taskPending = task(0); + expect(mocks.startServerForeground).toHaveBeenCalledOnce(); + const runOptions = mocks.startServerForeground.mock.calls[0]?.[0] as { + keepAlive: boolean; + host: string; + port: number; + }; + expect(runOptions.keepAlive).toBe(true); + expect(runOptions.host).toBe('127.0.0.1'); + expect(runOptions.port).toBe(58627); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + readyHooks?.onReady?.('http://127.0.0.1:58627'); + // The token is resolved inside the ready hook — after the server has + // written `server.token` on first boot — never during the TUI phase. + expect(mocks.tryResolveServerToken).toHaveBeenCalledOnce(); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + const written = stripAnsi(stdoutSpy.mock.calls.map((call) => String(call[0])).join('')); + // Same ready banner as `kimi web`, plus the session deep link. + expect(written).toContain('Kimi server ready'); + expect(written).toContain('http://127.0.0.1:58627/'); + expect(written).toContain('Token: tok-1'); + expect(written).toContain('Session: http://127.0.0.1:58627/sessions/ses-1#token=tok-1'); + // Foreground servers stop with Ctrl+C, not `kimi server kill`. + expect(written).toContain('Stop: Ctrl+C'); + expect(written).not.toContain('kimi server kill'); + } finally { + stdoutSpy.mockRestore(); + } + // Keep the never-resolving task from outliving the test. + void taskPending; + }); + + it('describes the foreground behavior in the confirmation step', async () => { + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host, ''); + const rendered = getMountedPanel()?.render(120).join('\n') ?? ''; + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(rendered).toContain('foreground'); + expect(rendered).toContain('Ctrl+C'); + }); }); }); diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index b6688148fe..dce8bd813d 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -120,7 +120,7 @@ In `stream-json` mode, regular replies produce an Assistant message; when the mo ## Subcommands -`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (alias for `kimi server run --open`), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). +`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (open the web UI; runs the server in the foreground by default), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). ### `kimi login` @@ -203,15 +203,17 @@ The loopback host, chosen port, and log level are recorded to `~/.kimi-code/serv Opens Kimi's graphical session in the browser as an alternative to the terminal TUI. -Equivalent to `kimi server run --open`: it starts a local Kimi server in the background (reusing one already running), opens the web UI in the default browser, and returns, leaving the server resident in the background. The only difference from `kimi server run` is that `--open` is enabled by default (auto-launches the browser); all other behavior is identical. +`kimi web` runs a local Kimi server in the foreground — the command stays attached to the terminal and the server stops when you press `Ctrl-C` — and opens the web UI in the default browser once the server is healthy. If a server is already running, it is reused: the command prints its address, opens the browser, and returns instead of binding a new port. Pass `--background` to start a background daemon and release the terminal immediately; the daemon shuts itself down after the last web client disconnects. + +The reused server keeps running whatever version started it — after an upgrade, an older server is reused as-is and the output points out the version mismatch. Run `kimi server kill` once after upgrading to restart on the new version. ```sh -kimi web # start the server in the background and open the browser (reuses a running one) -kimi web --no-open # don't open the browser; same as `kimi server run` -kimi web --foreground # run attached to the current terminal and open the browser +kimi web # run the server in the foreground and open the browser (reuses a running one) +kimi web --background # start a background daemon, open the browser, and release the terminal +kimi web --no-open # don't open the browser; keep the server attached to the terminal ``` -Stop the server with `kimi server kill` and list active connections with `kimi server ps`; `--port`, `--log-level`, and the other flags match `kimi server run`. +Stop a foreground server with `Ctrl-C` and a background one with `kimi server kill`, and list active connections with `kimi server ps`. `--port`, `--log-level`, `--foreground`, and the other flags match `kimi server run`; `--background` is only available on `kimi web`. ### `kimi doctor` diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 38b9174559..b52ae0bea8 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -40,6 +40,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/export-debug-zip` | — | Export the current session as a debug ZIP archive (same behavior as [`kimi export`](./kimi-command.md#kimi-export)) | No | | `/copy` | — | Copy the last assistant message to the clipboard | No | | `/add-dir []` | — | Add an extra workspace directory to the current session. Run without a path (or with `list`) to list configured directories. When adding, choose whether to remember the directory for the project in `.kimi-code/local.toml` | No | +| `/web [--background]` | — | Open the current session in the Web UI. By default the TUI exits and the server keeps running in the foreground on the same terminal (stop it with `Ctrl-C`); `--background` starts or reuses a background daemon and releases the terminal instead. See [`kimi web`](./kimi-command.md#kimi-web) | Yes | ## Modes & Run Control diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 0234f4bc54..3470eac198 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -120,7 +120,7 @@ kimi -p "List changed files" --output-format stream-json ## 子命令 -`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(`kimi server run --open` 的别名)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 +`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(打开 web UI,默认前台运行服务)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 ### `kimi login` @@ -203,15 +203,17 @@ kimi server status # 查看安装与运行状态 在浏览器中打开 Kimi 的图形会话界面,作为终端 TUI 的替代入口。 -等价于 `kimi server run --open`:在后台启动本地 Kimi 服务(若已运行则复用),用默认浏览器打开 web UI,随后命令返回,服务驻留后台。与 `kimi server run` 的唯一区别是默认启用 `--open`(自动打开浏览器),其余行为一致。 +`kimi web` 在前台运行本地 Kimi 服务——命令保持挂在当前终端,按 `Ctrl-C` 即停止服务——服务健康后用默认浏览器打开 web UI。如果已有服务在运行,则直接复用:打印其地址、打开浏览器并返回,不会再绑定新端口。加 `--background` 则启动后台守护进程并立即释放终端;守护进程在最后一个 web 客户端断开后自行关闭。 + +被复用的服务保持启动它时的版本:升级后,旧版本的服务会被原样复用,输出中会提示版本不一致。升级后运行一次 `kimi server kill`,下次启动即使用新版本。 ```sh -kimi web # 后台启动服务并打开浏览器(已运行则复用) -kimi web --no-open # 不打开浏览器,等同 `kimi server run` -kimi web --foreground # 在当前终端前台运行,同时打开浏览器 +kimi web # 前台运行服务并打开浏览器(已运行则复用) +kimi web --background # 启动后台守护进程,打开浏览器后立即释放终端 +kimi web --no-open # 不打开浏览器,服务保持挂在当前终端 ``` -停止服务使用 `kimi server kill`,查看活动连接使用 `kimi server ps`;`--port`、`--log-level` 等选项与 `kimi server run` 一致。 +前台服务按 `Ctrl-C` 停止,后台守护进程用 `kimi server kill` 停止,查看活动连接用 `kimi server ps`。`--port`、`--log-level`、`--foreground` 等选项与 `kimi server run` 一致;`--background` 仅 `kimi web` 支持。 ### `kimi doctor` diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 64449d6572..2eb94c300b 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -38,6 +38,7 @@ | `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 | | `/copy` | — | 将最后一条 AI 回复复制到剪贴板 | 否 | | `/add-dir []` | — | 为当前会话添加额外的工作目录。不带路径(或传入 `list`)运行时列出已配置的目录。添加时可选择是否将目录记入项目的 `.kimi-code/local.toml` | 否 | +| `/web [--background]` | — | 在 Web UI 中打开当前会话。默认退出 TUI 并让服务在同一终端前台运行(按 `Ctrl-C` 停止);`--background` 则启动或复用后台守护进程并释放终端。参见 [`kimi web`](./kimi-command.md#kimi-web) | 是 | ## 模式与运行控制