diff --git a/.changeset/foreground-only-web-servers.md b/.changeset/foreground-only-web-servers.md new file mode 100644 index 0000000000..4645a0815b --- /dev/null +++ b/.changeset/foreground-only-web-servers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Run web servers foreground-only end to end: the /web slash command now always starts a new server, and the `kimi web kill` / `kimi web ps` subcommands are removed — foreground servers stop with Ctrl+C. `kimi server kill` remains as a deprecated fallback that only stops servers started by a version before 0.28.0. diff --git a/apps/kimi-code/src/cli/sub/web/deprecated-server.ts b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts index 21818d9837..23c8e63b6f 100644 --- a/apps/kimi-code/src/cli/sub/web/deprecated-server.ts +++ b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts @@ -1,22 +1,29 @@ /** * Deprecated `kimi server` shim. * - * The `kimi server` command tree was replaced by `kimi web` (foreground - * server plus the kill/ps/rotate-token management subcommands). Any - * `kimi server …` invocation — bare or with any subcommand/flags — lands - * here, prints the deprecation notice, and exits 1. The shim itself is - * scheduled for removal in the next major version of Kimi Code. + * The `kimi server` command tree was replaced by `kimi web` (a foreground + * server opened in the browser). Any `kimi server …` invocation — bare or + * with any legacy subcommand/flags — lands here, prints the deprecation + * notice, and exits 1. The shim itself is scheduled for removal in the next + * major version of Kimi Code. + * + * One subcommand stays functional: `kimi server kill`, the cleanup path for + * background servers started by pre-0.28.0 builds (recorded in the legacy + * single-instance lock, which the instance registry never sees). */ import type { Command } from 'commander'; +import { registerLegacyKillCommand } from './legacy-kill'; + export const DEPRECATED_SERVER_NOTICE = '`kimi server` has been deprecated and no longer works.\n' + 'Use `kimi web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' + + 'To stop a server started by a version before 0.28.0, use `kimi server kill`.\n' + 'This notice will be removed in the next major version of Kimi Code.\n'; export function registerDeprecatedServerCommand(program: Command): void { - program + const server = program .command('server') .description('Deprecated — use `kimi web` instead.') // Swallow every legacy subcommand/flag (`run`, `kill`, `--port`, …) so @@ -27,4 +34,5 @@ export function registerDeprecatedServerCommand(program: Command): void { process.stderr.write(DEPRECATED_SERVER_NOTICE); process.exit(1); }); + registerLegacyKillCommand(server); } diff --git a/apps/kimi-code/src/cli/sub/web/index.ts b/apps/kimi-code/src/cli/sub/web/index.ts index 9099d72a9e..8cf8406716 100644 --- a/apps/kimi-code/src/cli/sub/web/index.ts +++ b/apps/kimi-code/src/cli/sub/web/index.ts @@ -3,19 +3,16 @@ * foreground and open the web UI in the default browser. * * The command itself is the runner (`kimi web` = start the server + open the - * browser; `--no-open` to skip). Management subcommands work off the instance - * registry (`~/.kimi-code/server/instances/`), so they see every instance - * sharing this home directory: - * - `web kill [serverId]` — stop an instance (default: the longest-running) - * - `web ps` — list connected clients per instance - * - `web rotate-token` — rotate the home-wide bearer token + * browser; `--no-open` to skip). The server stays attached to the terminal + * and stops with Ctrl+C, so there is no kill/ps subcommand; the only + * management subcommand is `web rotate-token` (rotate the home-wide bearer + * token). Servers left behind by pre-0.28.0 builds are cleaned up with + * `kimi server kill`. */ import type { Command } from 'commander'; import { registerDeprecatedServerCommand } from './deprecated-server'; -import { registerKillCommand } from './kill'; -import { registerPsCommand } from './ps'; import { registerRotateTokenCommand } from './rotate-token'; import { buildWebCommand } from './run'; @@ -25,8 +22,6 @@ export function registerWebCommand(program: Command): void { .command('web') .description('Run the local Kimi server and open the web UI.'), ); - registerKillCommand(web); - registerPsCommand(web); registerRotateTokenCommand(web); registerDeprecatedServerCommand(program); } diff --git a/apps/kimi-code/src/cli/sub/web/kill.ts b/apps/kimi-code/src/cli/sub/web/kill.ts deleted file mode 100644 index 4b76a31ee0..0000000000 --- a/apps/kimi-code/src/cli/sub/web/kill.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * `kimi web kill [serverId|all]` — terminate running servers. - * - * Combines two independent mechanisms so the server dies even if one path - * fails: - * - * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown - * (best-effort; older builds or a wedged server may not answer). - * 2. PID path — signal the pid recorded in the instance registry (SIGTERM → - * wait → SIGKILL). SIGKILL / TerminateProcess is the hard - * guarantee: it cannot be caught or ignored. - * - * With multiple servers sharing the home directory, the optional argument - * picks the target: a `serverId` kills that one instance, the reserved - * keyword `all` kills every live instance (a ULID can never collide with - * it), and no argument kills the longest-running live instance. The only - * honest failure mode is insufficient permissions (a process owned by - * another user), which surfaces as an error rather than a silent miss. - */ - -import type { Command } from 'commander'; - -import { listLiveServerInstances, type ServerInstanceInfo } from '@moonshot-ai/kap-server'; - -import { getDataDir } from '#/utils/paths'; - -import { authHeaders, instanceConnectHost, serverOrigin, tryResolveServerToken } from './shared'; - -/** How long to wait for the graceful API shutdown request. */ -const API_TIMEOUT_MS = 2000; -/** Grace period after SIGTERM before escalating to SIGKILL. */ -const TERM_GRACE_MS = 3000; -/** Grace period after SIGKILL before giving up. */ -const KILL_GRACE_MS = 2000; -/** Poll cadence while waiting for the pid to exit. */ -const POLL_INTERVAL_MS = 100; - -/** - * Reserved positional that targets every live instance. Server ids are ULIDs - * (26-char Crockford base32), so the keyword can never shadow a real id. - */ -export const KILL_ALL_KEYWORD = 'all'; - -export interface KillCommandDeps { - getLiveInstances(): Promise; - requestShutdown(origin: string, token: string | undefined): Promise; - /** Best-effort read of the persistent bearer token; undefined on miss. */ - resolveToken(): string | undefined; - signalPid(pid: number, signal: NodeJS.Signals): boolean; - pidAlive(pid: number): boolean; - sleep(ms: number): Promise; - stdout: Pick; - now(): number; -} - -export function registerKillCommand(server: Command): void { - server - .command('kill') - .description('Stop a running Kimi server (graceful API + forced PID kill).') - .argument( - '[serverId]', - 'Stop only the instance with this server id, or `all` for every instance (default: the longest-running one).', - ) - .action(async (serverId?: string) => { - try { - await handleKillCommand(DEFAULT_KILL_DEPS, serverId); - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); - } - }); -} - -export async function handleKillCommand( - deps: KillCommandDeps, - serverId?: string, -): Promise { - const instances = await deps.getLiveInstances(); - // The registry sorts by startedAt ascending — the first entry is the - // longest-running instance, the default target. - const [longestRunning] = instances; - if (longestRunning === undefined) { - deps.stdout.write('No running Kimi server.\n'); - return; - } - - if (serverId === KILL_ALL_KEYWORD) { - const failures: string[] = []; - for (const instance of instances) { - try { - const outcome = await killInstance(instance, deps); - deps.stdout.write( - `Kimi server ${instance.serverId} (pid ${String(instance.pid)}) ${outcome}.\n`, - ); - } catch (error) { - failures.push( - `server ${instance.serverId}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - if (failures.length > 0) { - throw new Error(failures.join('\n')); - } - return; - } - - let instance = longestRunning; - if (serverId !== undefined) { - const found = instances.find((i) => i.serverId === serverId); - if (found === undefined) { - const live = instances.map((i) => i.serverId).join(', '); - throw new Error(`No running Kimi server with id ${serverId}. Live servers: ${live}.`); - } - instance = found; - } - - const outcome = await killInstance(instance, deps); - deps.stdout.write(`Kimi server (pid ${String(instance.pid)}) ${outcome}.\n`); -} - -/** - * Kill one instance via the API path (best-effort graceful shutdown) followed - * by the PID path (SIGTERM → wait → SIGKILL). Resolves with how the process - * went down; throws when the pid survives SIGKILL. - */ -async function killInstance( - instance: ServerInstanceInfo, - deps: KillCommandDeps, -): Promise<'stopped' | 'killed'> { - const { pid } = instance; - const origin = serverOrigin(instanceConnectHost(instance), instance.port); - - // 1. API path — best-effort graceful shutdown. Ignore every outcome: the - // server may be an older build without the route, already wedged, or may - // drop the connection as it exits. The bearer token (M5.1) is best-effort - // too: if it can't be read the API call 401s and the PID path below still - // guarantees the kill. - const token = deps.resolveToken(); - await deps.requestShutdown(origin, token).catch(() => {}); - - // 2. PID path — SIGTERM, wait, then SIGKILL. - deps.signalPid(pid, 'SIGTERM'); - - if (await waitForExit(pid, TERM_GRACE_MS, deps)) { - return 'stopped'; - } - - deps.signalPid(pid, 'SIGKILL'); - - if (await waitForExit(pid, KILL_GRACE_MS, deps)) { - return 'killed'; - } - - throw new Error( - `Failed to stop Kimi server (pid ${String(pid)}); insufficient permissions?`, - ); -} - -async function waitForExit( - pid: number, - timeoutMs: number, - deps: Pick, -): Promise { - const deadline = deps.now() + timeoutMs; - do { - if (!deps.pidAlive(pid)) return true; - await deps.sleep(POLL_INTERVAL_MS); - } while (deps.now() < deadline); - return !deps.pidAlive(pid); -} - -/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ -export function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ESRCH') return false; - // EPERM = process exists but we can't signal it. Treat as alive. - return true; - } -} - -/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ -export function signalPid(pid: number, signal: NodeJS.Signals): boolean { - try { - process.kill(pid, signal); - return true; - } catch { - return false; - } -} - -/** POST the shutdown endpoint; resolves once the request completes or times out. */ -export async function requestShutdownViaApi( - origin: string, - token: string | undefined, -): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, API_TIMEOUT_MS); - try { - await fetch(`${origin}/api/v1/shutdown`, { - method: 'POST', - headers: token !== undefined ? authHeaders(token) : undefined, - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } -} - -const DEFAULT_KILL_DEPS: KillCommandDeps = { - getLiveInstances: listLiveServerInstances, - requestShutdown: requestShutdownViaApi, - resolveToken: () => tryResolveServerToken(getDataDir()), - signalPid, - pidAlive, - sleep: (ms) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }), - stdout: process.stdout, - now: () => Date.now(), -}; diff --git a/apps/kimi-code/src/cli/sub/web/legacy-kill.ts b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts new file mode 100644 index 0000000000..97cc1675a9 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts @@ -0,0 +1,263 @@ +/** + * `kimi server kill` — deprecated; only stops a server started by an old + * (pre-`kimi web`, i.e. before 0.28.0) build. + * + * Servers started by current builds run in the foreground attached to a + * terminal (Ctrl+C stops them), so they need no kill command. Builds before + * the `kimi web` command tree could leave a background daemon behind; those + * recorded themselves in the legacy single-instance lock at + * `/server/lock`, which the instance registry never sees. + * This command is the cleanup path for exactly those servers. + * + * The kill combines two independent mechanisms so the server dies even if one + * path fails: + * + * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown + * (best-effort; old builds may not have the route, or may not + * answer at all). + * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → + * SIGKILL). SIGKILL is the hard guarantee: it cannot be + * caught or ignored. + * + * The lock file is removed once the recorded pid is confirmed dead (or was + * dead already), so the cleanup is complete after one run. + */ + +import { readFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Command } from 'commander'; + +import { getDataDir } from '#/utils/paths'; + +import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; + +/** How long to wait for the graceful API shutdown request. */ +const API_TIMEOUT_MS = 2000; +/** Grace period after SIGTERM before escalating to SIGKILL. */ +const TERM_GRACE_MS = 3000; +/** Grace period after SIGKILL before giving up. */ +const KILL_GRACE_MS = 2000; +/** Poll cadence while waiting for the pid to exit. */ +const POLL_INTERVAL_MS = 100; + +/** + * The first release whose servers run in the foreground (`kimi web`) and + * register under `server/instances/`. Servers from older builds are the only + * ones this command can — and should — kill. + */ +export const LEGACY_SERVER_MAX_VERSION = '0.28.0'; + +/** Deprecation notice printed on every `kimi server kill` run. */ +export const DEPRECATED_KILL_NOTICE = + '`kimi server kill` is deprecated: it only stops servers started by a version before 0.28.0. Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.\n'; + +/** + * The fields of the legacy `/server/lock` this command needs. The full + * on-disk shape also carried `started_at` / `host_version` / `entry`, which + * are irrelevant to killing the process. + */ +export interface LegacyServerLock { + pid: number; + host?: string; + port?: number; +} + +export interface LegacyKillDeps { + /** Read and parse the legacy lock; undefined when missing or unparseable. */ + readLock(): Promise; + /** Delete the lock file. Best-effort semantics live with the caller. */ + removeLock(): Promise; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the persistent bearer token; undefined on miss. */ + resolveToken(): string | undefined; + signalPid(pid: number, signal: NodeJS.Signals): boolean; + pidAlive(pid: number): boolean; + sleep(ms: number): Promise; + stdout: Pick; + stderr: Pick; + now(): number; +} + +export function registerLegacyKillCommand(server: Command): void { + server + .command('kill') + .description( + 'Deprecated — stop a server started by a version before 0.28.0 (recorded in the legacy server lock). Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.', + ) + // Swallow legacy argument shapes (`kimi server kill `, flags): + // the legacy lock records a single server, so they carry no meaning here. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async () => { + try { + await handleLegacyKillCommand(DEFAULT_LEGACY_KILL_DEPS); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleLegacyKillCommand(deps: LegacyKillDeps): Promise { + deps.stderr.write(DEPRECATED_KILL_NOTICE); + + const lock = await deps.readLock(); + if (lock === undefined) { + deps.stdout.write('No running legacy Kimi server.\n'); + return; + } + + if (!deps.pidAlive(lock.pid)) { + // Stale lock from a server that died without releasing it; sweep it so the + // cleanup is done in one run. + await deps.removeLock().catch(() => {}); + deps.stdout.write('No running legacy Kimi server.\n'); + return; + } + + const outcome = await killLegacyServer(lock, deps); + await deps.removeLock().catch(() => {}); + deps.stdout.write(`Legacy Kimi server (pid ${String(lock.pid)}) ${outcome}.\n`); +} + +/** + * Kill the locked server via the API path (best-effort graceful shutdown) + * followed by the PID path (SIGTERM → wait → SIGKILL). Resolves with how the + * process went down; throws when the pid survives SIGKILL. + */ +async function killLegacyServer( + lock: LegacyServerLock, + deps: LegacyKillDeps, +): Promise<'stopped' | 'killed'> { + const { pid } = lock; + + // 1. API path — best-effort graceful shutdown. Ignore every outcome: an old + // build may not have the route, may be wedged, or may drop the connection + // as it exits. The bearer token is best-effort too: if it can't be read + // the API call 401s and the PID path below still guarantees the kill. + if (lock.port !== undefined) { + const origin = serverOrigin(lock.host ?? '127.0.0.1', lock.port); + await deps.requestShutdown(origin, deps.resolveToken()).catch(() => {}); + } + + // 2. PID path — SIGTERM, wait, then SIGKILL. + deps.signalPid(pid, 'SIGTERM'); + + if (await waitForExit(pid, TERM_GRACE_MS, deps)) { + return 'stopped'; + } + + deps.signalPid(pid, 'SIGKILL'); + + if (await waitForExit(pid, KILL_GRACE_MS, deps)) { + return 'killed'; + } + + throw new Error( + `Failed to stop legacy Kimi server (pid ${String(pid)}); insufficient permissions?`, + ); +} + +async function waitForExit( + pid: number, + timeoutMs: number, + deps: Pick, +): Promise { + const deadline = deps.now() + timeoutMs; + do { + if (!deps.pidAlive(pid)) return true; + await deps.sleep(POLL_INTERVAL_MS); + } while (deps.now() < deadline); + return !deps.pidAlive(pid); +} + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it. Treat as alive. + return true; + } +} + +/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ +export function signalPid(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** POST the shutdown endpoint; resolves once the request completes or times out. */ +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, API_TIMEOUT_MS); + try { + await fetch(`${origin}/api/v1/shutdown`, { + method: 'POST', + headers: token !== undefined ? authHeaders(token) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +/** Path of the legacy single-instance lock under the CLI's data dir. */ +export function legacyLockPath(homeDir: string): string { + return join(homeDir, 'server', 'lock'); +} + +/** Read + decode the legacy lock; undefined on missing/unparseable input. */ +export async function readLegacyLock(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, 'utf8'); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw) as Partial<{ pid: unknown; host: unknown; port: unknown }>; + // Only accept a positive safe-integer pid: on POSIX, 0 and negative pids + // have process-GROUP semantics, so signaling a corrupt lock's pid could + // hit this CLI's own group or an unrelated one. + if (typeof parsed.pid !== 'number' || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0) { + return undefined; + } + return { + pid: parsed.pid, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + port: typeof parsed.port === 'number' ? parsed.port : undefined, + }; + } catch { + return undefined; + } +} + +const DEFAULT_LEGACY_KILL_DEPS: LegacyKillDeps = { + readLock: () => readLegacyLock(legacyLockPath(getDataDir())), + removeLock: () => unlink(legacyLockPath(getDataDir())), + requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), + signalPid, + pidAlive, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + stdout: process.stdout, + stderr: process.stderr, + now: () => Date.now(), +}; diff --git a/apps/kimi-code/src/cli/sub/web/ps.ts b/apps/kimi-code/src/cli/sub/web/ps.ts deleted file mode 100644 index b1a8f70049..0000000000 --- a/apps/kimi-code/src/cli/sub/web/ps.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * `kimi web ps` — list clients currently connected to the running servers. - * - * Talks to every live server over HTTP (`GET /api/v1/connections`) using the - * instance registry (`~/.kimi-code/server/instances/`) to discover origins, - * and prints one section per server id. The bearer token is home-wide, so one - * token reaches every instance. An unreachable instance degrades to a - * per-server note instead of failing the whole listing. - */ - -import chalk from 'chalk'; -import type { Command } from 'commander'; - -import { listLiveServerInstances, type ServerInstanceInfo } from '@moonshot-ai/kap-server'; - -import { getDataDir } from '#/utils/paths'; - -import { authHeaders, instanceConnectHost, isServerHealthy, resolveServerToken, serverOrigin } from './shared'; - -/** Wire shape of a single connection returned by `GET /api/v1/connections`. */ -interface ConnectionInfo { - id: string; - connected_at: string; - remote_address: string | null; - user_agent: string | null; - has_client_hello: boolean; - subscriptions: string[]; -} - -interface ConnectionsEnvelope { - code: number; - msg: string; - data?: { connections?: ConnectionInfo[] }; -} - -const HEALTH_TIMEOUT_MS = 1500; -const FETCH_TIMEOUT_MS = 5000; -const USER_AGENT_MAX_WIDTH = 40; - -export function registerPsCommand(server: Command): void { - server - .command('ps') - .description('List clients currently connected to each running Kimi server.') - .option('--json', 'Print the raw per-server connection lists as JSON.') - .action(async (opts: { json?: boolean }) => { - try { - await handlePsCommand(opts); - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); - } - }); -} - -/** One instance's listing outcome: its connections, or why they could not be fetched. */ -interface ServerConnections { - instance: ServerInstanceInfo; - origin: string; - connections?: ConnectionInfo[]; - error?: string; -} - -async function handlePsCommand(opts: { json?: boolean }): Promise { - const instances = await listLiveServerInstances(); - if (instances.length === 0) { - throw new Error( - 'No running Kimi server. Start one with `kimi web`.', - ); - } - - // The `/api/v1/connections` route is gated by bearer auth (M5.1); the - // persistent token is home-wide, so one read reaches every instance. A clear - // error here means the server has never been started (no token file yet) or - // the token file was removed. - const token = resolveServerToken(getDataDir()); - - const sections: ServerConnections[] = []; - for (const instance of instances) { - const origin = serverOrigin(instanceConnectHost(instance), instance.port); - if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { - sections.push({ instance, origin, error: 'server is not responding' }); - continue; - } - try { - sections.push({ instance, origin, connections: await fetchConnections(origin, token) }); - } catch (error) { - sections.push({ - instance, - origin, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - if (opts.json) { - process.stdout.write(`${JSON.stringify({ servers: sections.map(toJsonSection) }, null, 2)}\n`); - return; - } - process.stdout.write(formatSections(sections)); -} - -function toJsonSection(section: ServerConnections): Record { - const base: Record = { - server_id: section.instance.serverId, - pid: section.instance.pid, - host: section.instance.host, - port: section.instance.port, - origin: section.origin, - }; - if (section.error !== undefined) { - return { ...base, error: section.error }; - } - return { ...base, connections: section.connections ?? [] }; -} - -function formatSections(sections: ServerConnections[]): string { - return ( - sections - .map((section) => { - const header = `server ${section.instance.serverId} (pid ${String(section.instance.pid)}, ${section.origin})`; - if (section.error !== undefined) { - return `${header}\n${section.error}\n`; - } - return `${header}\n${formatTable(section.connections ?? [])}`; - }) - .join('\n') - ); -} - -async function fetchConnections(origin: string, token: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, FETCH_TIMEOUT_MS); - try { - const res = await fetch(`${origin}/api/v1/connections`, { - headers: authHeaders(token), - signal: controller.signal, - }); - if (!res.ok) { - throw new Error(`Failed to list clients: HTTP ${String(res.status)} from ${origin}.`); - } - const body = (await res.json()) as ConnectionsEnvelope; - if (body.code !== 0) { - throw new Error(`Failed to list clients: ${body.msg}`); - } - return body.data?.connections ?? []; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Timed out listing clients from ${origin}.`); - } - throw error; - } finally { - clearTimeout(timeout); - } -} - -function formatTable(connections: ConnectionInfo[]): string { - if (connections.length === 0) { - return 'No active clients.\n'; - } - - const header = ['ID', 'CONNECTED', 'REMOTE', 'USER_AGENT', 'SESSIONS', 'HELLO']; - const rows = connections.map((c) => [ - c.id, - formatAge(c.connected_at), - c.remote_address ?? '-', - truncate(c.user_agent ?? '-', USER_AGENT_MAX_WIDTH), - String(c.subscriptions.length), - c.has_client_hello ? 'yes' : 'no', - ]); - - const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length))); - const formatRow = (cells: string[]): string => - cells.map((cell, i) => cell + ' '.repeat(Math.max(0, widths[i]! - cell.length))).join(' '); - - const lines = [chalk.bold(formatRow(header)), ...rows.map(formatRow)]; - return `${lines.join('\n')}\n`; -} - -function formatAge(iso: string): string { - const ms = Date.now() - Date.parse(iso); - if (!Number.isFinite(ms) || ms < 0) return '-'; - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${String(seconds)}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${String(minutes)}m`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${String(hours)}h`; - const days = Math.floor(hours / 24); - return `${String(days)}d`; -} - -function truncate(value: string, max: number): string { - if (value.length <= max) return value; - if (max <= 1) return value.slice(0, max); - return `${value.slice(0, max - 1)}…`; -} diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 301c1fd2c1..1a4fcbaa84 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -214,7 +214,7 @@ function formatDangerNoticeLines(): string[] { 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 web kill')}${danger(' now to stop this process.')}`, + ` ${danger('If you are unsure, stop this process now with ')}${dangerBold('Ctrl+C')}${danger('.')}`, ]; } @@ -328,8 +328,6 @@ interface FormatReadyBannerOptions { networkAddresses?: NetworkAddress[]; /** When true, render a red danger notice (auth is disabled). */ dangerousBypassAuth?: boolean; - /** When true, the server is attached to this terminal — Stop hint is Ctrl+C. */ - foreground?: boolean; } export function formatReadyBanner( @@ -390,8 +388,8 @@ export function formatReadyBanner( // Auxiliary controls last. lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); - const stopHint = opts.foreground === true ? 'Ctrl+C' : 'kimi web kill'; - lines.push(` ${label('Stop: ')}${muted(stopHint)}`); + // The server always runs in the foreground attached to this terminal. + lines.push(` ${label('Stop: ')}${muted('Ctrl+C')}`); lines.push(''); return lines.join('\n'); } diff --git a/apps/kimi-code/src/cli/sub/web/shared.ts b/apps/kimi-code/src/cli/sub/web/shared.ts index 6a441cee92..79dfff7d41 100644 --- a/apps/kimi-code/src/cli/sub/web/shared.ts +++ b/apps/kimi-code/src/cli/sub/web/shared.ts @@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import type { ServerInstanceInfo, ServerLogLevel } from '@moonshot-ai/kap-server'; +import type { ServerLogLevel } from '@moonshot-ai/kap-server'; export const LOCAL_SERVER_HOST = '127.0.0.1'; export const DEFAULT_LAN_HOST = '0.0.0.0'; @@ -31,14 +31,6 @@ export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ 'silent', ]; -/** - * Browser-reachable host for a registry instance: a wildcard bind - * (`0.0.0.0`) is not a connectable address, so advertise loopback instead. - */ -export function instanceConnectHost(instance: ServerInstanceInfo): string { - return instance.host === '0.0.0.0' ? LOCAL_SERVER_HOST : instance.host; -} - export interface ParsedServerOptions { host: string; port: number; @@ -133,40 +125,6 @@ export function normalizeServerOrigin(value: string): string { return url.toString().replace(/\/$/, ''); } -/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */ -export async function isServerHealthy(origin: string, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, timeoutMs); - try { - const response = await fetch(`${origin}/api/v1/healthz`, { - signal: controller.signal, - }); - if (!response.ok) return false; - const body = (await response.json()) as { code?: unknown }; - return body.code === 0; - } catch { - return false; - } finally { - clearTimeout(timeout); - } -} - -/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */ -export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - do { - if (await isServerHealthy(origin, 500)) { - return true; - } - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - } while (Date.now() < deadline); - return false; -} - /** * Read the persistent bearer token for the server. * diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index ce671d9e28..063bcd7bfe 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -393,8 +393,7 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'web', aliases: [], - description: - 'Open the current session in the Web UI — pick a running server or start a new one', + description: 'Open the current session in the Web UI by starting a new server', 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 f2decd8ea4..4a9035a32c 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -1,42 +1,23 @@ import chalk from 'chalk'; -import { listLiveServerInstances, type ServerInstanceInfo } from '@moonshot-ai/kap-server'; - import { splitTokenFragment } from '#/cli/sub/web/access-urls'; import { formatReadyBanner, startServerForeground } from '#/cli/sub/web/run'; -import { - instanceConnectHost, - isServerHealthy, - parseServerOptions, - serverOrigin, - tryResolveServerToken, -} from '#/cli/sub/web/shared'; -import { getVersion } from '#/cli/version'; +import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/web/shared'; import { openUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; -import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { darkColors } from '../theme/colors'; import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; -/** Picker value of the "start a new server" row (instance rows carry their serverId). */ -const NEW_SERVER_VALUE = '__new__'; - -/** How long to wait for the chosen server to answer `/healthz`. */ -const HEALTH_TIMEOUT_MS = 1500; - /** * `/web` — hand the current session off to the browser. * - * Lists the live server instances from the registry (with their versions) and - * lets the user pick one to open the session on, or start a new server — the - * new one runs in the foreground attached to this terminal after the TUI - * exits, taking the next free port alongside the running ones. With no - * instance running there is nothing to pick, so it starts a new server - * directly. Either way the TUI shuts down once the session deep link is - * opened. + * Always starts a new server: the TUI shuts down and this process becomes the + * server, running in the foreground attached to this terminal and taking the + * next free port alongside any running ones. The session deep link opens from + * the ready hook once the server is actually listening. */ export async function handleWebCommand(host: SlashCommandHost): Promise { const session = host.session; @@ -44,89 +25,11 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } - const sessionId = session.id; - - const instances = await listLiveServerInstances(); - if (instances.length === 0) { - // Nothing to pick: become the server right away, no picker needed. - startNewServerAfterExit(host, sessionId); - await host.stop(); - return; - } - const options: ChoiceOption[] = instances.map((instance) => ({ - value: instance.serverId, - label: serverOrigin(instanceConnectHost(instance), instance.port), - description: instanceDescription(instance), - descriptionTone: - instance.hostVersion !== undefined && instance.hostVersion !== getVersion() - ? 'warning' - : undefined, - })); - options.push({ - value: NEW_SERVER_VALUE, - label: 'Start a new server', - description: - 'Run a new server in the foreground on this terminal after the TUI exits (stop with Ctrl+C), then open the session deep link in your browser.', - }); - - const chosen = await new Promise((resolve) => { - const picker = new ChoicePickerComponent({ - title: 'Open current session in the Web UI?', - options, - onSelect: (value) => { - resolve(value); - }, - onCancel: () => { - resolve(undefined); - }, - }); - host.mountEditorReplacement(picker); - }); - host.restoreEditor(); - if (chosen === undefined) return; - - if (chosen === NEW_SERVER_VALUE) { - startNewServerAfterExit(host, sessionId); - await host.stop(); - return; - } - - const instance = instances.find((entry) => entry.serverId === chosen); - if (instance === undefined) return; - const origin = serverOrigin(instanceConnectHost(instance), instance.port); - if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { - host.showError(`Kimi server at ${origin} is not responding.`); - return; - } - - // Resolve the persistent token so the opened browser auto-authenticates via - // the `#token=` fragment — matching the `kimi web` command. 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()); - const url = webSessionUrl(origin, sessionId, token); - host.showStatus(`open ${url}`, 'success'); - if (token !== undefined) { - host.showStatus(`Token: ${token}`, 'success'); - } - openUrl(url); - host.setExitOpenUrl(url); + startNewServerAfterExit(host, session.id); await host.stop(); } -/** `version X · id Y` for the picker row; flags a CLI/server mismatch for the warning tone. */ -function instanceDescription(instance: ServerInstanceInfo): string { - if (instance.hostVersion === undefined) { - return `version unknown (registered by an older build) · id ${instance.serverId}`; - } - if (instance.hostVersion !== getVersion()) { - return `version ${instance.hostVersion} (this CLI: ${getVersion()}) · id ${instance.serverId}`; - } - return `version ${instance.hostVersion} · id ${instance.serverId}`; -} - /** * Register the exit takeover that turns this process into the new server once * the TUI has shut down (where `process.exit` would normally happen): the @@ -146,7 +49,7 @@ function startNewServerAfterExit(host: SlashCommandHost, sessionId: string): voi // gate. const token = tryResolveServerToken(getDataDir()); const url = webSessionUrl(origin, sessionId, token); - process.stdout.write(formatReadyBanner(origin, options.host, { token, foreground: true })); + process.stdout.write(formatReadyBanner(origin, options.host, { token })); process.stdout.write(`\n ${sessionLine(url)}\n`); openUrl(url); }, diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index 6532d4dbef..d23497b5d1 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -3,11 +3,11 @@ * * These tests don't actually start the server — the foreground runner is * injected, so they verify option parsing, the ready banner / one-line ready - * output, browser opening, and the kill / ps / rotate-token subcommands - * against fake deps. + * output, browser opening, and the rotate-token / deprecated `kimi server kill` + * subcommands against fake deps. */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -16,7 +16,7 @@ import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerWebCommand } from '#/cli/sub/web'; -import type { KillCommandDeps } from '#/cli/sub/web/kill'; +import type { LegacyKillDeps } from '#/cli/sub/web/legacy-kill'; import type { WebCommandDeps } from '#/cli/sub/web/run'; import type { ParsedServerOptions } from '#/cli/sub/web/shared'; import { darkColors } from '#/tui/theme/colors'; @@ -80,12 +80,13 @@ function makeIo(): { } describe('kimi web', () => { - it('registers the `web` command with the kill/ps/rotate-token subcommands', () => { + it('registers the `web` command with only the rotate-token subcommand', () => { const program = makeProgram(); const web = program.commands.find((c) => c.name() === 'web'); expect(web).toBeDefined(); const subs = web?.commands.map((c) => c.name()).toSorted(); - expect(subs).toEqual(['kill', 'ps', 'rotate-token']); + // Foreground servers stop with Ctrl+C, so there is no kill/ps. + expect(subs).toEqual(['rotate-token']); }); it('exposes the foreground server options on `web` itself', () => { @@ -116,7 +117,7 @@ describe('kimi web', () => { for (const argv of [ ['node', 'kimi', 'server'], ['node', 'kimi', 'server', 'run', '--port', '1'], - ['node', 'kimi', 'server', 'kill', 'abc'], + ['node', 'kimi', 'server', 'status'], ['node', 'kimi', 'server', 'ps', '--json'], ]) { const program = makeProgram(); @@ -138,6 +139,8 @@ describe('kimi web', () => { expect(exitCalls).toEqual([1]); expect(stderr).toContain('`kimi server` has been deprecated and no longer works.'); expect(stderr).toContain('kimi web'); + expect(stderr).toContain('kimi server kill'); + expect(stderr).toContain('0.28.0'); expect(stderr).toContain('next major version'); } }); @@ -173,7 +176,7 @@ describe('`kimi web` ready banner', () => { expect(plain).toContain('Logs:'); expect(plain).toContain('off'); expect(plain).toContain('Stop:'); - expect(plain).toContain('kimi web kill'); + expect(plain).toContain('Ctrl+C'); // No bordered panel (the token URL must print in full for copying), but // the Kimi sprite stays next to the title. expect(plain).not.toContain('╭'); @@ -257,7 +260,7 @@ describe('`kimi web` ready banner', () => { // Red, impossible-to-miss danger notice. expect(plain).toContain('DANGER: authentication is DISABLED'); expect(plain).toContain('--dangerous-bypass-auth'); - expect(plain).toContain('kimi web kill'); + expect(plain).toContain('Ctrl+C'); // The token is irrelevant when bypassed — neither printed nor carried in // any URL (so it cannot leak via copy/paste of the banner). expect(plain).not.toContain('tok'); @@ -541,46 +544,24 @@ describe('server web asset directory resolution', () => { }); }); -describe('instanceConnectHost (M6.2 connect side)', () => { - it('maps a 0.0.0.0 bind to 127.0.0.1 so the CLI connects over loopback', async () => { - const { instanceConnectHost } = await import('#/cli/sub/web/shared'); - // The server binds 0.0.0.0 (all interfaces), but the local CLI must - // connect over loopback — 0.0.0.0 is not a connectable address. The token - // then rides on that loopback connection (covered by the kill/ps - // Authorization tests). - expect( - instanceConnectHost({ - serverId: 'srv', - pid: 1, - startedAt: 0, - heartbeatAt: 0, - port: 58627, - host: '0.0.0.0', - }), - ).toBe('127.0.0.1'); - }); - - it('preserves a loopback / concrete bind host', async () => { - const { instanceConnectHost } = await import('#/cli/sub/web/shared'); - const base = { serverId: 'srv', pid: 1, startedAt: 0, heartbeatAt: 0, port: 58627 }; - expect(instanceConnectHost({ ...base, host: '127.0.0.1' })).toBe('127.0.0.1'); - expect(instanceConnectHost({ ...base, host: '192.168.1.5' })).toBe('192.168.1.5'); - }); -}); - -function makeKillDeps(overrides: Partial = {}): { - deps: KillCommandDeps; +function makeLegacyKillDeps(overrides: Partial = {}): { + deps: LegacyKillDeps; writes: string[]; + errors: string[]; signals: Array<{ pid: number; signal: NodeJS.Signals }>; - state: { shutdownCalls: number }; + state: { shutdownCalls: number; removeCalls: number }; clock: { t: number }; } { const writes: string[] = []; + const errors: string[] = []; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; - const state = { shutdownCalls: 0 }; + const state = { shutdownCalls: 0, removeCalls: 0 }; const clock = { t: 0 }; - const deps: KillCommandDeps = { - getLiveInstances: async () => [], + const deps: LegacyKillDeps = { + readLock: async () => undefined, + removeLock: async () => { + state.removeCalls += 1; + }, requestShutdown: async () => { state.shutdownCalls += 1; }, @@ -599,56 +580,91 @@ function makeKillDeps(overrides: Partial = {}): { return true; }, }, + stderr: { + write(chunk: string | Uint8Array) { + errors.push(String(chunk)); + return true; + }, + }, now: () => clock.t, ...overrides, }; - return { deps, writes, signals, state, clock }; + return { deps, writes, errors, signals, state, clock }; } -describe('`kimi web kill`', () => { - const liveInstance = { - serverId: 'srv-1', - pid: 1234, - host: '127.0.0.1', - port: 58627, - startedAt: 1000, - heartbeatAt: 1000, - }; +describe('`kimi server kill` (deprecated, legacy servers only)', () => { + const legacyLock = { pid: 1234, host: '127.0.0.1', port: 58627 }; - it('prints "No running Kimi server." and sends no signal when no live instance exists', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const { deps, writes, signals } = makeKillDeps({ getLiveInstances: async () => [] }); + it('is registered as the only working subcommand of the deprecated `server` command', () => { + const program = makeProgram(); + const server = program.commands.find((c) => c.name() === 'server'); + expect(server).toBeDefined(); + expect(server?.commands.map((c) => c.name())).toEqual(['kill']); + }); + + it('prints a deprecation notice naming the 0.28.0 cutoff on every run', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, errors } = makeLegacyKillDeps(); - await handleKillCommand(deps); + await handleLegacyKillCommand(deps); + + const notice = errors.join(''); + expect(notice).toContain('deprecated'); + expect(notice).toContain('0.28.0'); + expect(notice).toContain('Ctrl+C'); + }); - expect(writes.join('')).toContain('No running Kimi server.'); + it('prints "No running legacy Kimi server." and sends no signal when no lock exists', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals } = makeLegacyKillDeps({ readLock: async () => undefined }); + + await handleLegacyKillCommand(deps); + + expect(writes.join('')).toContain('No running legacy Kimi server.'); + expect(signals).toEqual([]); + }); + + it('sweeps a stale lock whose pid is already dead', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals, state } = makeLegacyKillDeps({ + readLock: async () => legacyLock, + pidAlive: () => false, + }); + + await handleLegacyKillCommand(deps); + + expect(writes.join('')).toContain('No running legacy Kimi server.'); expect(signals).toEqual([]); + expect(state.shutdownCalls).toBe(0); + expect(state.removeCalls).toBe(1); }); it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const { deps, writes, signals, state, clock } = makeKillDeps({ - getLiveInstances: async () => [liveInstance], + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals, state, clock } = makeLegacyKillDeps({ + readLock: async () => legacyLock, pidAlive: () => clock.t < 50, }); - await handleKillCommand(deps); + await handleLegacyKillCommand(deps); expect(state.shutdownCalls).toBe(1); expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); expect(writes.join('')).toContain('pid 1234'); expect(writes.join('')).toContain('stopped.'); + // The lock is removed once the pid is confirmed dead. + expect(state.removeCalls).toBe(1); }); it('escalates to SIGKILL when the pid survives SIGTERM', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const { deps, writes, signals, clock } = makeKillDeps({ - getLiveInstances: async () => [{ ...liveInstance, pid: 5678 }], + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals, clock } = makeLegacyKillDeps({ + readLock: async () => ({ ...legacyLock, pid: 5678 }), // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. pidAlive: () => clock.t < 3100, }); - await handleKillCommand(deps); + await handleLegacyKillCommand(deps); expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); expect(writes.join('')).toContain('pid 5678'); @@ -656,81 +672,105 @@ describe('`kimi web kill`', () => { }); it('throws a permissions error when the pid survives SIGKILL', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const { deps } = makeKillDeps({ - getLiveInstances: async () => [{ ...liveInstance, pid: 9999 }], + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps } = makeLegacyKillDeps({ + readLock: async () => ({ ...legacyLock, pid: 9999 }), pidAlive: () => true, }); - await expect(handleKillCommand(deps)).rejects.toThrow(/insufficient permissions/); + await expect(handleLegacyKillCommand(deps)).rejects.toThrow(/insufficient permissions/); }); - it('targets only the instance matching the given server-id', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const other = { ...liveInstance, serverId: 'srv-2', pid: 5678, port: 58628 }; - const { deps, writes, signals } = makeKillDeps({ - getLiveInstances: async () => [liveInstance, other], - pidAlive: () => false, + it('skips the API path when the lock records no port', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, signals, state, clock } = makeLegacyKillDeps({ + readLock: async () => ({ pid: 1234 }), + // Alive at the initial check, dead when the SIGTERM grace polls. + pidAlive: () => clock.t < 50, }); - await handleKillCommand(deps, 'srv-2'); + await handleLegacyKillCommand(deps); - expect(signals).toEqual([{ pid: 5678, signal: 'SIGTERM' }]); - expect(writes.join('')).toContain('pid 5678'); + expect(state.shutdownCalls).toBe(0); + expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); }); - it('throws and lists live server ids when the given server-id matches nothing', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const { deps, signals } = makeKillDeps({ - getLiveInstances: async () => [liveInstance], + it('passes the resolved token to requestShutdown', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + let seenToken: string | undefined = 'unset'; + const { deps, clock } = makeLegacyKillDeps({ + readLock: async () => legacyLock, + resolveToken: () => 'tok-123', + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => clock.t < 50, }); - await expect(handleKillCommand(deps, 'srv-nope')).rejects.toThrow( - /No running Kimi server with id srv-nope\. Live servers: srv-1\./, - ); - expect(signals).toEqual([]); + await handleLegacyKillCommand(deps); + + expect(seenToken).toBe('tok-123'); }); - it('kills every live instance when given the `all` keyword', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const other = { ...liveInstance, serverId: 'srv-2', pid: 5678, port: 58628 }; - const { deps, writes, signals, state } = makeKillDeps({ - getLiveInstances: async () => [liveInstance, other], - pidAlive: () => false, + it('passes undefined when the token cannot be read (best-effort)', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + let seenToken: string | undefined = 'unset'; + const { deps, clock } = makeLegacyKillDeps({ + readLock: async () => legacyLock, + resolveToken: () => undefined, + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => clock.t < 50, }); - await handleKillCommand(deps, 'all'); + await handleLegacyKillCommand(deps); - expect(state.shutdownCalls).toBe(2); - expect(signals).toEqual([ - { pid: 1234, signal: 'SIGTERM' }, - { pid: 5678, signal: 'SIGTERM' }, - ]); - const out = writes.join(''); - expect(out).toContain('server srv-1 (pid 1234) stopped.'); - expect(out).toContain('server srv-2 (pid 5678) stopped.'); + expect(seenToken).toBeUndefined(); }); +}); - it('continues past a failed instance and reports the failure at the end', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - const wedged = { ...liveInstance, serverId: 'srv-2', pid: 9999, port: 58628 }; - const { deps, writes, signals } = makeKillDeps({ - getLiveInstances: async () => [liveInstance, wedged], - // srv-1 dies on SIGTERM; srv-2 survives everything. - pidAlive: (pid) => pid === 9999, - }); +describe('readLegacyLock', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-legacy-lock-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); - await expect(handleKillCommand(deps, 'all')).rejects.toThrow( - /server srv-2: Failed to stop Kimi server \(pid 9999\); insufficient permissions\?/, + it('parses a lock written by an old build', async () => { + const { readLegacyLock } = await import('#/cli/sub/web/legacy-kill'); + const lockPath = join(dir, 'lock'); + writeFileSync( + lockPath, + JSON.stringify({ pid: 1234, started_at: '2026-01-01T00:00:00.000Z', port: 58627 }), ); - // The healthy instance was still stopped before the error surfaced. - expect(signals).toEqual([ - { pid: 1234, signal: 'SIGTERM' }, - { pid: 9999, signal: 'SIGTERM' }, - { pid: 9999, signal: 'SIGKILL' }, - ]); - expect(writes.join('')).toContain('server srv-1 (pid 1234) stopped.'); + await expect(readLegacyLock(lockPath)).resolves.toEqual({ + pid: 1234, + host: undefined, + port: 58627, + }); + }); + + it('rejects a corrupt lock whose pid is not a positive integer', async () => { + const { readLegacyLock } = await import('#/cli/sub/web/legacy-kill'); + const lockPath = join(dir, 'lock'); + // pid 0 / negative pids have process-group semantics on POSIX — the lock + // must be treated as unusable rather than signaled. + for (const pid of [0, -1, 1.5, '1234']) { + writeFileSync(lockPath, JSON.stringify({ pid, port: 58627 })); + await expect(readLegacyLock(lockPath)).resolves.toBeUndefined(); + } + }); + + it('returns undefined when the lock file is missing or unparseable', async () => { + const { readLegacyLock } = await import('#/cli/sub/web/legacy-kill'); + await expect(readLegacyLock(join(dir, 'missing'))).resolves.toBeUndefined(); + const lockPath = join(dir, 'lock'); + writeFileSync(lockPath, 'not json'); + await expect(readLegacyLock(lockPath)).resolves.toBeUndefined(); }); }); @@ -768,204 +808,6 @@ describe('authHeaders', () => { }); }); -describe('`kimi web kill` carries the bearer token', () => { - const liveInstance = { - serverId: 'srv-1', - pid: 1234, - host: '127.0.0.1', - port: 58627, - startedAt: 1000, - heartbeatAt: 1000, - }; - - it('passes the resolved token to requestShutdown', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - let seenToken: string | undefined = 'unset'; - const { deps } = makeKillDeps({ - getLiveInstances: async () => [liveInstance], - resolveToken: () => 'tok-123', - requestShutdown: async (_origin, token) => { - seenToken = token; - }, - pidAlive: () => false, - }); - - await handleKillCommand(deps); - - expect(seenToken).toBe('tok-123'); - }); - - it('passes undefined when the token cannot be read (best-effort)', async () => { - const { handleKillCommand } = await import('#/cli/sub/web/kill'); - let seenToken: string | undefined = 'unset'; - const { deps } = makeKillDeps({ - getLiveInstances: async () => [liveInstance], - resolveToken: () => undefined, - requestShutdown: async (_origin, token) => { - seenToken = token; - }, - pidAlive: () => false, - }); - - await handleKillCommand(deps); - - expect(seenToken).toBeUndefined(); - }); -}); - -describe('`kimi web ps`', () => { - let dir: string; - let prevHome: string | undefined; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'kimi-ps-')); - prevHome = process.env['KIMI_CODE_HOME']; - process.env['KIMI_CODE_HOME'] = dir; - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - if (prevHome === undefined) { - delete process.env['KIMI_CODE_HOME']; - } else { - process.env['KIMI_CODE_HOME'] = prevHome; - } - rmSync(dir, { recursive: true, force: true }); - }); - - function writeInstance(serverId: string, port: number, startedAt: number): void { - mkdirSync(join(dir, 'server', 'instances'), { recursive: true }); - writeFileSync( - join(dir, 'server', 'instances', `${serverId}.json`), - JSON.stringify({ - server_id: serverId, - pid: process.pid, - host: '127.0.0.1', - port, - started_at: startedAt, - heartbeat_at: startedAt, - }), - ); - } - - function connection(id: string, userAgent: string): Record { - return { - id, - connected_at: new Date().toISOString(), - remote_address: null, - user_agent: userAgent, - has_client_hello: true, - subscriptions: [], - }; - } - - function stubFetchForTwoServers(): void { - vi.stubGlobal('fetch', async (input: unknown) => { - const url = String(input); - if (url.endsWith('/api/v1/healthz')) { - return new Response(JSON.stringify({ code: 0 }), { status: 200 }); - } - if (url === 'http://127.0.0.1:58627/api/v1/connections') { - return new Response( - JSON.stringify({ - code: 0, - msg: 'ok', - data: { connections: [connection('conn-a', 'agent-a')] }, - }), - { status: 200 }, - ); - } - if (url === 'http://127.0.0.1:58628/api/v1/connections') { - return new Response( - JSON.stringify({ - code: 0, - msg: 'ok', - data: { connections: [connection('conn-b', 'agent-b')] }, - }), - { status: 200 }, - ); - } - return new Response('not found', { status: 404 }); - }); - } - - function captureStdout(): { read(): string; restore(): void } { - let stdout = ''; - const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { - stdout += String(chunk); - return true; - }); - return { - read: () => stdout, - restore: () => spy.mockRestore(), - }; - } - - it('lists connections grouped by server id, oldest instance first', async () => { - writeFileSync(join(dir, 'server.token'), 'tok'); - writeInstance('srv-a', 58627, 1000); - writeInstance('srv-b', 58628, 2000); - stubFetchForTwoServers(); - - const { registerWebCommand } = await import('#/cli/sub/web'); - const program = new Command('kimi').exitOverride(); - registerWebCommand(program); - const out = captureStdout(); - - await program.parseAsync(['node', 'kimi', 'web', 'ps']); - out.restore(); - - const plain = stripAnsi(out.read()); - expect(plain).toContain('server srv-a (pid '); - expect(plain).toContain('server srv-b (pid '); - // Oldest instance first, each connection under its own server section. - expect(plain.indexOf('server srv-a')).toBeLessThan(plain.indexOf('agent-a')); - expect(plain.indexOf('agent-a')).toBeLessThan(plain.indexOf('server srv-b')); - expect(plain.indexOf('server srv-b')).toBeLessThan(plain.indexOf('agent-b')); - }); - - it('prints per-server sections in --json', async () => { - writeFileSync(join(dir, 'server.token'), 'tok'); - writeInstance('srv-a', 58627, 1000); - writeInstance('srv-b', 58628, 2000); - stubFetchForTwoServers(); - - const { registerWebCommand } = await import('#/cli/sub/web'); - const program = new Command('kimi').exitOverride(); - registerWebCommand(program); - const out = captureStdout(); - - await program.parseAsync(['node', 'kimi', 'web', 'ps', '--json']); - out.restore(); - - const parsed = JSON.parse(out.read()) as { - servers: Array<{ server_id: string; connections: Array<{ id: string }> }>; - }; - expect(parsed.servers.map((s) => s.server_id)).toEqual(['srv-a', 'srv-b']); - expect(parsed.servers[0]?.connections.map((c) => c.id)).toEqual(['conn-a']); - expect(parsed.servers[1]?.connections.map((c) => c.id)).toEqual(['conn-b']); - }); - - it('errors when no server is running', async () => { - const { registerWebCommand } = await import('#/cli/sub/web'); - const program = new Command('kimi').exitOverride(); - registerWebCommand(program); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); - let stderr = ''; - const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { - stderr += String(chunk); - return true; - }); - - await program.parseAsync(['node', 'kimi', 'web', 'ps']); - errSpy.mockRestore(); - exitSpy.mockRestore(); - - expect(stderr).toContain('No running Kimi server.'); - }); -}); - describe('buildWebUrl', () => { it('carries the token in the URL fragment (not path or query)', async () => { const { buildWebUrl } = await import('#/cli/sub/web/run'); diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index 55f11b17ef..92a01a4830 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -1,24 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getVersion } from '#/cli/version'; 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(() => ({ - listLiveServerInstances: vi.fn(), startServerForeground: vi.fn(), - isServerHealthy: vi.fn(), tryResolveServerToken: vi.fn(), getDataDir: vi.fn(() => '/tmp/kimi-home'), openUrl: vi.fn(), })); -vi.mock('@moonshot-ai/kap-server', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, listLiveServerInstances: mocks.listLiveServerInstances }; -}); - vi.mock('#/cli/sub/web/run', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, startServerForeground: mocks.startServerForeground }; @@ -28,7 +20,6 @@ vi.mock('#/cli/sub/web/shared', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - isServerHealthy: mocks.isServerHealthy, tryResolveServerToken: mocks.tryResolveServerToken, }; }); @@ -43,29 +34,12 @@ vi.mock('#/utils/paths', async (importOriginal) => { return { ...actual, getDataDir: mocks.getDataDir }; }); -type MountedPanel = { - handleInput: (data: string) => void; - render: (width: number) => string[]; -}; - -const INSTANCE_SRV_1 = { - serverId: 'srv-1', - pid: 1234, - host: '127.0.0.1', - port: 58627, - startedAt: 1, - heartbeatAt: 1, -}; - function makeHost() { - let mountedPanel: MountedPanel | null = null; const host = { session: { id: 'ses-1' }, showStatus: vi.fn(), showError: vi.fn(), - mountEditorReplacement: vi.fn((panel: MountedPanel) => { - mountedPanel = panel; - }), + mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), setExitOpenUrl: vi.fn(), setExitForegroundTask: vi.fn(), @@ -79,7 +53,7 @@ function makeHost() { setExitForegroundTask: ReturnType; stop: ReturnType; }; - return { host, getMountedPanel: () => mountedPanel }; + return host; } describe('web slash command', () => { @@ -94,148 +68,31 @@ describe('handleWebCommand', () => { beforeEach(() => { vi.clearAllMocks(); mocks.getDataDir.mockReturnValue('/tmp/kimi-home'); - mocks.listLiveServerInstances.mockResolvedValue([INSTANCE_SRV_1]); - mocks.isServerHealthy.mockResolvedValue(true); }); - 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); - await vi.waitFor(() => { - expect(getMountedPanel()).not.toBeNull(); - }); - getMountedPanel()?.handleInput('\r'); - await pending; + it('shows an error and does nothing when there is no active session', async () => { + const host = makeHost(); + host.session = undefined; - 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(); - }); - - 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); - await vi.waitFor(() => { - expect(getMountedPanel()).not.toBeNull(); - }); - getMountedPanel()?.handleInput('\r'); - await pending; - - 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('opens the second instance when the user moves the cursor to it', async () => { - mocks.tryResolveServerToken.mockReturnValue(undefined); - mocks.listLiveServerInstances.mockResolvedValue([ - INSTANCE_SRV_1, - { ...INSTANCE_SRV_1, serverId: 'srv-2', port: 58628 }, - ]); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host); - await vi.waitFor(() => { - expect(getMountedPanel()).not.toBeNull(); - }); - getMountedPanel()?.handleInput('\u001B[B'); - getMountedPanel()?.handleInput('\r'); - await pending; - - expect(mocks.isServerHealthy).toHaveBeenCalledWith('http://127.0.0.1:58628', expect.any(Number)); - expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58628/sessions/ses-1'); - expect(host.stop).toHaveBeenCalledOnce(); - }); - - it('lists each instance with its version, flagging a CLI mismatch', async () => { - mocks.listLiveServerInstances.mockResolvedValue([ - { ...INSTANCE_SRV_1, hostVersion: '0.0.1-outdated' }, - ]); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host); - await vi.waitFor(() => { - expect(getMountedPanel()).not.toBeNull(); - }); - const lines = getMountedPanel()!.render(80).join('\n'); - getMountedPanel()?.handleInput('\u001B'); - await pending; - - expect(lines).toContain('http://127.0.0.1:58627'); - expect(lines).toContain(`version 0.0.1-outdated (this CLI: ${getVersion()})`); - expect(lines).toContain('Start a new server'); - }); - - it('shows an error and does not exit when the chosen server is unhealthy', async () => { - mocks.isServerHealthy.mockResolvedValue(false); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host); - await vi.waitFor(() => { - expect(getMountedPanel()).not.toBeNull(); - }); - getMountedPanel()?.handleInput('\r'); - await pending; - - expect(host.showError).toHaveBeenCalledWith( - 'Kimi server at http://127.0.0.1:58627 is not responding.', - ); - expect(mocks.openUrl).not.toHaveBeenCalled(); - expect(host.stop).not.toHaveBeenCalled(); - }); - - it('does nothing on cancel', async () => { - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host); - await vi.waitFor(() => { - expect(getMountedPanel()).not.toBeNull(); - }); - getMountedPanel()?.handleInput('\u001B'); - await pending; + await handleWebCommand(host); - expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(host.showError).toHaveBeenCalledOnce(); expect(host.setExitForegroundTask).not.toHaveBeenCalled(); expect(host.stop).not.toHaveBeenCalled(); }); - it('registers a foreground takeover when the user picks "Start a new server"', async () => { - const { host, getMountedPanel } = makeHost(); + it('registers a foreground takeover and stops the TUI without opening a URL yet', async () => { + const host = makeHost(); - const pending = handleWebCommand(host); - await vi.waitFor(() => { - expect(getMountedPanel()).not.toBeNull(); - }); - // One instance row, then "Start a new server". - getMountedPanel()?.handleInput('\u001B[B'); - getMountedPanel()?.handleInput('\r'); - await pending; + await handleWebCommand(host); expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); expect(host.stop).toHaveBeenCalledOnce(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); expect(mocks.openUrl).not.toHaveBeenCalled(); - expect(mocks.isServerHealthy).not.toHaveBeenCalled(); }); - it('starts a new server directly when no instance is running, opening the deep link on ready', async () => { - mocks.listLiveServerInstances.mockResolvedValue([]); + it('starts the new server on takeover, printing the banner and opening the deep link', async () => { mocks.tryResolveServerToken.mockReturnValue('tok-1'); const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); mocks.startServerForeground.mockImplementation( @@ -243,16 +100,9 @@ describe('handleWebCommand', () => { hooks.onReady?.('http://127.0.0.1:58627'); }, ); - const { host, getMountedPanel } = makeHost(); + const host = makeHost(); await handleWebCommand(host); - - // No picker: the takeover is registered and the TUI stops right away. - expect(getMountedPanel()).toBeNull(); - expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); - expect(host.stop).toHaveBeenCalledOnce(); - expect(mocks.openUrl).not.toHaveBeenCalled(); - const task = host.setExitForegroundTask.mock.calls[0]![0] as ( exitCode: number, ) => Promise; diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 5c7854c259..90999b98bf 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -167,20 +167,16 @@ Multiple instances can share one home directory: each registers itself under `~/ `kimi web` binds to local loopback only by default and prints the bearer token in the startup banner; the web UI authenticates automatically via the `#token=` URL fragment. ::: info -The `kimi server` command tree is deprecated: any `kimi server …` invocation (including all legacy subcommands) only prints a deprecation notice and exits with code 1 — use `kimi web` instead. The notice will be removed in the next major version of Kimi Code. +The `kimi server` command tree is deprecated: any `kimi server …` invocation (including all legacy subcommands) only prints a deprecation notice and exits with code 1 — use `kimi web` instead. The one exception is `kimi server kill`, which stays functional for stopping servers started by a version before 0.28.0. The notice will be removed in the next major version of Kimi Code. ::: ::: danger -`--dangerous-bypass-auth` disables authentication entirely. Anyone who can reach the port gets full access to your sessions, filesystem, and shell. Only use it on a trusted network or behind your own authenticating reverse proxy, and stop the server with Ctrl+C when you are done (or run `kimi web kill ` from another terminal). +`--dangerous-bypass-auth` disables authentication entirely. Anyone who can reach the port gets full access to your sessions, filesystem, and shell. Only use it on a trusted network or behind your own authenticating reverse proxy, and stop the server with `Ctrl+C` when you are done. ::: -#### `kimi web kill [server-id|all]` +#### `kimi server kill` -Stop a running server instance: first tries `POST /api/v1/shutdown` for a graceful exit, then signals the instance pid with SIGTERM, escalating to SIGKILL when needed. With multiple instances sharing the home directory, `[server-id]` picks the target; without it the longest-running instance is stopped; the special keyword `all` stops every live instance; an unknown id errors with the live instance ids listed. - -#### `kimi web ps` - -List the clients currently connected to each instance (from `GET /api/v1/connections`), grouped by server id; `--json` prints the raw data nested per instance. +Deprecated — only stops a server started by a version before 0.28.0. Those versions could leave a background server behind, recorded in the legacy single-instance lock at `~/.kimi-code/server/lock`; the command first tries `POST /api/v1/shutdown` for a graceful exit, then signals the recorded pid with SIGTERM, escalating to SIGKILL when needed, and removes the lock file once the process is confirmed dead. Servers started by `kimi web` run in the foreground — stop them with `Ctrl+C` instead. #### `kimi web rotate-token` diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 092dc34b73..8783318dc5 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -167,20 +167,16 @@ kimi web --port 58628 # 指定绑定端口 `kimi web` 默认只绑定本机 loopback 地址,并在启动横幅中打印 bearer token;web UI 通过 URL 的 `#token=` 片段自动完成鉴权。 ::: info 提示 -`kimi server` 命令树已废弃:任何 `kimi server …` 调用(含全部旧子命令)只会打印弃用提示并以退出码 1 结束,请改用 `kimi web`。该提示将在 Kimi Code 下个大版本移除。 +`kimi server` 命令树已废弃:任何 `kimi server …` 调用(含全部旧子命令)只会打印弃用提示并以退出码 1 结束,请改用 `kimi web`。唯一的例外是 `kimi server kill`,它仍然可用,仅用于停止 0.28.0 之前版本启动的服务。该提示将在 Kimi Code 下个大版本移除。 ::: ::: danger 警告 -`--dangerous-bypass-auth` 会彻底关闭鉴权。任何能访问该端口的人都能完全控制你的会话、文件系统和 shell。请仅在可信网络或自有鉴权反向代理之后使用,用完后按 Ctrl+C 停止服务(或在另一个终端运行 `kimi web kill `)。 +`--dangerous-bypass-auth` 会彻底关闭鉴权。任何能访问该端口的人都能完全控制你的会话、文件系统和 shell。请仅在可信网络或自有鉴权反向代理之后使用,用完后按 `Ctrl+C` 停止服务。 ::: -#### `kimi web kill [server-id|all]` +#### `kimi server kill` -停止运行中的服务实例:先请求 `POST /api/v1/shutdown` 优雅退出,再对实例 pid 发 SIGTERM、必要时升级为 SIGKILL。多实例并存时用 `[server-id]` 指定目标;缺省停止存活最久的实例;传入特殊关键字 `all` 停止全部实例;id 不存在时报错并列出所有存活实例 id。 - -#### `kimi web ps` - -按 server-id 分组列出每个实例当前连接的客户端(来自 `GET /api/v1/connections`);`--json` 输出按实例嵌套的原始数据。 +已废弃——仅用于停止 0.28.0 之前的 Kimi Code 版本启动的服务。那些版本可能在后台遗留服务进程,记录在 legacy 单实例锁文件 `~/.kimi-code/server/lock` 中;该命令先请求 `POST /api/v1/shutdown` 优雅退出,再对锁中记录的 pid 发 SIGTERM、必要时升级为 SIGKILL,并在确认进程退出后删除锁文件。`kimi web` 启动的服务在前台运行,直接用 `Ctrl+C` 停止即可。 #### `kimi web rotate-token`