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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/agent-core-dev/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Gate not-yet-public features behind `IFlagService.enabled(id)`, per the reposito
- `src/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod).
- `src/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope.
- `src/flag/index.ts` — **removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`).
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts` or `src/agent/faultInjection/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.

## Public surface

Expand Down
6 changes: 0 additions & 6 deletions .changeset/minidb-durability-hardening.md

This file was deleted.

6 changes: 0 additions & 6 deletions .changeset/minidb-perf-hardening.md

This file was deleted.

6 changes: 0 additions & 6 deletions .changeset/minidb-reader-catchup.md

This file was deleted.

6 changes: 0 additions & 6 deletions .changeset/minidb-review-hardening.md

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/multi-server-default-shared-home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Multiple servers can now share one Kimi home directory by default: a second `kimi server run` or `kimi web` starts on the next free port instead of failing, and `kimi server ps` / `kimi server kill` discover running servers through the shared instance registry.
2 changes: 1 addition & 1 deletion apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
"dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
"dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
"dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
"dev:kap-server:multi": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
"dev:server:restart": "node scripts/dev-server-restart.mjs",
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
Expand Down
62 changes: 33 additions & 29 deletions apps/kimi-code/src/cli/sub/server/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
/**
* `kimi web` daemon orchestration — parent (spawner) side.
*
* Ensures a single background server daemon exists for this device, then
* returns its origin so the caller can open the web UI. The flow:
* Ensures a background server daemon exists for this device, then returns its
* origin so the caller can open the web UI. The flow:
*
* 1. Read `~/.kimi-code/server/lock`. If it names a *live* daemon, reuse it
* (wait for it to be healthy) — never spawn a second one.
* 1. Read the instance registry (`~/.kimi-code/server/instances/`). If it
* names a *live* daemon, reuse it (wait for it to be healthy) — never
* spawn a second one.
* 2. Otherwise pick a free port (preferred port when available, else an
* OS-assigned one) and spawn `kimi server run --daemon` as a detached
* child whose stdio is redirected to the server log.
* 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's
* that won the lock) is healthy, then return its origin.
* 3. Poll the registry until *some* live daemon (ours, or a concurrent
* racer's that registered first) is healthy, then return its origin.
*
* The child side (`startServerDaemon`) lives in `./run.ts` next to the
* foreground runner so it can share the same bootstrap helpers.
Expand All @@ -22,7 +23,11 @@ import { createRequire } from 'node:module';
import { createServer } from 'node:net';
import { dirname, isAbsolute, join, resolve } from 'node:path';

import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/kap-server';
import {
DEFAULT_SERVER_DIR,
getLiveServerInstance,
type ServerInstanceInfo,
} from '@moonshot-ai/kap-server';

import {
DEFAULT_SERVER_HOST,
Expand Down Expand Up @@ -73,20 +78,19 @@ export interface EnsureDaemonResult {
readonly origin: string;
/** True when an already-running daemon was reused (no new server started). */
readonly reused: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
/** Bind host the running daemon is actually listening on (from the registry). */
readonly host: string;
/** Port the running daemon is actually listening on (from the lock). */
/** Port the running daemon is actually listening on (from the registry). */
readonly port: number;
}

/** Path of the daemon log file (shared with the OS-service log location). */
export function daemonLogPath(): string {
return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME);
return join(DEFAULT_SERVER_DIR, SERVER_LOG_FILENAME);
}

export function lockConnectHost(lock: LockContents): string {
const host = lock.host ?? LOCAL_SERVER_HOST;
return host === '0.0.0.0' ? LOCAL_SERVER_HOST : host;
export function instanceConnectHost(instance: ServerInstanceInfo): string {
return instance.host === '0.0.0.0' ? LOCAL_SERVER_HOST : instance.host;
}

/** True when `host:port` is currently free to bind (nothing listening). */
Expand Down Expand Up @@ -305,21 +309,21 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
const preferred = options.port ?? DEFAULT_SERVER_PORT;
const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL;

// 1. Reuse an already-live daemon if one holds the lock.
const existing = getLiveLock();
// 1. Reuse an already-live daemon if one is registered.
const existing = await getLiveServerInstance();
if (existing) {
const origin = serverOrigin(lockConnectHost(existing), existing.port);
const origin = serverOrigin(instanceConnectHost(existing), existing.port);
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
return {
origin,
reused: true,
host: existing.host ?? DEFAULT_SERVER_HOST,
host: existing.host,
port: existing.port,
};
}
// Live pid but not responding (wedged or mid-boot failure). Fall through
// and spawn: if it is truly wedged our child loses the lock race and we
// reconnect below; if it died, stale takeover lets our child claim it.
// and spawn: our child registers itself alongside it (walking to the next
// free port), and the poll below reconnects once either is healthy.
}

// 2. No reusable daemon — pick a free port and spawn one detached.
Expand All @@ -338,11 +342,11 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
idleGraceMs: options.idleGraceMs,
});

// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate,
// a config error, or a lost lock race with no other daemon to fall back to)
// surfaces the real error immediately instead of waiting out the full spawn
// timeout. The exit code/signal plus a tail of the daemon log is what tells
// the operator *why* it failed.
// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate
// or a config error, with no other daemon to fall back to) surfaces the real
// error immediately instead of waiting out the full spawn timeout. The exit
// code/signal plus a tail of the daemon log is what tells the operator *why*
// it failed.
let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
child.once('exit', (code, signal) => {
childExit = { code, signal };
Expand All @@ -353,23 +357,23 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
childExit = { code: -1, signal: null };
});

// 3. Wait until some live daemon (ours, or a racer that won the lock) is up.
// 3. Wait until some live daemon (ours, or a racer that registered first) is up.
const deadline = Date.now() + SPAWN_TIMEOUT_MS;
while (Date.now() < deadline) {
const live = getLiveLock();
const live = await getLiveServerInstance();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Probe every registered daemon before timing out

When an older registry entry has a live pid but is not answering /healthz, this loop keeps selecting only that oldest entry because getLiveServerInstance() returns live[0]. After spawning a replacement, the new healthy daemon has a later startedAt, so kimi web/server run will ignore it until the 20s timeout and leave the user with a failed startup despite a usable server being registered. In this fallback path, iterate listLiveServerInstances() and return the first healthy origin instead of polling only the oldest entry.

Useful? React with 👍 / 👎.

if (live) {
const origin = serverOrigin(lockConnectHost(live), live.port);
const origin = serverOrigin(instanceConnectHost(live), live.port);
if (await isServerHealthy(origin, 500)) {
return {
origin,
reused: false,
host: live.host ?? DEFAULT_SERVER_HOST,
host: live.host,
port: live.port,
};
}
}
if (childExit !== undefined && !live) {
// Our child exited and no other live daemon holds the lock to fall back
// Our child exited and no other live daemon is registered to fall back
// to — this is a real boot failure, not a lost race.
throw new Error(formatDaemonBootFailure(childExit, daemonLogPath()));
}
Expand Down
28 changes: 15 additions & 13 deletions apps/kimi-code/src/cli/sub/server/kill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@
*
* 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 lock (SIGTERM → wait
* SIGKILL). SIGKILL / TerminateProcess is the hard guarantee:
* it cannot be caught or ignored.
* 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.
*
* The only honest failure mode is insufficient permissions (a process owned by
* another user), which surfaces as an error rather than a silent miss.
* With multiple servers sharing the home directory, the longest-running live
* instance is the target. 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 { getLiveLock, type LockContents } from '@moonshot-ai/kap-server';
import { getLiveServerInstance, type ServerInstanceInfo } from '@moonshot-ai/kap-server';

import { getDataDir } from '#/utils/paths';

import { lockConnectHost } from './daemon';
import { instanceConnectHost } from './daemon';
import { authHeaders, serverOrigin, tryResolveServerToken } from './shared';

/** How long to wait for the graceful API shutdown request. */
Expand All @@ -33,7 +35,7 @@ const KILL_GRACE_MS = 2000;
const POLL_INTERVAL_MS = 100;

export interface KillCommandDeps {
getLiveLock(): LockContents | undefined;
getLiveInstance(): Promise<ServerInstanceInfo | undefined>;
requestShutdown(origin: string, token: string | undefined): Promise<void>;
/** Best-effort read of the persistent bearer token; undefined on miss. */
resolveToken(): string | undefined;
Expand All @@ -59,14 +61,14 @@ export function registerKillCommand(server: Command): void {
}

export async function handleKillCommand(deps: KillCommandDeps): Promise<void> {
const lock = deps.getLiveLock();
if (!lock) {
const instance = await deps.getLiveInstance();
if (!instance) {
deps.stdout.write('No running Kimi server.\n');
return;
}

const { pid } = lock;
const origin = serverOrigin(lockConnectHost(lock), lock.port);
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
Expand Down Expand Up @@ -153,7 +155,7 @@ export async function requestShutdownViaApi(
}

const DEFAULT_KILL_DEPS: KillCommandDeps = {
getLiveLock,
getLiveInstance: getLiveServerInstance,
requestShutdown: requestShutdownViaApi,
resolveToken: () => tryResolveServerToken(getDataDir()),
signalPid,
Expand Down
12 changes: 6 additions & 6 deletions apps/kimi-code/src/cli/sub/server/ps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
* `kimi server ps` — list clients currently connected to the running server.
*
* Talks to the running server over HTTP (`GET /api/v1/connections`) using the
* single-instance lock (`~/.kimi-code/server/lock`) to discover its origin —
* instance registry (`~/.kimi-code/server/instances/`) to discover its origin —
* the same way `kimi web` locates the daemon.
*/

import chalk from 'chalk';
import type { Command } from 'commander';

import { getLiveLock } from '@moonshot-ai/kap-server';
import { getLiveServerInstance } from '@moonshot-ai/kap-server';

import { getDataDir } from '#/utils/paths';

import { lockConnectHost } from './daemon';
import { instanceConnectHost } from './daemon';
import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared';

/** Wire shape of a single connection returned by `GET /api/v1/connections`. */
Expand Down Expand Up @@ -52,14 +52,14 @@ export function registerPsCommand(server: Command): void {
}

async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
const lock = getLiveLock();
if (!lock) {
const instance = await getLiveServerInstance();
if (!instance) {
throw new Error(
'No running Kimi server. Start one with `kimi server run` or `kimi web`.',
);
}

const origin = serverOrigin(lockConnectHost(lock), lock.port);
const origin = serverOrigin(instanceConnectHost(instance), instance.port);
if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) {
throw new Error(`Kimi server at ${origin} is not responding.`);
}
Expand Down
13 changes: 6 additions & 7 deletions apps/kimi-code/src/cli/sub/server/rotate-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@
* auth check, so rotation takes effect without a restart.
*/

import { getLiveLock, rotateServerToken } from '@moonshot-ai/kap-server';
import { getLiveServerInstance, rotateServerToken } from '@moonshot-ai/kap-server';
import chalk from 'chalk';
import type { Command } from 'commander';

import { darkColors } from '#/tui/theme/colors';
import { getDataDir } from '#/utils/paths';

import { accessUrlLines, splitTokenFragment } from './access-urls';
import { DEFAULT_SERVER_HOST } from './shared';

export function registerRotateTokenCommand(server: Command): void {
server
Expand All @@ -35,11 +34,11 @@ export function registerRotateTokenCommand(server: Command): void {

// Re-print the access links with the new token so the user can
// reconnect immediately. When a server is running its bind host/port
// come from the lock; otherwise there is nothing to connect to yet.
const lock = getLiveLock();
if (lock !== undefined) {
const host = lock.host ?? DEFAULT_SERVER_HOST;
for (const { label, url: href } of accessUrlLines(host, lock.port, token)) {
// come from the instance registry; otherwise there is nothing to
// connect to yet.
const instance = await getLiveServerInstance();
if (instance !== undefined) {
for (const { label, url: href } of accessUrlLines(instance.host, instance.port, token)) {
// De-emphasize the `#token=…` fragment so the host/port stands out.
const [base, frag] = splitTokenFragment(href);
const rendered =
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/src/cli/sub/server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ export interface RunCommandDeps {
origin: string;
/** True when an already-running daemon was reused (no new server started). */
reused?: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
/** Bind host the running daemon is actually listening on (from the registry). */
host?: string;
/** Port the running daemon is actually listening on (from the lock). */
/** Port the running daemon is actually listening on (from the registry). */
port?: number;
}>;
/** Foreground runner; defaults to the real in-process runner when omitted. */
Expand Down
Loading
Loading