diff --git a/apps/kimi-code/dist-web/boot.js b/apps/kimi-code/dist-web/boot.js
new file mode 100644
index 0000000000000000000000000000000000000000..c7f5f5c9cfd6edac3923f6ce36f8a1ac645778cd
--- /dev/null
+++ b/apps/kimi-code/dist-web/boot.js
@@ -0,0 +1,31 @@
+(function () {
+ try {
+ var v = localStorage.getItem('kimi-web.color-scheme');
+ if (v === 'light' || v === 'dark' || v === 'system') {
+ document.documentElement.dataset.colorScheme = v;
+ }
+ // Font scale, with the same migration rules as useAppearance (app-core):
+ // the retired 'xxlarge' lands on 'xlarge', and a legacy px key maps onto
+ // the nearest step — seeded pre-paint so a non-Medium user never flashes
+ // the Medium scale before the bundle runs.
+ var fs = localStorage.getItem('kimi-web.font-scale');
+ if (fs === 'xxlarge') fs = 'xlarge';
+ if (fs !== 'small' && fs !== 'medium' && fs !== 'large' && fs !== 'xlarge') {
+ var legacy = localStorage.getItem('kimi-web.ui-font-size');
+ var px = Number(legacy);
+ fs =
+ legacy !== null && isFinite(px)
+ ? px <= 13
+ ? 'small'
+ : px <= 15
+ ? 'medium'
+ : px <= 17
+ ? 'large'
+ : 'xlarge'
+ : 'medium';
+ }
+ document.documentElement.dataset.fontScale = fs;
+ } catch {
+ /* ignore */
+ }
+})();
diff --git a/apps/kimi-code/dist-web/favicon.ico b/apps/kimi-code/dist-web/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..f554e98ef4830a14479926865e0a3ea258025d08
Binary files /dev/null and b/apps/kimi-code/dist-web/favicon.ico differ
diff --git a/apps/kimi-code/dist-web/index.html b/apps/kimi-code/dist-web/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..89fd9c3261a2594579e703512ea5d253622b3705
--- /dev/null
+++ b/apps/kimi-code/dist-web/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+ Kimi Code Web
+
+
+
+
+
+
+
diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..64578319f3174a20c77b60ee990be973ac36155f
--- /dev/null
+++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+import { createWriteStream } from 'node:fs';
+import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
+import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import yazl from 'yazl';
+
+import { readPluginManifestVersion } from './plugin-manifest-version.mjs';
+
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
+const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
+const DEFAULT_OUT_DIR = resolve(DEFAULT_PLUGINS_ROOT, 'cdn');
+const SENTINEL = '.kimi-plugin-marketplace-build.json';
+const SKIP_DIRS = new Set(['.git', 'node_modules']);
+const SKIP_FILES = new Set(['.DS_Store']);
+const EXTRA_CDN_PLUGIN_SOURCES = ['./official/kimi-webbridge'];
+
+const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
+if (isMain) {
+ try {
+ const options = parseArgs(process.argv.slice(2));
+ const pluginsRoot = resolve(options.pluginsRoot ?? DEFAULT_PLUGINS_ROOT);
+ const outDir = resolve(options.outDir ?? DEFAULT_OUT_DIR);
+ await buildPluginMarketplaceCdn({ pluginsRoot, outDir });
+ } catch (error) {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
+ process.exit(1);
+ }
+}
+
+export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) {
+ assertSafeOutputDir(pluginsRoot, outDir);
+ await prepareOutputDir(outDir);
+
+ const marketplacePath = resolveInsideRoot(pluginsRoot, 'marketplace.json');
+ const raw = await readFile(marketplacePath, 'utf8');
+ const parsed = JSON.parse(raw);
+ if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) {
+ throw new Error('plugins/marketplace.json must contain a "plugins" array.');
+ }
+
+ const archives = [];
+ const plugins = [];
+ for (const entry of parsed.plugins) {
+ if (!isRecord(entry) || typeof entry.source !== 'string') {
+ plugins.push(entry);
+ continue;
+ }
+ const result = await materializeEntrySource(entry.source, pluginsRoot, outDir);
+ let stamped = { ...entry, source: result.source };
+ if (isLocalRelativeSource(entry.source)) {
+ // Stamp the version from the plugin's real manifest so "latest" stays truthful.
+ const version = await readPluginManifestVersion(resolveInsideRoot(pluginsRoot, entry.source));
+ if (version !== undefined) stamped = { ...stamped, version };
+ }
+ plugins.push(stamped);
+ if (result.archive !== undefined) archives.push(result.archive);
+ }
+
+ // WebBridge is injected by v2 clients rather than listed in the remote
+ // catalog, but its managed plugin still needs a CDN artifact.
+ for (const source of EXTRA_CDN_PLUGIN_SOURCES) {
+ const archive = stripRelativePrefix(withZipExtension(source));
+ if (archives.includes(archive)) continue;
+ const result = await materializeEntrySource(source, pluginsRoot, outDir);
+ if (result.archive !== undefined) archives.push(result.archive);
+ }
+
+ const outputMarketplace = {
+ ...parsed,
+ plugins,
+ };
+ await writeFile(
+ resolveInsideRoot(outDir, 'marketplace.json'),
+ JSON.stringify(outputMarketplace, null, 2) + '\n',
+ 'utf8',
+ );
+ await writeFile(
+ resolveInsideRoot(outDir, SENTINEL),
+ JSON.stringify(
+ {
+ generatedBy: 'build-plugin-marketplace-cdn',
+ generatedAt: new Date().toISOString(),
+ pluginsRoot,
+ },
+ null,
+ 2,
+ ) + '\n',
+ 'utf8',
+ );
+
+ process.stdout.write(`Plugin marketplace CDN artifacts written to ${outDir}\n`);
+ process.stdout.write(` marketplace.json\n`);
+ for (const archive of archives) {
+ process.stdout.write(` ${archive}\n`);
+ }
+}
+
+async function materializeEntrySource(source, pluginsRoot, outDir) {
+ if (!isLocalRelativeSource(source)) return { source };
+
+ const sourcePath = resolveInsideRoot(pluginsRoot, source);
+ const info = await stat(sourcePath).catch(() => undefined);
+ if (info === undefined) {
+ throw new Error(`Marketplace source does not exist: ${source}`);
+ }
+
+ if (info.isDirectory()) {
+ const zipSource = withZipExtension(source);
+ const zipRel = stripRelativePrefix(zipSource);
+ await zipDirectory(sourcePath, resolveInsideRoot(outDir, zipRel));
+ return { source: zipSource, archive: zipRel };
+ }
+
+ if (info.isFile() && extname(sourcePath) === '.zip') {
+ const zipRel = stripRelativePrefix(source);
+ await mkdir(dirname(resolveInsideRoot(outDir, zipRel)), { recursive: true });
+ await cp(sourcePath, resolveInsideRoot(outDir, zipRel));
+ return { source, archive: zipRel };
+ }
+
+ throw new Error(`Marketplace source must be a directory or .zip file: ${source}`);
+}
+
+async function zipDirectory(sourceRoot, outputFile) {
+ await mkdir(dirname(outputFile), { recursive: true });
+ const zipfile = new yazl.ZipFile();
+ const output = createWriteStream(outputFile);
+ const done = new Promise((resolveDone, rejectDone) => {
+ output.on('close', resolveDone);
+ output.on('error', rejectDone);
+ zipfile.outputStream.on('error', rejectDone);
+ });
+ zipfile.outputStream.pipe(output);
+ await addDirectoryToZip(zipfile, sourceRoot, basename(sourceRoot));
+ zipfile.end();
+ await done;
+}
+
+async function addDirectoryToZip(zipfile, root, zipRoot) {
+ const entries = await readdir(root, { withFileTypes: true });
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ for (const entry of entries) {
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
+ if (entry.isFile() && SKIP_FILES.has(entry.name)) continue;
+
+ const absolutePath = resolve(root, entry.name);
+ const zipPath = `${zipRoot}/${relative(root, absolutePath).replaceAll(sep, '/')}`;
+ if (entry.isDirectory()) {
+ await addDirectoryToZip(zipfile, absolutePath, zipPath);
+ } else if (entry.isFile()) {
+ zipfile.addFile(absolutePath, zipPath);
+ }
+ }
+}
+
+async function prepareOutputDir(outDir) {
+ const existing = await stat(outDir).catch(() => undefined);
+ if (existing === undefined) {
+ await mkdir(outDir, { recursive: true });
+ return;
+ }
+ if (!existing.isDirectory()) {
+ throw new Error(`Output path exists and is not a directory: ${outDir}`);
+ }
+ const entries = await readdir(outDir);
+ if (entries.length > 0 && !entries.includes(SENTINEL)) {
+ throw new Error(
+ `Refusing to overwrite non-generated output directory: ${outDir}\n` +
+ `Choose an empty --out-dir or remove it manually.`,
+ );
+ }
+ await rm(outDir, { recursive: true, force: true });
+ await mkdir(outDir, { recursive: true });
+}
+
+function assertSafeOutputDir(pluginsRoot, outDir) {
+ if (outDir === pluginsRoot) {
+ throw new Error('Output directory must not be the plugins root.');
+ }
+ if (isWithin(pluginsRoot, outDir)) {
+ throw new Error('Output directory must not contain the plugins root.');
+ }
+}
+
+function resolveInsideRoot(root, input) {
+ const resolved = resolve(root, input);
+ if (!isWithin(resolved, root)) {
+ throw new Error(`Path escapes root: ${input}`);
+ }
+ return resolved;
+}
+
+function isWithin(candidate, root) {
+ const relativePath = relative(root, candidate);
+ return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
+}
+
+function isLocalRelativeSource(source) {
+ const trimmed = source.trim();
+ return (
+ trimmed.length > 0 &&
+ !trimmed.startsWith('http://') &&
+ !trimmed.startsWith('https://') &&
+ !trimmed.startsWith('file://') &&
+ !trimmed.startsWith('/') &&
+ !trimmed.startsWith('~/') &&
+ trimmed !== '~'
+ );
+}
+
+function withZipExtension(source) {
+ const trimmed = source.trim().replace(/\/+$/, '');
+ return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`;
+}
+
+function stripRelativePrefix(source) {
+ return source.trim().replace(/^\.\//, '');
+}
+
+function isRecord(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function parseArgs(args) {
+ const out = {};
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+ if (arg === '--plugins-root') {
+ out.pluginsRoot = requiredValue(args, ++i, arg);
+ continue;
+ }
+ if (arg === '--out-dir') {
+ out.outDir = requiredValue(args, ++i, arg);
+ continue;
+ }
+ if (arg === '-h' || arg === '--help') {
+ printHelp();
+ process.exit(0);
+ }
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ return out;
+}
+
+function requiredValue(args, index, flag) {
+ const value = args[index];
+ if (value === undefined || value.startsWith('--')) {
+ throw new Error(`${flag} requires a value.`);
+ }
+ return value;
+}
+
+function printHelp() {
+ process.stdout.write(`Usage: pnpm run build:plugin-marketplace [-- --out-dir ]\n`);
+ process.stdout.write(`\n`);
+ process.stdout.write(`Build CDN-ready plugin marketplace artifacts.\n`);
+ process.stdout.write(`\n`);
+ process.stdout.write(`Options:\n`);
+ process.stdout.write(` --plugins-root Source plugins root. Default: ${DEFAULT_PLUGINS_ROOT}\n`);
+ process.stdout.write(` --out-dir Output directory. Default: ${DEFAULT_OUT_DIR}\n`);
+}
diff --git a/apps/kimi-code/scripts/build-vis-asset.mjs b/apps/kimi-code/scripts/build-vis-asset.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..962e37392a2df70b10f51dc8b7f109791b7c07bb
--- /dev/null
+++ b/apps/kimi-code/scripts/build-vis-asset.mjs
@@ -0,0 +1,54 @@
+// Builds the vis web single-file bundle, gzips it, and writes a generated
+// TS module that embeds it as base64 so tsdown can later bundle it into
+// dist/main.mjs (works identically for the npm package and the native SEA
+// binary).
+import { execSync } from 'node:child_process';
+import { gzipSync } from 'node:zlib';
+import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(here, '..', '..', '..');
+const visWeb = join(repoRoot, 'apps', 'vis', 'web');
+const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts');
+
+console.log('[build-vis-asset] building vis web single-file bundle…');
+try {
+ // Run vite with VIS_SINGLEFILE set on the spawn so the build is
+ // cross-platform (Node sets the env, not a POSIX-only inline-env shell
+ // prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up
+ // vis-web's vite.config.ts, which gates the single-file output on
+ // `process.env.VIS_SINGLEFILE === '1'`.
+ // execSync runs through the platform shell, which is required on Windows:
+ // pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no
+ // PATHEXT without a shell). The win32 native binary IS built on Windows
+ // runners (.github/workflows/_native-build.yml), which run this generator.
+ // A single command string (not an args array) avoids the args+shell
+ // deprecation; the command is static (no injection surface).
+ execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', {
+ stdio: 'inherit',
+ cwd: repoRoot,
+ env: { ...process.env, VIS_SINGLEFILE: '1' },
+ });
+} catch (err) {
+ throw new Error(
+ `[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`,
+ );
+}
+
+const html = readFileSync(join(visWeb, 'dist-single', 'index.html'));
+if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes(' pnpm run sync:web` 并提交 dist-web。',
+ );
+ }
+}
+
+await assertWebAssets();
+const files = await readdir(target, { recursive: true });
+console.log(`Web assets OK: ${target} (${files.length} entries, synced from code-app)`);
diff --git a/apps/kimi-code/scripts/copy-native-assets.mjs b/apps/kimi-code/scripts/copy-native-assets.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..f3b0e0cad9ab1b36d6338a97b81b4024ed87848b
--- /dev/null
+++ b/apps/kimi-code/scripts/copy-native-assets.mjs
@@ -0,0 +1,37 @@
+import { cp, mkdir, rm, stat } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const repoRoot = resolve(appRoot, '../..');
+const source = resolve(repoRoot, 'packages/pi-tui/native');
+const target = resolve(appRoot, 'native');
+
+// pi-tui ships platform-specific native helpers for darwin/linux/win32.
+const PLATFORMS = ['darwin', 'linux', 'win32'];
+
+async function assertPrebuilds(platform) {
+ const dir = resolve(source, platform, 'prebuilds');
+ try {
+ const info = await stat(dir);
+ if (!info.isDirectory()) {
+ throw new Error('not a directory');
+ }
+ } catch {
+ throw new Error(
+ `pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`,
+ );
+ }
+ return dir;
+}
+
+await rm(target, { recursive: true, force: true });
+await mkdir(target, { recursive: true });
+
+for (const platform of PLATFORMS) {
+ const srcPrebuilds = await assertPrebuilds(platform);
+ const dstPrebuilds = resolve(target, platform, 'prebuilds');
+ await cp(srcPrebuilds, dstPrebuilds, { recursive: true });
+}
+
+console.log(`Copied pi-tui native prebuilds to ${target}`);
diff --git a/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs b/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..2f7226737cdd7918603f99f4d87d233aa04ebef1
--- /dev/null
+++ b/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs
@@ -0,0 +1,239 @@
+#!/usr/bin/env node
+import { createReadStream } from 'node:fs';
+import { readdir, readFile, stat } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import yazl from 'yazl';
+
+import { readPluginManifestVersion } from './plugin-manifest-version.mjs';
+
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
+const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
+
+export async function startPluginMarketplaceServer(options = {}) {
+ const pluginsRoot = resolve(
+ options.pluginsRoot ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_ROOT ?? DEFAULT_PLUGINS_ROOT,
+ );
+ const host = options.host ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_HOST ?? '127.0.0.1';
+ const port = Number(options.port ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_PORT ?? 0);
+ const server = createServer((req, res) => {
+ void handleRequest(req, res, pluginsRoot);
+ });
+
+ await new Promise((resolveStarted, rejectStarted) => {
+ const onError = (error) => {
+ rejectStarted(error);
+ };
+ server.once('error', onError);
+ server.listen(port, host, () => {
+ server.off('error', onError);
+ resolveStarted();
+ });
+ });
+
+ const address = server.address();
+ if (address === null || typeof address === 'string') {
+ throw new Error('Plugin marketplace dev server did not bind to a TCP port.');
+ }
+
+ const marketplaceUrl = `http://${host}:${address.port}/marketplace.json`;
+ return {
+ server,
+ pluginsRoot,
+ marketplaceUrl,
+ close: () =>
+ new Promise((resolveClosed, rejectClosed) => {
+ server.close((error) => {
+ if (error !== undefined) rejectClosed(error);
+ else resolveClosed();
+ });
+ }),
+ };
+}
+
+async function handleRequest(req, res, pluginsRoot) {
+ const method = req.method ?? 'GET';
+ if (method !== 'GET' && method !== 'HEAD') {
+ res.writeHead(405, { Allow: 'GET, HEAD' });
+ res.end();
+ return;
+ }
+
+ let pathname;
+ try {
+ pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname);
+ } catch {
+ res.writeHead(400);
+ res.end('Bad request');
+ return;
+ }
+
+ try {
+ if (pathname === '/' || pathname === '/marketplace.json') {
+ await serveMarketplaceJson(res, pluginsRoot, method === 'HEAD');
+ return;
+ }
+ if (pathname.endsWith('.zip')) {
+ await servePluginZip(res, pluginsRoot, pathname, method === 'HEAD');
+ return;
+ }
+ await serveStaticFile(res, pluginsRoot, pathname, method === 'HEAD');
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
+ res.end(message);
+ }
+}
+
+async function serveMarketplaceJson(res, pluginsRoot, headOnly) {
+ const file = resolveInsideRoot(pluginsRoot, 'marketplace.json');
+ const raw = await readFile(file, 'utf8');
+ const body = Buffer.from(
+ JSON.stringify(await rewriteMarketplaceJson(raw, pluginsRoot), null, 2) + '\n',
+ );
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Content-Length': String(body.byteLength),
+ });
+ res.end(headOnly ? undefined : body);
+}
+
+async function rewriteMarketplaceJson(raw, pluginsRoot) {
+ const parsed = JSON.parse(raw);
+ if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return parsed;
+
+ const plugins = await Promise.all(
+ parsed.plugins.map(async (entry) => {
+ if (!isRecord(entry) || typeof entry.source !== 'string') return entry;
+ if (!isLocalRelativeSource(entry.source)) return entry;
+ const sourcePath = resolveInsideRoot(pluginsRoot, entry.source);
+ if (!(await isDirectory(sourcePath))) return entry;
+ // Stamp the version from the plugin's real manifest so "latest" stays truthful.
+ const version = await readPluginManifestVersion(sourcePath);
+ const withVersion = version !== undefined ? { ...entry, version } : entry;
+ return { ...withVersion, source: withZipExtension(withVersion.source) };
+ }),
+ );
+
+ return { ...parsed, plugins };
+}
+
+async function servePluginZip(res, pluginsRoot, pathname, headOnly) {
+ const zipRel = pathname.replace(/^\/+/, '');
+ const sourceRel = zipRel.slice(0, -'.zip'.length);
+ const sourceRoot = resolveInsideRoot(pluginsRoot, sourceRel);
+ if (!(await isDirectory(sourceRoot))) {
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
+ res.end('Not found');
+ return;
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/zip' });
+ if (headOnly) {
+ res.end();
+ return;
+ }
+
+ const zipfile = new yazl.ZipFile();
+ zipfile.outputStream.on('error', (error) => {
+ res.destroy(error);
+ });
+ zipfile.outputStream.pipe(res);
+ await addDirectoryToZip(zipfile, sourceRoot, basename(sourceRoot));
+ zipfile.end();
+}
+
+async function serveStaticFile(res, pluginsRoot, pathname, headOnly) {
+ const file = resolveInsideRoot(pluginsRoot, pathname.replace(/^\/+/, ''));
+ const info = await stat(file).catch(() => undefined);
+ if (info === undefined || !info.isFile()) {
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
+ res.end('Not found');
+ return;
+ }
+
+ res.writeHead(200, {
+ 'Content-Type': contentType(file),
+ 'Content-Length': String(info.size),
+ });
+ if (headOnly) {
+ res.end();
+ return;
+ }
+ createReadStream(file).on('error', (error) => res.destroy(error)).pipe(res);
+}
+
+async function addDirectoryToZip(zipfile, root, zipRoot) {
+ const entries = await readdir(root, { withFileTypes: true });
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ for (const entry of entries) {
+ const absolutePath = resolve(root, entry.name);
+ const zipPath = `${zipRoot}/${relative(root, absolutePath).replaceAll(sep, '/')}`;
+ if (entry.isDirectory()) {
+ await addDirectoryToZip(zipfile, absolutePath, zipPath);
+ } else if (entry.isFile()) {
+ zipfile.addFile(absolutePath, zipPath);
+ }
+ }
+}
+
+function resolveInsideRoot(root, input) {
+ const resolved = resolve(root, input);
+ const rootWithSep = root.endsWith(sep) ? root : root + sep;
+ if (resolved !== root && !resolved.startsWith(rootWithSep)) {
+ throw new Error(`Path escapes plugin marketplace root: ${input}`);
+ }
+ return resolved;
+}
+
+function isLocalRelativeSource(source) {
+ const trimmed = source.trim();
+ return (
+ trimmed.length > 0 &&
+ !trimmed.startsWith('http://') &&
+ !trimmed.startsWith('https://') &&
+ !trimmed.startsWith('file://') &&
+ !trimmed.startsWith('/') &&
+ !trimmed.startsWith('~/') &&
+ trimmed !== '~'
+ );
+}
+
+function withZipExtension(source) {
+ const trimmed = source.trim().replace(/\/+$/, '');
+ return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`;
+}
+
+async function isDirectory(path) {
+ return (await stat(path).catch(() => undefined))?.isDirectory() === true;
+}
+
+function contentType(path) {
+ switch (extname(path)) {
+ case '.json':
+ return 'application/json; charset=utf-8';
+ case '.mjs':
+ case '.js':
+ return 'text/javascript; charset=utf-8';
+ case '.md':
+ return 'text/markdown; charset=utf-8';
+ case '.txt':
+ return 'text/plain; charset=utf-8';
+ default:
+ return 'application/octet-stream';
+ }
+}
+
+function isRecord(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
+if (isMain) {
+ const started = await startPluginMarketplaceServer();
+ console.error(`Plugin marketplace dev server: ${started.marketplaceUrl}`);
+ console.error(`Serving: ${started.pluginsRoot}`);
+}
diff --git a/apps/kimi-code/scripts/dev-server-restart.mjs b/apps/kimi-code/scripts/dev-server-restart.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..62a34029a4b66b3579dc86206a2c9ee2a0ff9460
--- /dev/null
+++ b/apps/kimi-code/scripts/dev-server-restart.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+// Press-Enter-to-restart wrapper for the local server. No file watcher.
+//
+// Spawns `tsx ./src/main.ts web --no-open …extraArgs` once, then on each
+// newline read from stdin SIGTERMs the child and respawns after it has
+// cleanly exited. SIGTERM triggers the server's own `shutdown()` handler
+// (apps/kimi-code/src/cli/sub/web/run.ts) which releases the instance
+// registration and closes WS conns before exit, so a fresh start can
+// re-acquire 58627 without a stale-entry fight.
+//
+// CLI args after `--` (or any extras) are passed straight through, so:
+// pnpm dev:server:restart -- --host 0.0.0.0 --port 58627 --log-level debug
+// is equivalent to `pnpm dev:server` with that arg list, but with the restart
+// loop on top.
+
+import { spawn } from 'node:child_process';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const APP_ROOT = resolve(SCRIPT_DIR, '..');
+
+const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
+
+const cliArgs = process.argv.slice(2);
+if (cliArgs[0] === '--') cliArgs.shift();
+
+const tsxArgs = [
+ '--tsconfig',
+ './tsconfig.dev.json',
+ '--import',
+ '../../build/register-raw-text-loader.mjs',
+ './src/main.ts',
+ 'web',
+ '--no-open',
+ ...cliArgs,
+];
+
+let child = null;
+let restarting = false;
+let shuttingDown = false;
+let killTimer = null;
+
+function start() {
+ console.error('[dev:server:restart] starting server…');
+ child = spawn(tsxBin, tsxArgs, {
+ cwd: APP_ROOT,
+ env: { ...process.env, KIMI_CODE_DEV_SERVER: '1' },
+ // Server does not read stdin; keep ours free for the Enter trigger.
+ stdio: ['ignore', 'inherit', 'inherit'],
+ });
+
+ child.on('error', (err) => {
+ console.error(`[dev:server:restart] spawn error: ${err.message}`);
+ });
+
+ child.on('exit', (code, signal) => {
+ if (killTimer !== null) {
+ clearTimeout(killTimer);
+ killTimer = null;
+ }
+ const prev = child;
+ child = null;
+ if (shuttingDown) {
+ process.exit(code ?? 0);
+ return;
+ }
+ if (restarting) {
+ restarting = false;
+ start();
+ return;
+ }
+ // Server died on its own (port conflict, runtime error, etc.). Stay alive
+ // so the user can fix the issue and press Enter to retry.
+ const tag = signal !== null ? `signal=${signal}` : `code=${code}`;
+ console.error(
+ `[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`,
+ );
+ void prev; // silence unused warning
+ });
+}
+
+function restart() {
+ if (shuttingDown) return;
+ if (child === null) {
+ // Previous run already exited; just spin up a new one.
+ start();
+ return;
+ }
+ if (restarting) return; // debounce — multiple Enters during shutdown collapse
+ restarting = true;
+ console.error('[dev:server:restart] restarting…');
+ child.kill('SIGTERM');
+ // Safety net: if the child ignores SIGTERM, force-kill after 5s so the
+ // restart loop doesn't wedge.
+ killTimer = setTimeout(() => {
+ if (child !== null && child.exitCode === null && child.signalCode === null) {
+ console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL');
+ child.kill('SIGKILL');
+ }
+ }, 5000);
+}
+
+process.stdin.setEncoding('utf8');
+process.stdin.on('data', (chunk) => {
+ // Any newline (Enter on most terminals) triggers a restart. Empty Enter is
+ // the canonical signal; typing `r` works too.
+ if (chunk.includes('\n') || chunk.includes('\r')) {
+ restart();
+ }
+});
+
+const onShutdownSignal = (signal) => {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ if (child !== null) {
+ child.kill(signal);
+ // Give the server a moment to flush logs / release the lock.
+ setTimeout(() => process.exit(0), 1000).unref();
+ } else {
+ process.exit(0);
+ }
+};
+process.on('SIGINT', () => onShutdownSignal('SIGINT'));
+process.on('SIGTERM', () => onShutdownSignal('SIGTERM'));
+
+start();
diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..3934f34e7b20a562d4045ae85dc8553ab8b51e3f
--- /dev/null
+++ b/apps/kimi-code/scripts/dev.mjs
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+import { spawn } from 'node:child_process';
+import { createRequire } from 'node:module';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs';
+
+const require = createRequire(import.meta.url);
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const APP_ROOT = resolve(SCRIPT_DIR, '..');
+// Monorepo root. Used as the dev CLI's working directory so `make dev` opens
+// the whole repo instead of just apps/kimi-code.
+const REPO_ROOT = resolve(APP_ROOT, '../..');
+// Runtime variable the CLI reads to locate the marketplace JSON.
+const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
+// Opt-in for dev: point this run at an external marketplace instead of a local one.
+const EXTERNAL_MARKETPLACE_ENV = 'KIMI_CODE_DEV_MARKETPLACE_URL';
+
+let marketplaceServer;
+const env = { ...process.env };
+
+const externalUrl = process.env[EXTERNAL_MARKETPLACE_ENV]?.trim();
+if (externalUrl !== undefined && externalUrl.length > 0) {
+ // Explicitly asked to use an external marketplace; don't start a local server.
+ env[MARKETPLACE_ENV] = externalUrl;
+ console.error(`Using external plugin marketplace: ${externalUrl}`);
+} else {
+ // Default: every `pnpm run dev:cli` runs its own isolated marketplace server on a
+ // random port, so multiple concurrent dev instances never collide. Overwrite any
+ // inherited MARKETPLACE_ENV so a stale URL from a dead instance can't break this run.
+ const inherited = process.env[MARKETPLACE_ENV]?.trim();
+ marketplaceServer = await startPluginMarketplaceServer();
+ env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl;
+ // Marks the URL as the dev server's own (serving this repo's catalog), so
+ // the CLI can tell it apart from a user-configured marketplace override.
+ env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] = '1';
+ console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`);
+ if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) {
+ console.error(
+ `(ignored inherited ${MARKETPLACE_ENV}=${inherited}; set ${EXTERNAL_MARKETPLACE_ENV} to use an external marketplace)`,
+ );
+ }
+}
+
+const tsxCli = require.resolve('tsx/cli');
+const cliArgs = process.argv.slice(2);
+if (cliArgs[0] === '--') cliArgs.shift();
+const child = spawn(
+ process.execPath,
+ [
+ tsxCli,
+ // Use the dev tsconfig whose `include` covers packages/*/src, so tsx's
+ // esbuild transform sees `experimentalDecorators: true` for DI parameter
+ // decorators in agent-core-v2. Mirrors `dev:server` in package.json.
+ '--tsconfig',
+ resolve(APP_ROOT, 'tsconfig.dev.json'),
+ '--import',
+ pathToFileURL(resolve(REPO_ROOT, 'build/register-raw-text-loader.mjs')).href,
+ resolve(APP_ROOT, 'src/main.ts'),
+ ...cliArgs,
+ ],
+ {
+ cwd: REPO_ROOT,
+ env,
+ stdio: 'inherit',
+ },
+);
+
+child.on('error', async (error) => {
+ console.error(`Failed to start Kimi Code dev CLI: ${error.message}`);
+ await marketplaceServer?.close();
+ process.exit(1);
+});
+
+child.on('exit', async (code, signal) => {
+ await marketplaceServer?.close();
+ if (signal !== null) {
+ process.exit(1);
+ }
+ process.exit(code ?? 0);
+});
diff --git a/apps/kimi-code/scripts/plugin-manifest-version.mjs b/apps/kimi-code/scripts/plugin-manifest-version.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..a7a73f1ba94e1bc842bb47741f2a3fe34be83eff
--- /dev/null
+++ b/apps/kimi-code/scripts/plugin-manifest-version.mjs
@@ -0,0 +1,38 @@
+import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+
+// Read a local plugin directory's declared version from its manifest, mirroring
+// the plugin loader's precedence (packages/agent-core-v2/src/app/plugin/manager.ts):
+// `kimi.plugin.json` is authoritative once it exists, and `.kimi-plugin/plugin.json`
+// is only consulted when the root manifest is absent. Returns undefined when no
+// manifest is present or the chosen manifest has no version — callers then leave
+// the marketplace entry's existing version untouched.
+export async function readPluginManifestVersion(pluginDir) {
+ for (const rel of ['kimi.plugin.json', '.kimi-plugin/plugin.json']) {
+ const raw = await readFileOrUndefined(resolve(pluginDir, rel));
+ if (raw === undefined) continue; // manifest absent — fall back to the next candidate
+ return versionFromManifest(raw); // the chosen manifest wins, even if it has no version
+ }
+ return undefined;
+}
+
+async function readFileOrUndefined(file) {
+ try {
+ return await readFile(file, 'utf8');
+ } catch {
+ return undefined;
+ }
+}
+
+function versionFromManifest(raw) {
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string') {
+ const trimmed = parsed.version.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+ }
+ } catch {
+ return undefined;
+ }
+ return undefined;
+}
diff --git a/apps/kimi-code/scripts/postinstall.mjs b/apps/kimi-code/scripts/postinstall.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..054f3bf8d7ce8614b37b7bfd8c0c91cb8087f8ce
--- /dev/null
+++ b/apps/kimi-code/scripts/postinstall.mjs
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * Postinstall hook for @moonshot-ai/kimi-code.
+ *
+ * Goal: when this package is installed globally, ensure typing `kimi`
+ * invokes the new TypeScript CLI. The npm `package.json` bin field
+ * installs a fresh `kimi` shim into the global bin dir; this script
+ * removes any pre-existing `kimi` shim left behind by the previous
+ * Python CLI (installed via `uv tool install`, `pipx install`,
+ * `pip install`, etc.) that would otherwise shadow ours via PATH
+ * ordering. The renamed shim is kept as `kimi-legacy` so users can
+ * still invoke the old CLI if they want to fall back.
+ *
+ * ## Hard rules
+ *
+ * - Only runs for global installs across npm, yarn (classic), and
+ * pnpm. Non-global installs (npx, local project deps, workspace
+ * bootstraps, `pnpm dlx`) are silent no-ops.
+ * - Never fails the install. Any error here is caught and reported,
+ * but the script always exits 0.
+ * - Does not touch a `kimi` we don't recognize as the previous
+ * Python CLI (matched by realpath-resolved shim head containing
+ * `kimi_cli`).
+ * - Cross-platform: POSIX and Windows. Windows-specific bits live
+ * in the helpers (PATHEXT-aware PATH walking, whole-file marker
+ * sniff for uv's Rust launcher .exe, extension-preserving
+ * rename target like `kimi.exe` → `kimi-legacy.exe`).
+ *
+ * ## Code layout
+ *
+ * This file is the orchestrator; the actual logic lives in
+ * sibling modules to keep each file under a manageable size:
+ *
+ * - `./postinstall/reach.mjs` — package-manager detection,
+ * global-install gate, own-package-root resolution, user-shell
+ * PATH lookup, reachability check.
+ * - `./postinstall/migrate.mjs` — legacy detection,
+ * `kimi`-vs-`kimi-legacy` classification, the rename / unlink
+ * primitives.
+ * - `./postinstall/takeover.mjs` — the plan → execute → verify
+ * state machine this orchestrator drives.
+ * - `./postinstall/ui.mjs` — `notify()` (with `/dev/tty` fallback),
+ * ANSI styling, the fixed-width box, and the outcome renderers.
+ *
+ * ## Workflow
+ *
+ * What runs when a user types `npm install -g @moonshot-ai/kimi-code`
+ * (or the yarn / pnpm equivalent):
+ *
+ * 1. The manager extracts the package and runs lifecycle scripts.
+ * The `bin.kimi` mapping in `package.json` tells the manager to
+ * install a `kimi` shim under its global bin directory.
+ * 2. The manager invokes this script via the `scripts.postinstall`
+ * entry — orchestrated by `main` below.
+ * 3. Install-context gate: only proceed when this is a global
+ * install (`isGlobalInstall` checks `npm_config_global` /
+ * `pnpm_config_global` / `npm_config_location`).
+ * 4. Probe PATH once via `postinstallPaths()`: detection uses the
+ * union of shell PATH + process PATH; reachability uses the
+ * shell PATH alone (with a fallback to process PATH if the
+ * shell can't be probed). Sharing one probe keeps detection
+ * and reachability symmetric and avoids running `$SHELL -l`
+ * twice.
+ * 5. `planTakeover`: detect EVERY previous Python `kimi-cli` shim
+ * on the detection PATH, pre-flight classify each (no writes),
+ * and simulate PATH resolution with the actionable shims gone:
+ * - `own` wins → proceed.
+ * - a blocked legacy still wins → `logMigrationBlocked`.
+ * - a foreign `kimi` wins → `logForeignKimiInTheWay`.
+ * - nothing resolves → `logNewCliNotOnPath`.
+ * The abort branches touch NOTHING.
+ * 6. `executeTakeover`: the FIRST shim in PATH order that can be
+ * preserved becomes `kimi-legacy`; each subsequent shim is
+ * `unlink`ed. A failed preserve attempt does not promote the
+ * next shim to deletion — it gets its own preserve attempt, so
+ * a usable legacy fallback survives whenever one is possible.
+ * 7. `verifyTakeover`: walk the reachability PATH as it actually
+ * is AFTER execution. Only `{ kind: 'own' }` renders the
+ * success box (`logMigrationDone`); anything else renders
+ * `logMigrationIncomplete` — what changed, what still blocks,
+ * and how to finish by hand. The pre-flight simulation in
+ * step 5 is never reported as proof of success.
+ * 8. The manager completes the install with its usual summary.
+ * This script always exits 0; any uncaught error is swallowed
+ * by the top-level `catch` so the install never fails because
+ * of the migration.
+ */
+
+import {
+ detectPackageManager,
+ isGlobalInstall,
+ ownPackageRoot,
+ postinstallPaths,
+} from './postinstall/reach.mjs';
+import {
+ executeTakeover,
+ planTakeover,
+ verifyTakeover,
+} from './postinstall/takeover.mjs';
+import {
+ logForeignKimiInTheWay,
+ logMigrationBlocked,
+ logMigrationDone,
+ logMigrationIncomplete,
+ logNewCliNotOnPath,
+ notify,
+} from './postinstall/ui.mjs';
+
+async function main() {
+ // Step 1: skip non-global installs (npx, local project deps,
+ // workspace bootstraps). Windows is supported natively; the
+ // platform-specific bits (PATHEXT-aware PATH walk, whole-file
+ // marker sniff for uv's launcher .exe, extension-preserving
+ // rename) live in the helpers.
+ if (!isGlobalInstall()) return;
+
+ // Step 2: locate our own installed package root once and share it
+ // with both detection (skip files inside our package) and
+ // reachability (only count our shim as "found").
+ const ownRoot = await ownPackageRoot(import.meta.dirname);
+ const pm = detectPackageManager();
+
+ // Step 3: probe the user's shell PATH once so detection and
+ // reachability share a single consistent view. Detection uses the
+ // union of shell PATH + process PATH (so we catch a legacy shim
+ // visible to either); reachability uses the shell PATH alone (so
+ // we don't claim "kimi works now" when the shim only sits in the
+ // installer's env).
+ const paths = await postinstallPaths();
+
+ // Step 4: plan against the whole detected shim set without writing.
+ const plan = await planTakeover(
+ ownRoot,
+ paths.detection,
+ paths.reachability,
+ process.platform,
+ );
+ if (plan.kind === 'noop') return;
+ if (plan.kind === 'blocked') {
+ logMigrationBlocked(plan.blocked, plan.actionable, pm);
+ return;
+ }
+ if (plan.kind === 'foreign') {
+ logForeignKimiInTheWay(plan.path, pm);
+ return;
+ }
+ if (plan.kind === 'not-on-path') {
+ logNewCliNotOnPath(plan.detection, pm);
+ return;
+ }
+
+ // Step 5: execute (preserve the first preservable shim as
+ // `kimi-legacy`, delete the rest).
+ const outcomes = await executeTakeover(plan.classifications);
+
+ // Step 6: post-execution verification — the ONLY ground for a
+ // success claim. If reality diverged from the step-4 simulation
+ // (a rename failed, a new shim appeared), report it honestly.
+ const verify = await verifyTakeover(
+ ownRoot,
+ paths.reachability,
+ plan.classifications.map((c) => c.shimPath),
+ process.platform,
+ );
+ if (verify.kind === 'own') {
+ logMigrationDone({ ...outcomes, blockedHarmless: plan.blocked }, pm);
+ return;
+ }
+ logMigrationIncomplete({ outcomes, verify, blocked: plan.blocked }, pm);
+}
+
+main().catch((err) => {
+ const message = err instanceof Error ? err.message : String(err);
+ notify(`[kimi-code] postinstall warning: ${message}`);
+});
diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..b4a5afa0944aa8f8c399580d3c9bb9b07289eecf
--- /dev/null
+++ b/apps/kimi-code/scripts/smoke.mjs
@@ -0,0 +1,71 @@
+import { execFile } from 'node:child_process';
+import { readFile, stat } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const bundlePath = resolve(appRoot, 'dist', 'main.mjs');
+const webIndexPath = resolve(appRoot, 'dist-web', 'index.html');
+const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8'));
+const expectedVersion = packageJson.version;
+
+function fail(message) {
+ console.error(message);
+ process.exit(1);
+}
+
+async function ensureBundleExists() {
+ try {
+ await stat(bundlePath);
+ } catch {
+ fail(`Bundle not found at ${bundlePath}. Run \`pnpm build\` first.`);
+ }
+}
+
+async function ensureRuntimeAssetsExist() {
+ try {
+ await stat(webIndexPath);
+ } catch {
+ fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`);
+ }
+}
+
+async function runBundle(args) {
+ try {
+ const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], {
+ cwd: appRoot,
+ maxBuffer: 1024 * 1024 * 16,
+ });
+ return `${stdout}${stderr}`;
+ } catch (error) {
+ const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message]
+ .filter(Boolean)
+ .join('\n');
+ fail(`Bundle smoke failed: node ${bundlePath} ${args.join(' ')}\n${detail}`);
+ }
+}
+
+function assertIncludes(output, expected, command) {
+ if (!output.includes(expected)) {
+ fail(`Bundle smoke output for "${command}" did not include "${expected}".\n${output}`);
+ }
+}
+
+await ensureBundleExists();
+await ensureRuntimeAssetsExist();
+
+const versionOutput = await runBundle(['--version']);
+assertIncludes(versionOutput, expectedVersion, '--version');
+
+const helpOutput = await runBundle(['--help']);
+assertIncludes(helpOutput, 'Usage: kimi', '--help');
+
+const exportHelpOutput = await runBundle(['export', '--help']);
+assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help');
+
+const webHelpOutput = await runBundle(['web', '--help']);
+assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help');
+
+console.log(`Bundle smoke passed: ${bundlePath}`);
diff --git a/apps/kimi-code/scripts/update-catalog.mjs b/apps/kimi-code/scripts/update-catalog.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..ee43eeafc159bb0eb3710fa3b547e26be4f28c4c
--- /dev/null
+++ b/apps/kimi-code/scripts/update-catalog.mjs
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * Fetches models.dev/api.json, strips fields not needed by kimi-code, and
+ * writes the result as raw JSON for release builds to inline.
+ *
+ * This script intentionally does not write into src/. The source tree keeps a
+ * placeholder so the generated catalog is not committed.
+ */
+
+import { mkdirSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+
+const scriptDir = import.meta.dirname;
+const outFile = resolveOutputFile(process.argv.slice(2));
+const modelsUrl = process.env.MODELS_DEV_URL || "https://models.dev/api.json";
+
+const KEEP_PROVIDER = new Set(["id", "name", "api", "env", "npm", "type", "models"]);
+const KEEP_MODEL = new Set([
+ "id",
+ "name",
+ "family",
+ "limit",
+ "tool_call",
+ "reasoning",
+ "interleaved",
+ "modalities",
+ // Message-level tool declarations capability — kosong's
+ // catalogModelToCapability reads it; stripping it here would silently
+ // disable tool-select for catalog-imported aliases.
+ "dynamically_loaded_tools",
+]);
+
+function resolveOutputFile(args) {
+ const index = args.indexOf("--out");
+ if (index !== -1) {
+ const value = args[index + 1];
+ if (value === undefined || value.length === 0) {
+ throw new Error("Missing value for --out");
+ }
+ return resolve(process.cwd(), value);
+ }
+ return resolve(scriptDir, "../dist/built-in-catalog.json");
+}
+
+function stripModel(model) {
+ if (typeof model !== "object" || model === null) return undefined;
+ const result = {};
+ for (const key of Object.keys(model)) {
+ if (KEEP_MODEL.has(key)) result[key] = model[key];
+ }
+ return result;
+}
+
+function stripProvider(provider) {
+ if (typeof provider !== "object" || provider === null) return undefined;
+ const result = {};
+ for (const key of Object.keys(provider)) {
+ if (!KEEP_PROVIDER.has(key)) continue;
+ const value = provider[key];
+ if (key === "models") {
+ const stripped = {};
+ for (const [mId, m] of Object.entries(value)) {
+ const s = stripModel(m);
+ if (s !== undefined) stripped[mId] = s;
+ }
+ if (Object.keys(stripped).length > 0) result[key] = stripped;
+ } else {
+ result[key] = value;
+ }
+ }
+ return result;
+}
+
+async function fetchCatalog(url) {
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const raw = await res.json();
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
+ throw new Error("invalid payload shape");
+ }
+ const stripped = {};
+ for (const [k, v] of Object.entries(raw)) {
+ const p = stripProvider(v);
+ if (p !== undefined && Object.keys(p).length > 0) stripped[k] = p;
+ }
+ return JSON.stringify(stripped);
+}
+
+async function main() {
+ console.log(`Fetching ${modelsUrl} ...`);
+ const json = await fetchCatalog(modelsUrl);
+ mkdirSync(dirname(outFile), { recursive: true });
+ writeFileSync(outFile, json, "utf-8");
+ console.log(`Wrote ${outFile} (${(json.length / 1024).toFixed(0)} KB JSON)`);
+}
+
+main().catch((error) => {
+ console.error(error.message);
+ process.exit(1);
+});
diff --git a/apps/kimi-code/src/built-in-catalog.ts b/apps/kimi-code/src/built-in-catalog.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1511de307f49fba06b49d2ec0c11c25cd255884d
--- /dev/null
+++ b/apps/kimi-code/src/built-in-catalog.ts
@@ -0,0 +1,8 @@
+// Filled by tsdown define in release builds. Source stays empty so the
+// generated models.dev snapshot is not committed.
+declare const __KIMI_CODE_BUILT_IN_CATALOG__: string | undefined;
+
+export const BUILT_IN_CATALOG_JSON: string | undefined =
+ typeof __KIMI_CODE_BUILT_IN_CATALOG__ === 'string'
+ ? __KIMI_CODE_BUILT_IN_CATALOG__
+ : undefined;
diff --git a/apps/kimi-code/src/cli/agent-selection.ts b/apps/kimi-code/src/cli/agent-selection.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0ffb53d5999e50a9a92c96cec2fb837952a1b402
--- /dev/null
+++ b/apps/kimi-code/src/cli/agent-selection.ts
@@ -0,0 +1,42 @@
+import { readFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+
+import { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/kimi-code-sdk';
+
+import type { CLIOptions } from './options';
+
+/**
+ * Resolve which agent profile the launch flags select.
+ *
+ * `--agent` carries the profile name directly; `--agent-file` implicitly
+ * selects the profile the file defines, so the file is parsed here (fatal on
+ * error) so a bad file fails before any session work. Returns undefined when
+ * neither flag is present.
+ */
+export async function resolveAgentProfileSelection(
+ opts: Pick,
+ workDir: string,
+): Promise {
+ if (opts.agent !== undefined) return opts.agent;
+ const agentFile = opts.agentFiles?.[0];
+ if (agentFile === undefined) return undefined;
+
+ const path = resolveAgentPath(agentFile, workDir, homedir());
+ let text: string;
+ try {
+ text = await readFile(path, 'utf8');
+ } catch (error) {
+ throw new Error(
+ `Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`,
+ { cause: error },
+ );
+ }
+ try {
+ return parseAgentFileText({ path, source: 'explicit', text }).name;
+ } catch (error) {
+ throw new Error(
+ `Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`,
+ { cause: error },
+ );
+ }
+}
diff --git a/apps/kimi-code/src/cli/build-info.ts b/apps/kimi-code/src/cli/build-info.ts
new file mode 100644
index 0000000000000000000000000000000000000000..56c89a5b83277aa4e767a12a1059d855c1389ffe
--- /dev/null
+++ b/apps/kimi-code/src/cli/build-info.ts
@@ -0,0 +1,34 @@
+declare const __KIMI_CODE_VERSION__: string | undefined;
+declare const __KIMI_CODE_CHANNEL__: string | undefined;
+declare const __KIMI_CODE_COMMIT__: string | undefined;
+declare const __KIMI_CODE_BUILD_TARGET__: string | undefined;
+
+export interface KimiBuildInfo {
+ readonly version?: string;
+ readonly channel?: string;
+ readonly commit?: string;
+ readonly buildTarget?: string;
+}
+
+function optionalBuildString(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+export const KIMI_BUILD_INFO: KimiBuildInfo = {
+ version:
+ typeof __KIMI_CODE_VERSION__ === 'string'
+ ? optionalBuildString(__KIMI_CODE_VERSION__)
+ : undefined,
+ channel:
+ typeof __KIMI_CODE_CHANNEL__ === 'string'
+ ? optionalBuildString(__KIMI_CODE_CHANNEL__)
+ : undefined,
+ commit:
+ typeof __KIMI_CODE_COMMIT__ === 'string'
+ ? optionalBuildString(__KIMI_CODE_COMMIT__)
+ : undefined,
+ buildTarget:
+ typeof __KIMI_CODE_BUILD_TARGET__ === 'string'
+ ? optionalBuildString(__KIMI_CODE_BUILD_TARGET__)
+ : undefined,
+};
diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e6022a8f119f8a79185d6fec75ab22732d4ffcd4
--- /dev/null
+++ b/apps/kimi-code/src/cli/commands.ts
@@ -0,0 +1,191 @@
+import { CLI_COMMAND_NAME } from '#/constant/app';
+import { registerMigrateCommand, type MigrateCommandOptions } from '#/migration/index';
+import { Command, InvalidArgumentError, Option } from 'commander';
+
+import type { CLIOptions } from './options';
+import { registerAcpCommand } from './sub/acp';
+import { registerDoctorCommand } from './sub/doctor';
+import { registerExportCommand } from './sub/export';
+import { registerForkCommand } from './sub/fork';
+import { registerLoginCommand } from './sub/login';
+import { registerProviderCommand } from './sub/provider';
+import { registerSessionCommand } from './sub/session';
+import { registerVisCommand } from './sub/vis';
+import { registerWebCommand } from './sub/web';
+
+export type MainCommandHandler = (opts: CLIOptions) => void;
+export type MigrateCommandHandler = (options: MigrateCommandOptions) => void;
+export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void;
+export type UpgradeCommandHandler = (yes: boolean) => void | Promise;
+export type UpdateDownloadHandler = (version: string, manual: boolean) => void;
+
+export function createProgram(
+ version: string,
+ onMain: MainCommandHandler,
+ onMigrate: MigrateCommandHandler,
+ onPluginNodeRunner: PluginNodeRunnerHandler = () => {},
+ onUpgrade: UpgradeCommandHandler = () => {},
+ onUpdateDownload: UpdateDownloadHandler = () => {},
+): Command {
+ const program = new Command(CLI_COMMAND_NAME)
+ .description('The Starting Point for Next-Gen Agents')
+ .version(version, '-V, --version')
+ .allowUnknownOption(false)
+ .enablePositionalOptions()
+ .configureHelp({ helpWidth: 100 })
+ .helpOption('-h, --help', 'Show help.')
+ .usage('[options] [command]')
+ .addHelpText('after', '\nDocumentation: https://moonshotai.github.io/kimi-code/\n');
+
+ program
+ .addOption(
+ new Option(
+ '-S, --session [id]',
+ 'Resume a session. With ID: resume that session. Without ID: interactively pick.',
+ ).argParser((val: string | boolean) => (val === true ? '' : (val as string))),
+ )
+ .addOption(
+ new Option('-r, --resume [id]')
+ .hideHelp()
+ .argParser((val: string | boolean) => (val === true ? '' : (val as string))),
+ )
+ .option('-c, --continue', 'Continue the previous session for the working directory.', false)
+ .addOption(new Option('-C').hideHelp().default(false))
+ .option('-y, --yolo', 'Start in Ask When Needed mode: routine edits and commands run automatically; risky actions, questions, and plans still ask.', false)
+ .option('--auto', 'Start in Never Ask mode: never interrupts you; everything runs and is decided automatically.', false)
+ .addOption(
+ new Option(
+ '-m, --model ',
+ 'LLM model alias to use for this invocation. Defaults to default_model in config.toml.',
+ ),
+ )
+ .addOption(
+ new Option(
+ '-p, --prompt ',
+ 'Run one prompt non-interactively and print the response.',
+ ),
+ )
+ .addOption(
+ new Option(
+ '--output-format ',
+ 'Output format for prompt mode. Defaults to text.',
+ ).choices(['text', 'stream-json']),
+ )
+ .addOption(
+ new Option(
+ '--skills-dir ',
+ 'Load skills from this directory instead of auto-discovered user and project directories. Can be repeated.',
+ )
+ .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
+ .default([]),
+ )
+ .addOption(
+ new Option(
+ '--agent ',
+ 'Agent profile to start the new session with. Custom profiles are discovered from agent directories or loaded via --agent-file. Cannot be combined with --session/--continue.',
+ )
+ .argParser((value: string, previous: string | undefined) => {
+ if (previous !== undefined) {
+ throw new InvalidArgumentError('--agent may only be specified once.');
+ }
+ return value;
+ })
+ .conflicts('agentFile'),
+ )
+ .addOption(
+ new Option(
+ '--agent-file ',
+ 'Load an agent definition from a Markdown file and select it for the new session. Cannot be combined with --session/--continue.',
+ )
+ .argParser((value: string, previous: string[] | undefined) => {
+ if ((previous?.length ?? 0) > 0) {
+ throw new InvalidArgumentError('--agent-file may only be specified once.');
+ }
+ return [value];
+ })
+ .conflicts('agent')
+ .default([]),
+ )
+ .addOption(
+ new Option(
+ '--add-dir ',
+ 'Add an additional workspace directory for this session. Can be repeated.',
+ )
+ .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
+ .default([]),
+ )
+ .addOption(new Option('--yes').hideHelp().default(false))
+ .addOption(new Option('--auto-approve').hideHelp().default(false))
+ .option('--plan', 'Start in plan mode.', false);
+
+ registerExportCommand(program);
+ registerForkCommand(program);
+ registerProviderCommand(program);
+ registerSessionCommand(program);
+ registerAcpCommand(program);
+ registerWebCommand(program);
+ registerLoginCommand(program);
+ registerDoctorCommand(program);
+ registerVisCommand(program);
+ registerMigrateCommand(program, onMigrate);
+ program
+ .command('upgrade')
+ .alias('update')
+ .description('Upgrade Kimi Code to the latest version.')
+ .option('-y, --yes', 'Skip the confirmation prompt and install the update directly.', false)
+ .action(async (options: { yes?: boolean }) => {
+ await onUpgrade(options.yes === true);
+ });
+
+ program
+ .command('__plugin_run_node', { hidden: true })
+ .argument('')
+ .argument('[args...]')
+ .allowUnknownOption(true)
+ .action((entry: string, args: string[]) => {
+ onPluginNodeRunner(entry, args);
+ });
+
+ // Self-spawned worker for native staged updates (detached background
+ // download, or foreground from `kimi upgrade` — `--manual` marks the
+ // latter's stage as user-requested). Hidden: not user-facing.
+ program
+ .command('__update_download', { hidden: true })
+ .argument('')
+ .option('--manual', 'the stage answers an explicit user-initiated upgrade')
+ .action((targetVersion: string, options: { manual?: boolean }) => {
+ onUpdateDownload(targetVersion, options.manual === true);
+ });
+
+ program.argument('[args...]').action((args: string[]) => {
+ if (args.length > 0) {
+ program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`);
+ }
+
+ const raw = program.opts>();
+
+ const rawSession = raw['session'] ?? raw['resume'];
+ const sessionValue = rawSession === true ? '' : (rawSession as string | undefined);
+ const yoloValue = raw['yolo'] === true || raw['yes'] === true || raw['autoApprove'] === true;
+ const autoValue = raw['auto'] === true;
+
+ const opts: CLIOptions = {
+ session: sessionValue,
+ continue: raw['continue'] === true || raw['C'] === true,
+ yolo: yoloValue,
+ auto: autoValue,
+ plan: raw['plan'] as boolean,
+ model: raw['model'] as string | undefined,
+ outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
+ prompt: raw['prompt'] as string | undefined,
+ skillsDirs: raw['skillsDir'] as string[],
+ agent: raw['agent'] as string | undefined,
+ agentFiles: raw['agentFile'] as string[],
+ addDirs: raw['addDir'] as string[],
+ };
+
+ onMain(opts);
+ });
+
+ return program;
+}
diff --git a/apps/kimi-code/src/cli/goal-prompt.ts b/apps/kimi-code/src/cli/goal-prompt.ts
new file mode 100644
index 0000000000000000000000000000000000000000..57f223ad443ef4432d35febd2b2684357173552c
--- /dev/null
+++ b/apps/kimi-code/src/cli/goal-prompt.ts
@@ -0,0 +1,103 @@
+import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk';
+
+import { parseGoalCommand } from '#/tui/commands/index';
+
+/**
+ * Headless goal-mode support for the `kimi -p "/goal "` prompt path.
+ *
+ * The goal driver keeps the prompt's turn-run alive across continuation turns
+ * until the goal reaches a terminal state, so the existing prompt-turn waiter
+ * already blocks until then. This module adds the create-on-entry parsing, a
+ * machine-readable summary, and the terminal-status → exit-code mapping.
+ */
+
+export interface HeadlessGoalCreate {
+ readonly objective: string;
+ readonly replace: boolean;
+}
+
+/**
+ * Exit codes by final goal status. The lifecycle has only one success outcome
+ * (`complete` → 0) and two resumable stopped states: `blocked` (the system
+ * stopped pursuing — the model's UpdateGoal, a budget, or an error) and `paused`
+ * (a turn abort / SIGINT). Both are non-zero — the goal did not complete. An absent goal
+ * (should not happen on the create path) maps to success.
+ */
+export const GOAL_EXIT_CODES = {
+ complete: 0,
+ blocked: 3,
+ paused: 6,
+} as const;
+
+export function goalExitCode(status: string | undefined): number {
+ switch (status) {
+ case 'blocked':
+ return GOAL_EXIT_CODES.blocked;
+ case 'paused':
+ return GOAL_EXIT_CODES.paused;
+ default:
+ return GOAL_EXIT_CODES.complete;
+ }
+}
+
+const GOAL_PREFIX = /^\/goal(\s|$)/;
+
+/**
+ * Parses a headless prompt into a goal-create request, or `undefined` when the
+ * prompt is not a `/goal` create command (so the caller runs it as a normal
+ * prompt). Non-create goal subcommands are not supported headless and fall
+ * through to normal prompt handling. Malformed create commands throw instead of
+ * falling through, so validation errors are reported before anything is sent to
+ * the model.
+ */
+export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
+ const trimmed = prompt.trim();
+ if (!GOAL_PREFIX.test(trimmed)) return undefined;
+ const args = trimmed.replace(/^\/goal/, '').trim();
+ const parsed = parseGoalCommand(args);
+ if (parsed.kind === 'error') {
+ throw new Error(parsed.message);
+ }
+ if (parsed.kind !== 'create') return undefined;
+ return { objective: parsed.objective, replace: parsed.replace };
+}
+
+export interface GoalSummary {
+ readonly type: 'goal.summary';
+ readonly goalId: string | null;
+ readonly status: string | null;
+ readonly reason: string | null;
+ readonly turnsUsed: number | null;
+ readonly tokensUsed: number | null;
+ readonly wallClockMs: number | null;
+}
+
+export function goalSummaryJson(goal: GoalSnapshot | null): GoalSummary {
+ if (goal === null) {
+ return {
+ type: 'goal.summary',
+ goalId: null,
+ status: null,
+ reason: null,
+ turnsUsed: null,
+ tokensUsed: null,
+ wallClockMs: null,
+ };
+ }
+ return {
+ type: 'goal.summary',
+ goalId: goal.goalId,
+ status: goal.status,
+ reason: goal.terminalReason ?? null,
+ turnsUsed: goal.turnsUsed,
+ tokensUsed: goal.tokensUsed,
+ wallClockMs: goal.wallClockMs,
+ };
+}
+
+export function formatGoalSummaryText(goal: GoalSnapshot | null): string {
+ if (goal === null) return 'Goal: no goal found.';
+ const parts = [`Goal [${goal.status}]`];
+ if (goal.terminalReason !== undefined) parts.push(goal.terminalReason);
+ return `${parts.join(': ')} (turns: ${goal.turnsUsed}, tokens: ${goal.tokensUsed})`;
+}
diff --git a/apps/kimi-code/src/cli/headless-exit.ts b/apps/kimi-code/src/cli/headless-exit.ts
new file mode 100644
index 0000000000000000000000000000000000000000..18057284431b1c6ff9df4ec80d28f9b877856cff
--- /dev/null
+++ b/apps/kimi-code/src/cli/headless-exit.ts
@@ -0,0 +1,96 @@
+import type { Writable } from 'node:stream';
+
+import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app';
+
+/** Minimal process surface needed to force a headless run to terminate. */
+export interface ExitableProcess {
+ exit(code?: number): void;
+}
+
+/**
+ * Schedule a best-effort force-exit for a completed headless (`kimi -p`) run.
+ *
+ * Print mode does not call `process.exit()`; it relies on the Node event loop
+ * draining once the run is done. If a stray ref'd handle survives shutdown — a
+ * lingering socket (e.g. a connection blackholed by a restrictive firewall, or
+ * an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose
+ * pipes stay open — the loop never empties and the process hangs until an
+ * external timeout kills it.
+ *
+ * This arms an **unref'd** fallback timer: a healthy run drains and exits
+ * naturally before it fires (so behaviour is unchanged), and the timer itself
+ * never keeps the loop alive. It only force-exits a run whose loop is already
+ * wedged. The exit code is read lazily at fire time so callers may set
+ * `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal
+ * status to a non-zero code).
+ *
+ * Returns the timer handle so callers/tests can `clearTimeout` it.
+ */
+export function scheduleHeadlessForceExit(
+ proc: ExitableProcess,
+ getExitCode: () => number,
+ graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS,
+): NodeJS.Timeout {
+ const timer = setTimeout(() => {
+ proc.exit(getExitCode());
+ }, graceMs);
+ timer.unref?.();
+ return timer;
+}
+
+/** Resolve once a stream's currently-buffered writes have flushed to its sink. */
+function flushStream(stream: Writable): Promise {
+ return new Promise((resolve) => {
+ try {
+ // An empty write's callback fires after all previously-queued writes have
+ // been flushed (writes are ordered), which is the documented way to know a
+ // stream's buffer has drained.
+ stream.write('', () => resolve());
+ } catch {
+ resolve();
+ }
+ });
+}
+
+/**
+ * Wait for buffered output on the given streams to flush, bounded by `timeoutMs`.
+ *
+ * A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the
+ * pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any
+ * force-exit prevents truncating output from an otherwise-successful run. The
+ * wait is bounded so a permanently-stuck consumer can't re-introduce the hang.
+ */
+export async function drainStdio(
+ streams: readonly Writable[],
+ timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS,
+): Promise {
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(resolve, timeoutMs);
+ timer.unref?.();
+ });
+ try {
+ await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]);
+ } finally {
+ if (timer !== undefined) clearTimeout(timer);
+ }
+}
+
+/**
+ * Finalize a completed headless run: flush stdio, then arm the force-exit
+ * backstop.
+ *
+ * Draining first means in-flight legitimate output is fully written before the
+ * backstop can fire, and — since drained stdio no longer holds the loop — only a
+ * genuinely leaked handle can keep it alive afterwards, which is exactly what
+ * the backstop is for.
+ */
+export async function finalizeHeadlessRun(
+ proc: ExitableProcess,
+ streams: readonly Writable[],
+ getExitCode: () => number,
+ options: { drainTimeoutMs?: number; graceMs?: number } = {},
+): Promise {
+ await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS);
+ scheduleHeadlessForceExit(proc, getExitCode, options.graceMs);
+}
diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts
new file mode 100644
index 0000000000000000000000000000000000000000..004fd7cabdf622dba31ec8c5c3037c0b797fdb95
--- /dev/null
+++ b/apps/kimi-code/src/cli/options.ts
@@ -0,0 +1,121 @@
+export type UIMode = 'shell' | 'print';
+export type PromptOutputFormat = 'text' | 'stream-json';
+
+/** Environment variable that sets the default `-p` output format (flag wins). */
+export const OUTPUT_FORMAT_ENV = 'KIMI_MODEL_OUTPUT_FORMAT';
+
+const OUTPUT_FORMATS = ['text', 'stream-json'] as const;
+
+function isOutputFormat(value: string): value is PromptOutputFormat {
+ return (OUTPUT_FORMATS as readonly string[]).includes(value);
+}
+
+/**
+ * Resolve the effective `-p` output format.
+ *
+ * Precedence: explicit `--output-format` flag → `KIMI_MODEL_OUTPUT_FORMAT` env
+ * (prompt mode only) → `text`. The env var is ignored outside prompt mode so an
+ * ambient value never affects interactive `kimi`. An invalid env value fails
+ * fast via `OptionConflictError`.
+ */
+export function resolveOutputFormat(
+ opts: Pick,
+ env: Readonly> = process.env,
+): PromptOutputFormat {
+ if (opts.outputFormat !== undefined) return opts.outputFormat;
+ if (opts.prompt === undefined) return 'text';
+ const raw = (env[OUTPUT_FORMAT_ENV] ?? '').trim();
+ if (raw.length === 0) return 'text';
+ if (!isOutputFormat(raw)) {
+ throw new OptionConflictError(
+ `Invalid ${OUTPUT_FORMAT_ENV} value "${raw}". Expected one of: text, stream-json.`,
+ );
+ }
+ return raw;
+}
+
+export interface CLIOptions {
+ session: string | undefined;
+ continue: boolean;
+ yolo: boolean;
+ auto: boolean;
+ plan: boolean;
+ model: string | undefined;
+ outputFormat: PromptOutputFormat | undefined;
+ prompt: string | undefined;
+ skillsDirs: string[];
+ agent: string | undefined;
+ agentFiles: string[];
+ addDirs?: string[];
+}
+
+export interface ValidatedOptions {
+ options: CLIOptions;
+ uiMode: UIMode;
+}
+
+export class OptionConflictError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'OptionConflictError';
+ }
+}
+
+export function validateOptions(
+ opts: CLIOptions,
+ env: Readonly> = process.env,
+): ValidatedOptions {
+ const prompt = opts.prompt;
+ const promptMode = prompt !== undefined;
+ if (promptMode && prompt.trim().length === 0) {
+ throw new OptionConflictError('Prompt cannot be empty.');
+ }
+ if (opts.model !== undefined && opts.model.trim().length === 0) {
+ throw new OptionConflictError('Model cannot be empty.');
+ }
+ if (!promptMode && opts.outputFormat !== undefined) {
+ throw new OptionConflictError('Output format is only supported in prompt mode.');
+ }
+ if (promptMode && opts.yolo) {
+ throw new OptionConflictError('Cannot combine --prompt with --yolo.');
+ }
+ if (promptMode && opts.auto) {
+ throw new OptionConflictError('Cannot combine --prompt with --auto.');
+ }
+ if (promptMode && opts.plan) {
+ throw new OptionConflictError('Cannot combine --prompt with --plan.');
+ }
+ if (opts.agent !== undefined && opts.agent.trim().length === 0) {
+ throw new OptionConflictError('Agent cannot be empty.');
+ }
+ if (opts.agentFiles.length > 1) {
+ throw new OptionConflictError('--agent-file may only be specified once.');
+ }
+ if (opts.agentFiles.some((file) => file.trim().length === 0)) {
+ throw new OptionConflictError('Agent file path cannot be empty.');
+ }
+ if (opts.agent !== undefined && opts.agentFiles.length > 0) {
+ throw new OptionConflictError('Cannot combine --agent with --agent-file.');
+ }
+ if (
+ (opts.agent !== undefined || opts.agentFiles.length > 0) &&
+ (opts.session !== undefined || opts.continue)
+ ) {
+ throw new OptionConflictError(
+ 'Cannot combine --agent/--agent-file with --session/--continue: the agent is bound at session creation and the bound agent is restored automatically on resume.',
+ );
+ }
+ if (promptMode && opts.session === '') {
+ throw new OptionConflictError('Cannot use --session without an id in prompt mode.');
+ }
+ if (opts.continue && opts.session !== undefined) {
+ throw new OptionConflictError('Cannot combine --continue, --session.');
+ }
+ if (opts.yolo && opts.auto) {
+ throw new OptionConflictError('Cannot combine --yolo with --auto.');
+ }
+ // Validate `KIMI_MODEL_OUTPUT_FORMAT` eagerly in prompt mode so a typo fails
+ // fast through the friendly `error:` path instead of mid-run.
+ if (promptMode) resolveOutputFormat(opts, env);
+ return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
+}
diff --git a/apps/kimi-code/src/cli/prompt-render.ts b/apps/kimi-code/src/cli/prompt-render.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0e2f35238db066a13b53ad2cfff11bdff2f76724
--- /dev/null
+++ b/apps/kimi-code/src/cli/prompt-render.ts
@@ -0,0 +1,410 @@
+/**
+ * Output rendering for `kimi -p` (print mode) — shared by the v1 driver
+ * (`run-prompt.ts`) and the native v2 runner (`v2/run-v2-print.ts`).
+ *
+ * Both engines feed the same writer classes: v1 via the SDK `Event` stream, v2
+ * via the main agent's native `IEventBus` (whose `DomainEvent` payloads are
+ * already v1-protocol-shaped). Keeping the writers here lets v2 reuse them
+ * without re-implementing rendering, while v1's `runPromptTurn` keeps its own
+ * event-filtering / completion flow intact.
+ */
+
+import type { PromptOutputFormat } from './options';
+
+/**
+ * Structural hook-result shape the renderer reads. Both the v1 SDK
+ * `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it,
+ * so the renderer stays engine-agnostic without depending on either event
+ * definition.
+ */
+interface HookResultEventLike {
+ readonly hookEvent: string;
+ readonly content: string;
+ readonly blocked?: boolean;
+}
+
+/**
+ * Structural retry shape the renderer reads. Mirrors the v1 SDK
+ * `turn.step.retrying` event fields the stream-json meta line surfaces. Both
+ * drivers forward retries to `writeRetrying`: v1 from its SDK event stream,
+ * v2 from the native `turn.step.retrying` `DomainEvent` (same field names),
+ * after discarding the failed attempt's partial output.
+ */
+interface RetryingEventLike {
+ readonly failedAttempt: number;
+ readonly nextAttempt: number;
+ readonly maxAttempts: number;
+ readonly delayMs: number;
+ readonly errorName: string;
+ readonly errorMessage: string;
+ readonly statusCode?: number;
+}
+
+export interface PromptOutput {
+ readonly columns?: number | undefined;
+ write(chunk: string): boolean;
+}
+
+const PROMPT_BLOCK_BULLET = '• ';
+const PROMPT_BLOCK_INDENT = ' ';
+
+export interface PromptTurnWriter {
+ writeAssistantDelta(delta: string): void;
+ writeHookResult(event: HookResultEventLike): void;
+ writeThinkingDelta(delta: string): void;
+ writeToolCall(toolCallId: string, name: string, args: unknown): void;
+ writeToolCallDelta(
+ toolCallId: string,
+ name: string | undefined,
+ argumentsPart: string | undefined,
+ ): void;
+ writeToolResult(toolCallId: string, output: unknown): void;
+ writeRetrying(event: RetryingEventLike): void;
+ flushAssistant(): void;
+ discardAssistant(): void;
+ finish(): void;
+}
+
+interface PromptJsonToolCall {
+ type: 'function';
+ id: string;
+ function: {
+ name: string;
+ arguments: string;
+ };
+}
+
+interface PromptJsonAssistantMessage {
+ role: 'assistant';
+ content?: string;
+ tool_calls?: PromptJsonToolCall[];
+}
+
+interface PromptJsonToolMessage {
+ role: 'tool';
+ tool_call_id: string;
+ content: string;
+}
+
+interface PromptJsonRetryMetaMessage {
+ role: 'meta';
+ type: 'turn.step.retrying';
+ failed_attempt: number;
+ next_attempt: number;
+ max_attempts: number;
+ delay_ms: number;
+ error_name: string;
+ error_message: string;
+ status_code?: number;
+}
+
+export class PromptTranscriptWriter implements PromptTurnWriter {
+ private readonly assistantWriter: PromptBlockWriter;
+ private readonly thinkingWriter: PromptBlockWriter;
+
+ constructor(stdout: PromptOutput, stderr: PromptOutput) {
+ this.assistantWriter = new PromptBlockWriter(stdout);
+ this.thinkingWriter = new PromptBlockWriter(stderr);
+ }
+
+ writeAssistantDelta(delta: string): void {
+ this.thinkingWriter.finish();
+ this.assistantWriter.write(delta);
+ }
+
+ writeHookResult(event: HookResultEventLike): void {
+ this.thinkingWriter.finish();
+ this.assistantWriter.finish();
+ this.assistantWriter.write(formatHookResultPlain(event));
+ this.assistantWriter.finish();
+ }
+
+ writeThinkingDelta(delta: string): void {
+ this.thinkingWriter.write(delta);
+ }
+
+ writeToolCall(): void {}
+
+ writeToolCallDelta(): void {}
+
+ writeToolResult(): void {}
+
+ // Text `-p` keeps retries silent: only the failed attempt's partial assistant
+ // text is discarded (handled by the caller). No human-readable retry line is
+ // emitted, matching the prior behavior.
+ writeRetrying(): void {}
+
+ flushAssistant(): void {
+ this.assistantWriter.finish();
+ }
+
+ discardAssistant(): void {}
+
+ finish(): void {
+ this.thinkingWriter.finish();
+ this.assistantWriter.finish();
+ }
+}
+
+export class PromptJsonWriter implements PromptTurnWriter {
+ private assistantText = '';
+ private readonly toolCalls: PromptJsonToolCall[] = [];
+
+ constructor(private readonly stdout: PromptOutput) {}
+
+ writeAssistantDelta(delta: string): void {
+ this.assistantText += delta;
+ }
+
+ writeHookResult(event: HookResultEventLike): void {
+ this.flushAssistant();
+ this.writeJsonLine({
+ role: 'assistant',
+ content: formatHookResultPlain(event),
+ });
+ }
+
+ writeThinkingDelta(): void {}
+
+ writeToolCall(toolCallId: string, name: string, args: unknown): void {
+ const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
+ if (existing !== undefined) {
+ existing.function.name = name;
+ existing.function.arguments = stringifyJsonValue(args);
+ return;
+ }
+ this.toolCalls.push({
+ type: 'function',
+ id: toolCallId,
+ function: {
+ name,
+ arguments: stringifyJsonValue(args),
+ },
+ });
+ }
+
+ writeToolCallDelta(
+ toolCallId: string,
+ name: string | undefined,
+ argumentsPart: string | undefined,
+ ): void {
+ const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
+ if (name !== undefined) {
+ toolCall.function.name = name;
+ }
+ if (argumentsPart !== undefined) {
+ toolCall.function.arguments += argumentsPart;
+ }
+ }
+
+ writeToolResult(toolCallId: string, output: unknown): void {
+ this.flushAssistant();
+ this.writeJsonLine({
+ role: 'tool',
+ tool_call_id: toolCallId,
+ content: stringifyToolOutput(output),
+ });
+ }
+
+ writeRetrying(event: RetryingEventLike): void {
+ // Emit a machine-readable meta line so stream-json consumers can observe
+ // provider retries. The failed attempt's partial assistant text was already
+ // discarded by the caller, so no half-formed assistant message leaks.
+ const message: PromptJsonRetryMetaMessage = {
+ role: 'meta',
+ type: 'turn.step.retrying',
+ failed_attempt: event.failedAttempt,
+ next_attempt: event.nextAttempt,
+ max_attempts: event.maxAttempts,
+ delay_ms: event.delayMs,
+ error_name: event.errorName,
+ error_message: event.errorMessage,
+ status_code: event.statusCode,
+ };
+ this.writeJsonLine(message);
+ }
+
+ flushAssistant(): void {
+ if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
+ const message: PromptJsonAssistantMessage = {
+ role: 'assistant',
+ content: this.assistantText.length > 0 ? this.assistantText : undefined,
+ tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
+ };
+ this.writeJsonLine(message);
+ this.discardAssistant();
+ }
+
+ discardAssistant(): void {
+ this.assistantText = '';
+ this.toolCalls.length = 0;
+ }
+
+ finish(): void {
+ this.flushAssistant();
+ }
+
+ private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
+ const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
+ if (existing !== undefined) return existing;
+ const toolCall: PromptJsonToolCall = {
+ type: 'function',
+ id: toolCallId,
+ function: {
+ name,
+ arguments: '',
+ },
+ };
+ this.toolCalls.push(toolCall);
+ return toolCall;
+ }
+
+ private writeJsonLine(
+ message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage,
+ ): void {
+ this.stdout.write(`${JSON.stringify(message)}\n`);
+ }
+}
+
+class PromptBlockWriter {
+ private started = false;
+ private atLineStart = false;
+ private lineWidth = 0;
+ private readonly wrapWidth: number | undefined;
+
+ constructor(private readonly output: PromptOutput) {
+ this.wrapWidth =
+ typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
+ ? output.columns
+ : undefined;
+ }
+
+ write(chunk: string): void {
+ if (chunk.length === 0) return;
+ let rendered = this.start();
+ for (const char of chunk) {
+ if (this.atLineStart && char !== '\n') {
+ rendered += PROMPT_BLOCK_INDENT;
+ this.atLineStart = false;
+ this.lineWidth = PROMPT_BLOCK_INDENT.length;
+ }
+ const charWidth = visibleCharWidth(char);
+ if (
+ this.wrapWidth !== undefined &&
+ !this.atLineStart &&
+ char !== '\n' &&
+ this.lineWidth + charWidth > this.wrapWidth
+ ) {
+ rendered += `\n${PROMPT_BLOCK_INDENT}`;
+ this.lineWidth = PROMPT_BLOCK_INDENT.length;
+ }
+ rendered += char;
+ if (char === '\n') {
+ this.atLineStart = true;
+ this.lineWidth = 0;
+ } else {
+ this.lineWidth += charWidth;
+ }
+ }
+ this.output.write(rendered);
+ }
+
+ finish(): void {
+ if (!this.started) return;
+ this.output.write(this.atLineStart ? '\n' : '\n\n');
+ this.started = false;
+ this.atLineStart = false;
+ this.lineWidth = 0;
+ }
+
+ private start(): string {
+ if (this.started) return '';
+ this.started = true;
+ this.atLineStart = false;
+ this.lineWidth = PROMPT_BLOCK_BULLET.length;
+ return PROMPT_BLOCK_BULLET;
+ }
+}
+
+function visibleCharWidth(char: string): number {
+ return char === '\t' ? 4 : 1;
+}
+
+function formatHookResultPlain(event: HookResultEventLike): string {
+ return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
+}
+
+function formatHookResultTitle(event: HookResultEventLike): string {
+ return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
+}
+
+function formatHookResultBody(event: HookResultEventLike): string {
+ const content = event.content.trim();
+ return content.length === 0 ? '(empty)' : content;
+}
+
+function stringifyJsonValue(value: unknown): string {
+ if (typeof value === 'string') return value;
+ const json = JSON.stringify(value);
+ return json ?? '';
+}
+
+function stringifyToolOutput(output: unknown): string {
+ if (typeof output === 'string') return output;
+ const json = JSON.stringify(output);
+ return json ?? String(output);
+}
+
+interface PromptJsonResumeMetaMessage {
+ role: 'meta';
+ type: 'session.resume_hint';
+ session_id: string;
+ command: string;
+ content: string;
+}
+
+interface PromptJsonVersionMetaMessage {
+ role: 'meta';
+ type: 'system.version';
+ version: string;
+}
+
+export function writeExperimentalVersion(
+ version: string,
+ outputFormat: PromptOutputFormat,
+ stdout: PromptOutput,
+ stderr: PromptOutput,
+): void {
+ if (outputFormat === 'stream-json') {
+ const message: PromptJsonVersionMetaMessage = {
+ role: 'meta',
+ type: 'system.version',
+ version,
+ };
+ stdout.write(`${JSON.stringify(message)}\n`);
+ return;
+ }
+ stderr.write(`kimi version ${version}\n`);
+}
+
+export function writeResumeHint(
+ sessionId: string,
+ outputFormat: PromptOutputFormat,
+ stdout: PromptOutput,
+ stderr: PromptOutput,
+): void {
+ const command = `kimi -r ${sessionId}`;
+ const content = `To resume this session: ${command}`;
+ if (outputFormat === 'stream-json') {
+ const message: PromptJsonResumeMetaMessage = {
+ role: 'meta',
+ type: 'session.resume_hint',
+ session_id: sessionId,
+ command,
+ content,
+ };
+ stdout.write(`${JSON.stringify(message)}\n`);
+ return;
+ }
+ stderr.write(`${content}\n`);
+}
diff --git a/apps/kimi-code/src/cli/prompt-session.ts b/apps/kimi-code/src/cli/prompt-session.ts
new file mode 100644
index 0000000000000000000000000000000000000000..668fa3ddfd635f7b32da633eeff10223cb5f4b70
--- /dev/null
+++ b/apps/kimi-code/src/cli/prompt-session.ts
@@ -0,0 +1,26 @@
+import type {
+ ConfigDiagnostics,
+ CreateSessionOptions,
+ KimiAuthFacade,
+ KimiConfig,
+ ListSessionsOptions,
+ ResumeSessionInput,
+ Session,
+ SessionSummary,
+ TelemetryProperties,
+} from '@moonshot-ai/kimi-code-sdk';
+
+export interface PromptHarness {
+ readonly homeDir: string;
+ readonly auth: KimiAuthFacade;
+
+ track(event: string, properties?: TelemetryProperties): void;
+
+ ensureConfigFile(): Promise;
+ getConfig(): Promise>;
+ getConfigDiagnostics(): Promise;
+ listSessions(options: ListSessionsOptions): Promise;
+ createSession(options: CreateSessionOptions): Promise;
+ resumeSession(input: ResumeSessionInput): Promise;
+ close(): Promise;
+}
diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts
new file mode 100644
index 0000000000000000000000000000000000000000..65a49446872c9fb939343324cef5ff8e15f3cc76
--- /dev/null
+++ b/apps/kimi-code/src/cli/run-prompt.ts
@@ -0,0 +1,94 @@
+import type { CLIOptions } from './options';
+
+export async function raceWithTimeout(promise: Promise, timeoutMs: number): Promise {
+ let timedOut = false;
+ let timer: ReturnType | undefined;
+ const guarded = promise.catch((error: unknown) => {
+ if (timedOut) return;
+ throw error;
+ });
+ const timedOutSignal = new Promise((resolve) => {
+ timer = setTimeout(() => {
+ timedOut = true;
+ resolve();
+ }, timeoutMs);
+ });
+ try {
+ await Promise.race([guarded, timedOutSignal]);
+ } finally {
+ if (timer !== undefined) clearTimeout(timer);
+ }
+}
+
+interface PromptOutput {
+ readonly columns?: number | undefined;
+ write(chunk: string): boolean;
+}
+
+export interface PromptRunIO {
+ readonly stdout?: PromptOutput;
+ readonly stderr?: PromptOutput;
+ readonly process?: PromptProcess;
+}
+
+export interface PromptProcess {
+ once(signal: NodeJS.Signals, listener: () => Promise): unknown;
+ off(signal: NodeJS.Signals, listener: () => Promise): unknown;
+ exit(code?: number): never | void;
+}
+
+export async function runPrompt(
+ opts: CLIOptions,
+ version: string,
+ io: PromptRunIO = {},
+): Promise {
+ const { runV2Print } = await import('./v2/run-v2-print');
+ await runV2Print(opts, version, io);
+}
+
+export function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
+ const model = configuredModel(...models);
+ if (model === undefined) {
+ throw new Error(
+ 'No model configured. Run `kimi` and use /login to sign in, then retry; or set default_model in config.toml.',
+ );
+ }
+ return model;
+}
+
+export function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
+ return models.find((model) => model !== undefined && model.trim().length > 0);
+}
+
+export function installPromptTerminationCleanup(
+ promptProcess: PromptProcess,
+ cleanup: () => Promise,
+): () => void {
+ let terminating = false;
+ const exitAfterCleanup = async (signal: NodeJS.Signals): Promise => {
+ if (terminating) return;
+ terminating = true;
+ try {
+ await cleanup();
+ } finally {
+ promptProcess.exit(signalExitCode(signal));
+ }
+ };
+ const onSigint = () => exitAfterCleanup('SIGINT');
+ const onSigterm = () => exitAfterCleanup('SIGTERM');
+ const onSighup = () => exitAfterCleanup('SIGHUP');
+ promptProcess.once('SIGINT', onSigint);
+ promptProcess.once('SIGTERM', onSigterm);
+ promptProcess.once('SIGHUP', onSighup);
+ return () => {
+ promptProcess.off('SIGINT', onSigint);
+ promptProcess.off('SIGTERM', onSigterm);
+ promptProcess.off('SIGHUP', onSighup);
+ };
+}
+
+export function signalExitCode(signal: NodeJS.Signals): number {
+ if (signal === 'SIGINT') return 130;
+ if (signal === 'SIGHUP') return 129;
+ return 143;
+}
diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts
new file mode 100644
index 0000000000000000000000000000000000000000..65349cfb6bd00dec8e60d5aa03d0355a7a18cb9b
--- /dev/null
+++ b/apps/kimi-code/src/cli/run-shell.ts
@@ -0,0 +1,288 @@
+import { execFileSync, spawnSync } from 'node:child_process';
+import { homedir } from 'node:os';
+
+import {
+ createKimiHarness,
+ flushDiagnosticLogsSync,
+ log,
+ type KimiHarness,
+ type KimiHarnessOptions,
+ type TelemetryClient,
+} from '@moonshot-ai/kimi-code-sdk';
+import {
+ setCrashPhase,
+ setTelemetryContext,
+ shutdownTelemetry,
+ track,
+ withTelemetryContext,
+} from '@moonshot-ai/kimi-telemetry';
+
+import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, TUI_HOST_UI_CAPABILITIES } from '#/constant/app';
+import { detectPendingMigration, resolveLegacySourceHome, sameLegacyPath } from '#/migration/index';
+import type { TuiConfig } from '#/tui/config';
+import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
+import { CHROME_GUTTER } from '#/tui/constant/rendering';
+import { KimiTUI } from '#/tui/index';
+import { startupTrace } from '#/utils/startup-trace';
+import { currentTheme, getColorPalette } from '#/tui/theme';
+import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
+import { restoreTerminalModes } from '#/utils/terminal-restore';
+import { resolveCommandPath } from '#/utils/process/resolve-command';
+
+import type { CLIOptions } from './options';
+import { resolveAgentProfileSelection } from './agent-selection';
+import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
+import { createKimiCodeHostIdentity } from './version';
+
+export async function runShell(
+ opts: CLIOptions,
+ version: string,
+ runOptions: { readonly migrateOnly?: boolean } = {},
+): Promise {
+ const startedAt = Date.now();
+ const configStartedAt = startedAt;
+ let tuiConfig: TuiConfig;
+ let configWarning: string | undefined;
+ try {
+ tuiConfig = await loadTuiConfig();
+ } catch (error) {
+ if (!(error instanceof TuiConfigParseError)) throw error;
+ tuiConfig = error.fallback;
+ configWarning = error.message;
+ }
+
+ // Initialise the global Theme singleton before pi-tui grabs stdin.
+ const palette = await getColorPalette(tuiConfig.theme);
+ currentTheme.setPalette(palette);
+
+ const workDir = process.cwd();
+ const telemetryBootstrap = createCliTelemetryBootstrap();
+ const telemetryClient: TelemetryClient = {
+ track,
+ withContext: withTelemetryContext,
+ setContext: setTelemetryContext,
+ };
+ const harnessOptions: KimiHarnessOptions = {
+ homeDir: telemetryBootstrap.homeDir,
+ identity: createKimiCodeHostIdentity(version),
+ skillDirs: opts.skillsDirs,
+ // The TUI renders the mid-turn update panel; declaring it here is what
+ // makes the engine offer NotifyUser to this process and to no other host.
+ uiCapabilities: TUI_HOST_UI_CAPABILITIES,
+ telemetry: telemetryClient,
+ onOAuthRefresh: (outcome) => {
+ if (outcome.success) {
+ track('oauth_refresh', { outcome: 'success' });
+ return;
+ }
+ track('oauth_refresh', {
+ outcome: 'error',
+ reason: outcome.reason,
+ });
+ },
+ sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false },
+ };
+ const harness = createKimiHarness(harnessOptions);
+ startupTrace('harness:created');
+ log.info('kimi-code starting', {
+ version,
+ uiMode: CLI_UI_MODE,
+ nodeVersion: process.version,
+ platform: `${process.platform}/${process.arch}`,
+ workDir,
+ });
+
+ await harness.ensureConfigFile();
+ const legacySource = resolveLegacySourceHome(process.env, homedir(), process.cwd());
+ const sourceIsTarget = sameLegacyPath(legacySource.sourceHome, harness.homeDir);
+ if (sourceIsTarget) {
+ process.stderr.write(
+ ` KIMI_SHARE_DIR (${legacySource.sourceHome}) points at the Kimi Code home; legacy migration is disabled. Unset it or point it at the kimi-cli data directory to migrate.\n`,
+ );
+ }
+ const migrationPlan = sourceIsTarget
+ ? null
+ : await detectPendingMigration({
+ sourceHome: legacySource.sourceHome,
+ skillsSourceHome: legacySource.skillsSourceHome,
+ targetHome: harness.homeDir,
+ ignoreMarker: runOptions.migrateOnly,
+ });
+ if (runOptions.migrateOnly === true && migrationPlan === null) {
+ if (!sourceIsTarget) {
+ process.stdout.write(` Nothing to migrate from ${legacySource.sourceHome}.\n`);
+ }
+ await harness.close();
+ return;
+ }
+ const config = await harness.getConfig();
+ startupTrace('config:loaded');
+ // Config diagnostics (deprecated keys, invalid sections, ...) are surfaced
+ // by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` —
+ // folded into the dim startup notice they were too easy to miss.
+ const configMs = Date.now() - configStartedAt;
+ // Resolve --agent/--agent-file once for the startup session; validateOptions
+ // has already rejected them alongside --session/--continue.
+ const agentProfile = await resolveAgentProfileSelection(opts, workDir);
+ const tui = new KimiTUI(harness, {
+ cliOptions: opts,
+ agentProfile,
+ additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
+ tuiConfig,
+ version,
+ workDir,
+ startupNotice: configWarning,
+ migrationPlan,
+ migrateOnly: runOptions.migrateOnly,
+ telemetryDisabled: config.telemetry === false,
+ });
+
+ initializeCliTelemetry({
+ harness,
+ bootstrap: telemetryBootstrap,
+ config,
+ version,
+ uiMode: CLI_UI_MODE,
+ });
+ setCrashPhase('runtime');
+
+ const trackLifecycleForSession = (
+ sessionId: string,
+ event: string,
+ properties?: Parameters[1],
+ ) => {
+ if (sessionId.length === 0) {
+ harness.track(event, properties);
+ return;
+ }
+ withTelemetryContext({ sessionId }).track(event, properties);
+ };
+ const trackLifecycle = (event: string, properties?: Parameters[1]) => {
+ trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
+ };
+
+ let savedStty: string | undefined;
+ // stty runs before tui.start() reaches the workspace trust gate, so it must
+ // never be resolved by name through PATH: a `.` or empty PATH segment would
+ // let an untrusted checkout plant an `stty` executable and run it pre-trust.
+ // resolveCommandPath returns an absolute path and refuses hits inside the
+ // cwd; when it cannot resolve stty, skip the save/restore entirely — it is
+ // best-effort terminal hygiene, not required for startup.
+ // stty is also POSIX-only, so skip it on Windows instead of relying on the
+ // catch below.
+ const sttyPath = process.platform === 'win32' ? undefined : resolveCommandPath('stty');
+ if (sttyPath !== undefined) {
+ try {
+ // stty operates on the terminal behind stdin, so stdin must be the TTY —
+ // piping /dev/null (ignore) makes stty fail with "not a tty".
+ const saved = execFileSync(sttyPath, ['-g'], {
+ encoding: 'utf8',
+ stdio: ['inherit', 'pipe', 'ignore'],
+ });
+ savedStty = saved.trim();
+ execFileSync(sttyPath, ['-ixon'], { stdio: ['inherit', 'ignore', 'ignore'] });
+ } catch {
+ /* ignore */
+ }
+ }
+ const restoreStty = (): void => {
+ if (sttyPath === undefined || savedStty === undefined) return;
+ const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
+ if (args.length === 0) return;
+ spawnSync(sttyPath, args, { stdio: ['inherit', 'ignore', 'ignore'] });
+ };
+
+ // If we crash without going through KimiTUI.stop(), the terminal is left in
+ // raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore
+ // both before exiting so the user's shell is usable afterwards.
+ const emergencyExit = (exitCode: number): void => {
+ // The crash log above is only enqueued into the async sink; flush it
+ // synchronously or the `process.exit()` below would drop the one line that
+ // explains why we crashed. Best-effort: an exit path must never throw.
+ try {
+ flushDiagnosticLogsSync();
+ } catch {
+ /* ignore */
+ }
+ restoreTerminalModes();
+ restoreStty();
+ process.exit(exitCode);
+ };
+ const onUncaughtException = (error: unknown): void => {
+ try {
+ log.error('uncaughtException, restoring terminal and exiting', { error: String(error) });
+ } catch {
+ /* ignore */
+ }
+ emergencyExit(1);
+ };
+ const onUnhandledRejection = (reason: unknown): void => {
+ try {
+ log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) });
+ } catch {
+ /* ignore */
+ }
+ emergencyExit(1);
+ };
+ process.on('uncaughtException', onUncaughtException);
+ process.on('unhandledRejection', onUnhandledRejection);
+ // Remove the crash handlers once the TUI exits cleanly so repeated runShell()
+ // calls in the same process (e.g. tests) don't accumulate process listeners.
+ const removeCrashHandlers = (): void => {
+ process.off('uncaughtException', onUncaughtException);
+ process.off('unhandledRejection', onUnhandledRejection);
+ };
+
+ tui.onExit = async (exitCode = 0) => {
+ const sessionId = tui.getCurrentSessionId();
+ const hasContent = tui.hasSessionContent();
+ setCrashPhase('shutdown');
+ trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode });
+ await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
+ const gutter = ' '.repeat(CHROME_GUTTER);
+ process.stdout.write(`${gutter}Bye!\n`);
+ const hints: string[] = [];
+ if (sessionId !== '' && hasContent) {
+ hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`);
+ }
+ if (tui.exitOpenUrl !== undefined) {
+ hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`);
+ }
+ if (hints.length > 0) {
+ process.stderr.write(`\n${hints.join('\n')}\n`);
+ }
+ removeCrashHandlers();
+ restoreStty();
+ if (tui.exitForegroundTask !== undefined) {
+ // `/web` starting a new server: 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 {
+ const initStartedAt = Date.now();
+ startupTrace('tui.start:begin');
+ await tui.start();
+ startupTrace('tui.start:end');
+ const initMs = Date.now() - initStartedAt;
+ const startupSessionId = tui.getCurrentSessionId();
+ const mcpMs = await tui.getStartupMcpMs();
+ trackLifecycleForSession(startupSessionId, 'startup_perf', {
+ duration_ms: Date.now() - startedAt,
+ config_ms: configMs,
+ init_ms: initMs,
+ mcp_ms: mcpMs,
+ tui_mode: tui.state.ui.mode,
+ });
+ } catch (error) {
+ removeCrashHandlers();
+ setCrashPhase('shutdown');
+ trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode });
+ await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
+ await harness.close();
+ throw error;
+ }
+}
diff --git a/apps/kimi-code/src/cli/startup-error.ts b/apps/kimi-code/src/cli/startup-error.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b40b714f24548db3577cf10974204dbd023ad5da
--- /dev/null
+++ b/apps/kimi-code/src/cli/startup-error.ts
@@ -0,0 +1,35 @@
+import { KIMI_ERROR_INFO, isKimiError } from '@moonshot-ai/kimi-code-sdk';
+import { chalkStderr } from 'chalk';
+
+import { STARTUP_ERROR_COLOR } from '#/constant/startup-error';
+
+export interface StartupErrorFormatOptions {
+ readonly errorStyle?: (text: string) => string;
+ readonly operation?: string;
+}
+
+function formatUnknownErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+export function formatStartupError(
+ error: unknown,
+ options: StartupErrorFormatOptions = {},
+): string {
+ const errorStyle = options.errorStyle ?? chalkStderr.hex(STARTUP_ERROR_COLOR);
+
+ if (!isKimiError(error)) {
+ const operation = options.operation ?? 'start shell';
+ return `${errorStyle(`error: failed to ${operation}: ${formatUnknownErrorMessage(error)}`)}\n`;
+ }
+
+ const info = KIMI_ERROR_INFO[error.code];
+ const lines = [
+ errorStyle(`error: ${info.title}`),
+ '',
+ errorStyle('message:'),
+ errorStyle(error.message),
+ ];
+
+ return `${lines.join('\n')}\n`;
+}
diff --git a/apps/kimi-code/src/cli/sub/acp.ts b/apps/kimi-code/src/cli/sub/acp.ts
new file mode 100644
index 0000000000000000000000000000000000000000..17bbbe009a729f74e2987846263043a690b074d3
--- /dev/null
+++ b/apps/kimi-code/src/cli/sub/acp.ts
@@ -0,0 +1,75 @@
+/**
+ * `kimi acp` sub-command.
+ *
+ * Starts the Agent Client Protocol (ACP) server backed directly by the
+ * DI × Scope agent engine (`agent-core-v2`) over stdio, so ACP-compatible
+ * clients can drive a kimi-code session.
+ *
+ * Wire-up:
+ * - `--login` pivots into the shared device-code login flow (the entry point
+ * ACP clients hit via the first-class `AuthMethodTerminal` path, re-invoking
+ * the agent binary with the advertised `args:['--login']`).
+ * - `KIMI_CODE_HOME` (if set) is forwarded into `authMethods[0].env` so the
+ * login subprocess writes its token under the same data root the server
+ * reads from, and `process.argv[1]` is advertised as the legacy
+ * `_meta['terminal-auth'].command` fallback.
+ *
+ * `@moonshot-ai/acp-server` (and its `agent-core-v2` engine) is loaded via a
+ * lazy dynamic import so parsing the CLI does not initialize the ACP engine —
+ * mirroring the `kimi server run` v2 routing in `#/cli/sub/server/run.ts`.
+ */
+
+import type { Command } from 'commander';
+
+import { getVersion } from '#/cli/version';
+import { KIMI_CODE_HOME_ENV } from '#/constant/app';
+import { getDataDir } from '#/utils/paths';
+
+import { parseRegionFlag, runLoginFlow } from './login-flow';
+
+export function registerAcpCommand(parent: Command): void {
+ parent
+ .command('acp')
+ .description('Run kimi-code as an Agent Client Protocol (ACP) server over stdio.')
+ .option(
+ '--login',
+ 'Run the device-code login flow then exit (entry point for ACP terminal-auth).',
+ false,
+ )
+ .option('--region ', 'Login region used together with --login: "mainland-cn" (kimi.com) or "global" (kimi.ai).')
+ .action(async (opts: { login?: boolean; region?: string }) => {
+ if (opts.login === true) {
+ await runLoginFlow({
+ region: opts.region === undefined ? undefined : parseRegionFlag(opts.region),
+ });
+ return;
+ }
+ // Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the
+ // login subprocess clients spawn for terminal-auth writes its token
+ // under the same data root the ACP server reads from.
+ const sandboxHome = process.env[KIMI_CODE_HOME_ENV];
+ const terminalAuthEnv =
+ sandboxHome !== undefined && sandboxHome.length > 0
+ ? { [KIMI_CODE_HOME_ENV]: sandboxHome }
+ : undefined;
+ // Legacy `_meta.terminal-auth` fallback for clients that don't yet
+ // honor the first-class `type:'terminal'`. `command` is the absolute
+ // path to this very binary so the client can spawn it for login.
+ const legacyCommand = process.argv[1];
+ try {
+ const { runAcpServer } = await import('@moonshot-ai/acp-server');
+ await runAcpServer({
+ homeDir: getDataDir(),
+ agentInfo: { name: 'Kimi Code CLI', version: getVersion() },
+ ...(terminalAuthEnv ? { terminalAuthEnv } : {}),
+ ...(legacyCommand !== undefined && legacyCommand.length > 0
+ ? { terminalAuthLegacyCommand: legacyCommand }
+ : {}),
+ });
+ process.exit(0);
+ } catch (error) {
+ process.stderr.write(`acp server: fatal error: ${String(error)}\n`);
+ process.exit(1);
+ }
+ });
+}
diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cbbfa30004ee55b95e282a493eaf38318b46d266
--- /dev/null
+++ b/apps/kimi-code/src/cli/sub/doctor.ts
@@ -0,0 +1,335 @@
+import { existsSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { isAbsolute, resolve } from 'node:path';
+
+import { resolveConfigPath, type KimiConfigValidationIssue } from '@moonshot-ai/kimi-code-sdk';
+import type { Command } from 'commander';
+import { z } from 'zod';
+
+import { getTuiConfigPath, parseTuiConfig } from '#/tui/config';
+
+interface WritableLike {
+ write(chunk: string): boolean;
+}
+
+type MaybePromise = T | Promise;
+
+export interface DoctorDeps {
+ readonly cwd: () => string;
+ readonly defaultConfigPath: () => MaybePromise;
+ readonly defaultTuiConfigPath: () => string;
+ readonly stdout: WritableLike;
+ readonly stderr: WritableLike;
+ readonly exit: (code: number) => never;
+ readonly fileExists?: (path: string) => boolean;
+ readonly readTextFile?: (path: string) => Promise;
+ readonly validateConfigToml?: (text: string, path: string) => MaybePromise;
+}
+
+export interface DoctorOptions {
+ readonly target?: 'config' | 'tui';
+ readonly path?: string;
+}
+
+interface CheckSpec {
+ readonly label: 'config.toml' | 'tui.toml';
+ readonly path: string;
+ readonly explicit: boolean;
+ /** Throws on invalid content; may return a non-fatal warning message. */
+ readonly parse: (text: string, path: string) => MaybePromise;
+}
+
+interface CheckResult {
+ readonly label: CheckSpec['label'];
+ readonly path: string;
+ readonly status: 'OK' | 'SKIP' | 'ERROR';
+ readonly message?: string;
+}
+
+interface ResolvedDoctorDeps {
+ readonly cwd: () => string;
+ readonly defaultConfigPath: () => MaybePromise;
+ readonly defaultTuiConfigPath: () => string;
+ readonly stdout: WritableLike;
+ readonly stderr: WritableLike;
+ readonly exit: (code: number) => never;
+ readonly fileExists: (path: string) => boolean;
+ readonly readTextFile: (path: string) => Promise;
+ readonly validateConfigToml: (text: string, path: string) => MaybePromise;
+}
+
+export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise {
+ const resolved = resolveDeps(deps);
+ const cwd = resolved.cwd();
+ const specs = await buildCheckSpecs(resolved, options, cwd);
+ const results = await Promise.all(specs.map((spec) => checkTomlFile(resolved, spec)));
+
+ const issueCount = results.filter((result) => result.status === 'ERROR').length;
+ const text = issueCount === 0 ? formatSuccess(results) : formatFailure(results, issueCount);
+ if (issueCount === 0) {
+ resolved.stdout.write(text);
+ } else {
+ resolved.stderr.write(text);
+ }
+ return issueCount === 0 ? 0 : 1;
+}
+
+export function registerDoctorCommand(parent: Command, deps?: Partial): void {
+ const doctor = parent
+ .command('doctor')
+ .description('Validate Kimi Code configuration files.')
+ .action(async () => {
+ await runDoctorCommand(deps, {});
+ });
+
+ doctor
+ .command('config')
+ .description('Validate config.toml.')
+ .argument('[path]', 'Validate this file as config.toml instead of the default path.')
+ .action(async (path: string | undefined) => {
+ await runDoctorCommand(deps, { target: 'config', path });
+ });
+
+ doctor
+ .command('tui')
+ .description('Validate tui.toml.')
+ .argument('[path]', 'Validate this file as tui.toml instead of the default path.')
+ .action(async (path: string | undefined) => {
+ await runDoctorCommand(deps, { target: 'tui', path });
+ });
+}
+
+async function runDoctorCommand(
+ deps: Partial | undefined,
+ options: DoctorOptions,
+): Promise {
+ const resolved = resolveDeps(deps);
+ const code = await handleDoctor(resolved, options);
+ if (code !== 0) resolved.exit(code);
+}
+
+function resolveDeps(deps: Partial | DoctorDeps | undefined): ResolvedDoctorDeps {
+ return {
+ cwd: deps?.cwd ?? (() => process.cwd()),
+ defaultConfigPath: deps?.defaultConfigPath ?? (() => resolveConfigPath({})),
+ defaultTuiConfigPath: deps?.defaultTuiConfigPath ?? getTuiConfigPath,
+ stdout: deps?.stdout ?? process.stdout,
+ stderr: deps?.stderr ?? process.stderr,
+ exit: deps?.exit ?? ((code) => process.exit(code)),
+ fileExists: deps?.fileExists ?? existsSync,
+ readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')),
+ validateConfigToml:
+ deps?.validateConfigToml ??
+ (async (text, filePath) => {
+ const { validateConfigTomlV2 } = await import('../v2/validate-config');
+ return validateConfigTomlV2(text, filePath);
+ }),
+ };
+}
+
+async function buildCheckSpecs(
+ deps: ResolvedDoctorDeps,
+ options: DoctorOptions,
+ cwd: string,
+): Promise {
+ if (options.target === 'config') {
+ return [
+ makeConfigSpec(
+ await resolveConfigTargetPath(deps, options.path, cwd),
+ options.path !== undefined,
+ deps,
+ ),
+ ];
+ }
+
+ if (options.target === 'tui') {
+ return [
+ makeTuiSpec(
+ resolveTuiTargetPath(deps, options.path, cwd),
+ options.path !== undefined,
+ ),
+ ];
+ }
+
+ return [
+ makeConfigSpec(await deps.defaultConfigPath(), false, deps),
+ makeTuiSpec(deps.defaultTuiConfigPath(), false),
+ ];
+}
+
+function makeConfigSpec(
+ path: string,
+ explicit: boolean,
+ deps: ResolvedDoctorDeps,
+): CheckSpec {
+ return {
+ label: 'config.toml',
+ path,
+ explicit,
+ parse: (text, filePath) => {
+ return deps.validateConfigToml(text, filePath);
+ },
+ };
+}
+
+function makeTuiSpec(path: string, explicit: boolean): CheckSpec {
+ return {
+ label: 'tui.toml',
+ path,
+ explicit,
+ parse: (text) => {
+ parseTuiConfig(text);
+ },
+ };
+}
+
+async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise {
+ if (!deps.fileExists(spec.path)) {
+ return {
+ label: spec.label,
+ path: spec.path,
+ status: spec.explicit ? 'ERROR' : 'SKIP',
+ message: spec.explicit
+ ? 'File does not exist.'
+ : 'File does not exist; built-in defaults will apply.',
+ };
+ }
+
+ try {
+ const text = await deps.readTextFile(spec.path);
+ const warning = await spec.parse(text, spec.path);
+ return { label: spec.label, path: spec.path, status: 'OK', message: warning ?? undefined };
+ } catch (error) {
+ return {
+ label: spec.label,
+ path: spec.path,
+ status: 'ERROR',
+ message: formatErrorMessage(error, spec.path),
+ };
+ }
+}
+
+async function resolveConfigTargetPath(
+ deps: ResolvedDoctorDeps,
+ input: string | undefined,
+ cwd: string,
+): Promise {
+ return input === undefined ? deps.defaultConfigPath() : resolveInputPath(input, cwd);
+}
+
+function resolveTuiTargetPath(
+ deps: ResolvedDoctorDeps,
+ input: string | undefined,
+ cwd: string,
+): string {
+ return input === undefined ? deps.defaultTuiConfigPath() : resolveInputPath(input, cwd);
+}
+
+function resolveInputPath(input: string, cwd: string): string {
+ return isAbsolute(input) ? input : resolve(cwd, input);
+}
+
+function formatSuccess(results: readonly CheckResult[]): string {
+ return [
+ 'Kimi doctor',
+ '',
+ ...formatResults(results),
+ '',
+ 'All checked config files are valid.',
+ '',
+ ].join('\n');
+}
+
+function formatFailure(results: readonly CheckResult[], issueCount: number): string {
+ return [
+ `Kimi doctor found ${String(issueCount)} ${issueCount === 1 ? 'issue' : 'issues'}.`,
+ '',
+ ...formatResults(results),
+ '',
+ ].join('\n');
+}
+
+function formatResults(results: readonly CheckResult[]): string[] {
+ const lines: string[] = [];
+ for (const result of results) {
+ lines.push(`${result.status} ${result.label.padEnd(12)} ${result.path}`);
+ if (result.message !== undefined) {
+ for (const line of result.message.split('\n')) {
+ lines.push(` ${line}`);
+ }
+ }
+ }
+ return lines;
+}
+
+function formatErrorMessage(error: unknown, filePath: string): string {
+ const validationIssues = findValidationIssues(error);
+ if (validationIssues !== undefined) {
+ return [
+ `Invalid configuration in ${filePath}.`,
+ 'Validation issues:',
+ ...validationIssues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`),
+ ].join('\n');
+ }
+
+ const zodError = findZodError(error);
+ if (zodError !== undefined) {
+ return [
+ `Invalid configuration in ${filePath}.`,
+ 'Validation issues:',
+ ...zodError.issues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`),
+ ].join('\n');
+ }
+ return error instanceof Error ? error.message : String(error);
+}
+
+function findValidationIssues(error: unknown): readonly KimiConfigValidationIssue[] | undefined {
+ if (!(error instanceof Error)) return undefined;
+ const details = 'details' in error ? error.details : undefined;
+ if (!isRecord(details)) return undefined;
+ const validationIssues = details['validationIssues'];
+ return isValidationIssueArray(validationIssues) ? validationIssues : undefined;
+}
+
+function isValidationIssueArray(value: unknown): value is readonly KimiConfigValidationIssue[] {
+ return Array.isArray(value) && value.every(isValidationIssue);
+}
+
+function isValidationIssue(value: unknown): value is KimiConfigValidationIssue {
+ if (!isRecord(value) || typeof value['message'] !== 'string') return false;
+ const path = value['path'];
+ return (
+ Array.isArray(path) &&
+ path.every((segment) => typeof segment === 'string' || typeof segment === 'number')
+ );
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+function findZodError(error: unknown): z.ZodError | undefined {
+ if (error instanceof z.ZodError) return error;
+ if (error instanceof Error && error.cause instanceof z.ZodError) return error.cause;
+ return undefined;
+}
+
+function formatIssuePath(path: readonly PropertyKey[]): string {
+ if (path.length === 0) return '';
+
+ let out = '';
+ for (const segment of path) {
+ if (typeof segment === 'number') {
+ out += `[${String(segment)}]`;
+ } else if (out.length === 0) {
+ out = camelToSnake(String(segment));
+ } else {
+ out += `.${camelToSnake(String(segment))}`;
+ }
+ }
+ return out;
+}
+
+function camelToSnake(value: string): string {
+ return value.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
+}
diff --git a/apps/kimi-code/src/cli/sub/export.ts b/apps/kimi-code/src/cli/sub/export.ts
new file mode 100644
index 0000000000000000000000000000000000000000..79c1315435846c8fd1b53b7cce842fb640518f77
--- /dev/null
+++ b/apps/kimi-code/src/cli/sub/export.ts
@@ -0,0 +1,251 @@
+/**
+ * `kimi export` sub-command.
+ *
+ * CLI glue only: session lookup, previous-session confirmation, and output.
+ * The actual ZIP/manifest export is owned by the SDK.
+ */
+
+import { createInterface } from 'node:readline/promises';
+
+import {
+ setTelemetryContext,
+ shutdownTelemetry,
+ track,
+ withTelemetryContext,
+} from '@moonshot-ai/kimi-telemetry';
+import {
+ createKimiHarness,
+ type ExportSessionInput,
+ type ExportSessionResult,
+ type KimiHarness,
+ type SessionSummary,
+ type ShellEnvironment,
+ type TelemetryClient,
+} from '@moonshot-ai/kimi-code-sdk';
+import type { Command } from 'commander';
+
+import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
+import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry';
+import { detectInstallSource } from '#/cli/update/source';
+import { createKimiCodeHostIdentity } from '#/cli/version';
+import { detectShellEnvironment } from '#/utils/process/shell-env';
+
+interface WritableLike {
+ write(chunk: string): boolean;
+}
+
+export interface PreviousSessionSummary {
+ readonly workDir: string;
+ readonly sessionId: string;
+ readonly sessionDir: string;
+ readonly title?: string | undefined;
+}
+
+export interface ExportDeps {
+ readonly listSessions: (workDir: string) => Promise;
+ readonly exportSession: (input: ExportSessionInput) => Promise;
+ readonly confirmPreviousSession: (summary: PreviousSessionSummary) => Promise;
+ readonly getInstallSource: () => Promise;
+ readonly getShellEnv: () => ShellEnvironment;
+ readonly version: string;
+ readonly cwd: () => string;
+ readonly stdout: WritableLike;
+ readonly stderr: WritableLike;
+ readonly exit: (code: number) => never;
+}
+
+export interface ExportOptions {
+ readonly yes: boolean;
+ readonly includeGlobalLog: boolean;
+}
+
+export async function handleExport(
+ deps: ExportDeps,
+ sessionId: string | undefined,
+ output: string | undefined,
+ opts: ExportOptions,
+): Promise {
+ const requestedId = normalizeOptionalSessionId(sessionId);
+ const previousSummary = requestedId === undefined ? await findPreviousSession(deps) : undefined;
+
+ let resolvedId: string;
+ if (requestedId !== undefined) {
+ resolvedId = requestedId;
+ } else {
+ if (previousSummary === undefined) {
+ deps.stderr.write('No previous session found to export.\n');
+ deps.exit(1);
+ }
+ resolvedId = previousSummary.id;
+ if (!opts.yes) {
+ const confirmed = await deps.confirmPreviousSession(toPreviousSessionSummary(previousSummary));
+ if (!confirmed) {
+ deps.stdout.write('Export cancelled.\n');
+ return;
+ }
+ }
+ }
+
+ try {
+ const installSource = await deps.getInstallSource();
+ const shellEnv = deps.getShellEnv();
+ const result = await deps.exportSession({
+ id: resolvedId,
+ version: deps.version,
+ installSource,
+ shellEnv,
+ ...(output === undefined ? {} : { outputPath: output }),
+ ...(opts.includeGlobalLog ? { includeGlobalLog: true } : {}),
+ });
+ deps.stdout.write(`${result.zipPath}\n`);
+ } catch (error) {
+ deps.stderr.write(`${errorMessage(error)}\n`);
+ deps.exit(1);
+ }
+}
+
+export function registerExportCommand(parent: Command, deps?: Partial): void {
+ parent
+ .command('export')
+ .description('Export a session as a ZIP archive.')
+ .option('-o, --output ', 'Output ZIP path.')
+ .option('-y, --yes', 'Skip previous-session confirmation.')
+ .option(
+ '--no-include-global-log',
+ 'Skip bundling the active global diagnostic log (~/.kimi-code/logs/kimi-code.log, not rotated .1 files). By default the global log is included.',
+ )
+ .argument('[sessionId]', 'Session id to export. Defaults to the most recent session.')
+ .action(
+ async (
+ sessionId: string | undefined,
+ options: { output?: string; yes?: boolean; includeGlobalLog?: boolean },
+ ) => {
+ const resolved = createDefaultExportDeps(deps);
+ try {
+ await handleExport(resolved, sessionId, options.output, {
+ yes: options.yes === true,
+ includeGlobalLog: options.includeGlobalLog !== false,
+ });
+ } finally {
+ await resolved.close();
+ }
+ },
+ );
+}
+
+function createDefaultExportDeps(overrides: Partial = {}): ExportDeps & {
+ readonly close: () => Promise;
+} {
+ let harness: KimiHarness | undefined;
+ let telemetryBootstrap: ReturnType | undefined;
+ let telemetryInitialized = false;
+ let telemetryShutdown = false;
+ const identity = createKimiCodeHostIdentity();
+ const telemetryClient: TelemetryClient = {
+ track,
+ withContext: withTelemetryContext,
+ setContext: setTelemetryContext,
+ };
+ const getTelemetryBootstrap = (): ReturnType => {
+ telemetryBootstrap ??= createCliTelemetryBootstrap();
+ return telemetryBootstrap;
+ };
+ const getHarness = (): KimiHarness => {
+ const currentTelemetryBootstrap = getTelemetryBootstrap();
+ harness ??= createKimiHarness({
+ homeDir: currentTelemetryBootstrap.homeDir,
+ identity,
+ telemetry: telemetryClient,
+ });
+ return harness;
+ };
+ const initializeDefaultTelemetry = async (): Promise => {
+ if (telemetryInitialized) return;
+ const currentTelemetryBootstrap = getTelemetryBootstrap();
+ const currentHarness = getHarness();
+ await currentHarness.ensureConfigFile();
+ const config = await currentHarness.getConfig();
+ initializeCliTelemetry({
+ harness: currentHarness,
+ bootstrap: currentTelemetryBootstrap,
+ config,
+ version: identity.version,
+ uiMode: CLI_UI_MODE,
+ });
+ telemetryInitialized = true;
+ };
+ const shutdownDefaultTelemetry = async (): Promise => {
+ if (!telemetryInitialized || telemetryShutdown) return;
+ telemetryShutdown = true;
+ await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
+ };
+ return {
+ listSessions:
+ overrides.listSessions ??
+ ((workDir: string) =>
+ getHarness().listSessions({
+ workDir,
+ })),
+ exportSession:
+ overrides.exportSession ??
+ (async (input: ExportSessionInput) => {
+ await initializeDefaultTelemetry();
+ try {
+ return await getHarness().exportSession(input);
+ } finally {
+ await shutdownDefaultTelemetry();
+ }
+ }),
+ version: overrides.version ?? identity.version,
+ getInstallSource: overrides.getInstallSource ?? (() => detectInstallSource()),
+ getShellEnv: overrides.getShellEnv ?? detectShellEnvironment,
+ confirmPreviousSession: overrides.confirmPreviousSession ?? confirmPreviousSession,
+ cwd: overrides.cwd ?? (() => process.cwd()),
+ stdout: overrides.stdout ?? process.stdout,
+ stderr: overrides.stderr ?? process.stderr,
+ exit: overrides.exit ?? ((code: number) => process.exit(code)),
+ // The v2 harness boots an engine whose watchers hold the event loop open;
+ // close it so a one-shot command can exit. No-op when the run never needed
+ // the harness.
+ close: async () => {
+ await harness?.close();
+ },
+ };
+}
+
+async function findPreviousSession(deps: Pick): Promise<
+ SessionSummary | undefined
+> {
+ const sessions = await deps.listSessions(deps.cwd());
+ return sessions[0];
+}
+
+function toPreviousSessionSummary(summary: SessionSummary): PreviousSessionSummary {
+ return {
+ workDir: summary.workDir,
+ sessionId: summary.id,
+ sessionDir: summary.sessionDir,
+ ...(summary.title === undefined ? {} : { title: summary.title }),
+ };
+}
+
+function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined {
+ const trimmed = sessionId?.trim();
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed;
+}
+
+async function confirmPreviousSession(summary: PreviousSessionSummary): Promise {
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
+ try {
+ const title = summary.title === undefined ? summary.sessionId : `${summary.title} (${summary.sessionId})`;
+ const answer = await rl.question(`Export previous session "${title}"? [Y/n] `);
+ const trimmed = answer.trim().toLowerCase();
+ return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
+ } finally {
+ rl.close();
+ }
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/apps/kimi-code/src/cli/sub/login.ts b/apps/kimi-code/src/cli/sub/login.ts
new file mode 100644
index 0000000000000000000000000000000000000000..78510c9953ec6476e8b6ac5af603fd37eb2c373e
--- /dev/null
+++ b/apps/kimi-code/src/cli/sub/login.ts
@@ -0,0 +1,26 @@
+/**
+ * `kimi login` — drive the OAuth device-code flow non-interactively.
+ * The `authMethods.terminal-auth.args=['login']` (legacy `_meta` path)
+ * advertised by the ACP server points clients at this entry point. The
+ * first-class ACP `args=['--login']` path enters the same flow via
+ * `kimi acp --login`.
+ */
+
+import type { Command } from 'commander';
+
+import { parseRegionFlag, runLoginFlow } from './login-flow';
+
+export function registerLoginCommand(parent: Command): void {
+ parent
+ .command('login')
+ .description('Authenticate with Kimi Code CLI via the device-code flow.')
+ .option(
+ '--region ',
+ 'Login region: "mainland-cn" (kimi.com) or "global" (kimi.ai).',
+ )
+ .action(async (opts: { region?: string }) => {
+ await runLoginFlow({
+ region: opts.region === undefined ? undefined : parseRegionFlag(opts.region),
+ });
+ });
+}
diff --git a/apps/kimi-code/src/cli/sub/plugin-run-node.ts b/apps/kimi-code/src/cli/sub/plugin-run-node.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3926a04dc1deecfaeb513d48937f1e69e60b102a
--- /dev/null
+++ b/apps/kimi-code/src/cli/sub/plugin-run-node.ts
@@ -0,0 +1,26 @@
+import { realpath } from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+export async function runPluginNodeEntry(entry: string, args: readonly string[]): Promise {
+ const pluginRoot = process.env['KIMI_PLUGIN_ROOT'];
+ if (pluginRoot === undefined || pluginRoot.trim().length === 0) {
+ throw new Error('KIMI_PLUGIN_ROOT is required to run a plugin node entry.');
+ }
+
+ const [rootReal, entryReal] = await Promise.all([
+ realpath(pluginRoot),
+ realpath(entry),
+ ]);
+ if (!isWithin(entryReal, rootReal)) {
+ throw new Error(`Plugin node entry must be inside KIMI_PLUGIN_ROOT: ${entry}`);
+ }
+
+ process.argv = [process.argv[0] ?? process.execPath, entryReal, ...args];
+ await import(pathToFileURL(entryReal).href);
+}
+
+function isWithin(candidate: string, root: string): boolean {
+ const relative = path.relative(root, candidate);
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
+}
diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts
new file mode 100644
index 0000000000000000000000000000000000000000..efc582ecb96fc6f06f64aefebea3876c65d9544d
--- /dev/null
+++ b/apps/kimi-code/src/cli/sub/update-download.ts
@@ -0,0 +1,185 @@
+/**
+ * Hidden `kimi __update_download ` sub-command: the self-spawned
+ * worker behind native staged updates. Preflight spawns it detached in the
+ * background (and the `upgrade` command in the foreground); it downloads,
+ * verifies and stages the binary next to the running exe. The swap into
+ * place happens on the next startup (see `cli/update/native-swap.ts`).
+ */
+
+import { log } from '@moonshot-ai/kimi-code-sdk';
+
+import {
+ readUpdateInstallLockVersion,
+ tryAcquireUpdateInstallLock,
+ type UpdateInstallLockHandle,
+} from '#/cli/update/install-lock';
+import {
+ hashFileSha256,
+ promoteStagedUpdateToManual,
+ readStagedNativeUpdate,
+ stagedExePath,
+ stageNativeUpdate,
+} from '#/cli/update/native-stage';
+import { detectNativeInstall } from '#/cli/update/source';
+
+const LOCK_HELD_POLL_INTERVAL_MS = 2_000;
+
+type StagedUpdateWait =
+ | { readonly status: 'staged' }
+ | { readonly status: 'takeover'; readonly lock: UpdateInstallLockHandle | null };
+
+/**
+ * Another worker holds the install lock for the SAME version. Returning right
+ * away would report a success that has not happened yet — the in-flight
+ * download may still fail — so wait for it: 'staged' once its staged update is
+ * verified on disk; 'takeover' once the lock becomes acquirable, with the lock
+ * already held for the caller. The lock goes stale the moment its holder dies
+ * (see install-lock), so a killed downloader cannot strand a foreground
+ * `kimi upgrade` in this loop.
+ *
+ * Adoption applies the same integrity bar as stageNativeUpdate's
+ * already-staged path: the recorded size proves nothing, and the holder may
+ * still be RE-STAGING a same-size-corrupted payload (its metadata is only
+ * replaced when the new generation publishes). A recorded stage whose payload
+ * fails the checksum is treated as not-yet-staged — the lock poll below takes
+ * over once the holder finishes without repairing it.
+ *
+ * A manual (explicit-upgrade) waiter adopts only after CONFIRMING the manual
+ * marker landed on the stage — a concurrent startup swap may be claiming and
+ * restoring the metadata right now, and reporting adoption for a promotion
+ * that never persisted would strand the update under the env opt-out.
+ */
+async function waitForStagedUpdate(
+ version: string,
+ exePath: string,
+ manual: boolean,
+): Promise {
+ for (;;) {
+ const staged = await readStagedNativeUpdate(exePath);
+ const digest =
+ staged !== null && staged.version === version
+ ? await hashFileSha256(stagedExePath(exePath, staged))
+ : null;
+ if (staged !== null && digest === staged.sha256) {
+ if (!manual || (await promoteStagedUpdateToManual(exePath, staged))) {
+ return { status: 'staged' };
+ }
+ // The stage is being claimed/restored by a concurrent swap — the next
+ // poll either promotes the restored stage or takes over once it is
+ // gone.
+ } else {
+ // Poll the acquisition itself: while the holder lives its lock stays
+ // fresh and this returns null without side effects; when the holder
+ // finishes (or dies) without staging a VERIFIED payload, the takeover
+ // happens right here.
+ const lock = await tryAcquireUpdateInstallLock({ version });
+ if (lock !== null) return { status: 'takeover', lock };
+ }
+ await new Promise((resolve) => {
+ setTimeout(resolve, LOCK_HELD_POLL_INTERVAL_MS);
+ });
+ }
+}
+
+export async function runUpdateDownloadCommand(
+ version: string,
+ manual: boolean = false,
+): Promise {
+ if (!detectNativeInstall()) {
+ process.stderr.write('error: update download is only available in the native build\n');
+ return 1;
+ }
+ const out = process.stdout;
+ let lock = await tryAcquireUpdateInstallLock({ version });
+ if (lock === null) {
+ const holderVersion = await readUpdateInstallLockVersion();
+ if (holderVersion === version) {
+ // Another worker is already downloading this exact version: wait for it
+ // and adopt its verified result instead of exiting on a maybe.
+ out.write(
+ `A download of Kimi Code ${version} is already in progress; waiting for it to finish…\n`,
+ );
+ const wait = await waitForStagedUpdate(version, process.execPath, manual);
+ if (wait.status === 'staged') {
+ out.write(`Kimi Code ${version} is downloaded; it applies on the next start.\n`);
+ return 0;
+ }
+ // The holder finished without staging (failed or died): take over. The
+ // lock may already be held by another winner of the takeover race —
+ // the null check below reports that as held.
+ lock = wait.lock;
+ } else if (holderVersion === undefined) {
+ // The lock was released between the two reads — retry the acquire once.
+ lock = await tryAcquireUpdateInstallLock({ version });
+ }
+ if (lock === null) {
+ process.stderr.write(
+ `error: another update (${holderVersion ?? 'unknown version'}) is already downloading\n`,
+ );
+ return 1;
+ }
+ }
+ const label = `Downloading Kimi Code ${version} (${process.platform}-${process.arch})…`;
+ const onProgress = createDownloadProgress(out, label);
+ try {
+ const result = await stageNativeUpdate({
+ version,
+ exePath: process.execPath,
+ onProgress,
+ manual,
+ });
+ if (out.isTTY) out.write('\n');
+ if (result.status === 'already-staged') {
+ out.write(`Kimi Code ${version} is already downloaded; it applies on the next start.\n`);
+ }
+ return 0;
+ } catch (error) {
+ if (out.isTTY) out.write('\n');
+ const message = error instanceof Error ? error.message : String(error);
+ process.stderr.write(`error: failed to download update ${version}: ${message}\n`);
+ log.warn('native update download failed', { version, error: message });
+ return 1;
+ } finally {
+ await lock.release().catch(() => {});
+ }
+}
+
+const PROGRESS_FRAME_INTERVAL_MS = 100;
+const PROGRESS_LINE_INTERVAL_BYTES = 32 * 1024 * 1024;
+
+function formatDownloadProgress(label: string, downloaded: number, total: number | null): string {
+ const mb = Math.floor(downloaded / (1024 * 1024));
+ if (total === null || total <= 0) return `${label} ${mb} MB`;
+ const totalMb = Math.max(1, Math.round(total / (1024 * 1024)));
+ const percent = Math.min(100, Math.floor((downloaded / total) * 100));
+ return `${label} ${percent}% (${mb}/${totalMb} MB)`;
+}
+
+/**
+ * Download progress renderer for the (foreground) downloader: a single
+ * in-place line on a TTY (`\r` + clear-line, throttled to 10 fps, final frame
+ * always rendered), or one line per 32 MB when piped to a file. The caller
+ * owns the trailing newline.
+ */
+export function createDownloadProgress(
+ out: NodeJS.WriteStream,
+ label: string,
+): (downloadedBytes: number, totalBytes: number | null) => void {
+ const isTTY = out.isTTY;
+ let lastFrameAt = 0;
+ let lastLineAt = 0;
+ if (!isTTY) out.write(`${label}\n`);
+ return (downloaded, total) => {
+ const done = total !== null && downloaded >= total;
+ if (isTTY) {
+ const now = Date.now();
+ if (!done && now - lastFrameAt < PROGRESS_FRAME_INTERVAL_MS) return;
+ lastFrameAt = now;
+ out.write(`\r\u001B[K${formatDownloadProgress(label, downloaded, total)}`);
+ return;
+ }
+ if (!done && downloaded - lastLineAt < PROGRESS_LINE_INTERVAL_BYTES) return;
+ lastLineAt = downloaded;
+ out.write(`${formatDownloadProgress(label, downloaded, total)}\n`);
+ };
+}
diff --git a/apps/kimi-code/src/cli/sub/vis.ts b/apps/kimi-code/src/cli/sub/vis.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7e6f2e1f2594d0382d2a886e3f51133b3c6e6a20
--- /dev/null
+++ b/apps/kimi-code/src/cli/sub/vis.ts
@@ -0,0 +1,158 @@
+/**
+ * `kimi vis` sub-command.
+ *
+ * CLI glue only: resolves the kimi home, starts the in-process session
+ * visualizer server (auto-picking a free port by default), prints the URL,
+ * optionally opens the browser (with an optional session deep-link), then
+ * waits for Ctrl-C and shuts the server down. The visualizer server itself
+ * lives in `@moonshot-ai/vis-server`.
+ */
+
+import type { Command } from 'commander';
+
+import { createCliTelemetryBootstrap } from '#/cli/telemetry';
+import { openUrl } from '#/utils/open-url';
+
+interface WritableLike {
+ write(chunk: string): boolean;
+}
+
+export interface StartedVisServer {
+ readonly port: number;
+ readonly host: string;
+ readonly url: string;
+ readonly close: () => Promise;
+}
+
+export interface StartVisServerArgs {
+ readonly homeDir: string;
+ readonly port: number;
+ readonly host?: string;
+ readonly webAsset?: { gzipped: Uint8Array };
+}
+
+export interface VisDeps {
+ readonly getHomeDir: () => string;
+ readonly startVisServer: (opts: StartVisServerArgs) => Promise;
+ readonly openUrl: (url: string) => Promise;
+ readonly waitForShutdown: () => Promise;
+ readonly stdout: WritableLike;
+ readonly stderr: WritableLike;
+ readonly exit: (code: number) => never;
+}
+
+export interface VisOptions {
+ readonly open: boolean;
+ readonly port?: number;
+ readonly host?: string;
+ readonly sessionId?: string;
+}
+
+export async function handleVis(deps: VisDeps, opts: VisOptions): Promise {
+ const homeDir = deps.getHomeDir();
+
+ // Lazily load the embedded single-file SPA so normal `kimi` startup never
+ // pays for it. The module is generated at build time (prebuild). When running
+ // from source without a build — e.g. tests — the generated value module is
+ // absent and the dynamic import throws; in that case the server falls back to
+ // its own static `public/` directory.
+ let webAsset: { gzipped: Uint8Array } | undefined;
+ try {
+ const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset');
+ if (VIS_WEB_GZIP_B64.length > 0) {
+ webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) };
+ }
+ } catch {
+ // Embedded asset not generated in this context — fall back to filesystem.
+ }
+
+ let server: StartedVisServer;
+ try {
+ server = await deps.startVisServer({
+ homeDir,
+ port: opts.port ?? 0,
+ ...(opts.host === undefined ? {} : { host: opts.host }),
+ ...(webAsset === undefined ? {} : { webAsset }),
+ });
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ deps.stderr.write(`Failed to start kimi vis: ${msg}\n`);
+ return deps.exit(1);
+ }
+
+ const target =
+ opts.sessionId === undefined
+ ? server.url
+ : `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;
+
+ deps.stdout.write(`kimi vis is running at ${server.url}\n`);
+ deps.stdout.write('Press Ctrl-C to stop.\n');
+
+ if (opts.open) {
+ try {
+ await deps.openUrl(target);
+ } catch {
+ deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`);
+ }
+ }
+
+ await deps.waitForShutdown();
+ await server.close();
+}
+
+export function registerVisCommand(parent: Command, overrides?: Partial): void {
+ parent
+ .command('vis')
+ .description('Launch the session visualizer in your browser.')
+ .option('--port ', 'Port to bind. Default: auto-pick a free port.')
+ .option('--host ', 'Host to bind. Default: 127.0.0.1.')
+ .option('--no-open', 'Do not open the browser automatically.')
+ .argument('[sessionId]', 'Open directly to this session.')
+ .action(
+ async (
+ sessionId: string | undefined,
+ options: { port?: string; host?: string; open?: boolean },
+ ) => {
+ const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10);
+ await handleVis(createDefaultVisDeps(overrides), {
+ open: options.open !== false,
+ ...(port === undefined || Number.isNaN(port) ? {} : { port }),
+ ...(options.host === undefined ? {} : { host: options.host }),
+ ...(sessionId === undefined ? {} : { sessionId }),
+ });
+ },
+ );
+}
+
+function createDefaultVisDeps(overrides: Partial = {}): VisDeps {
+ return {
+ getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir),
+ startVisServer:
+ overrides.startVisServer ??
+ (async (opts) => {
+ // Dynamic import keeps the vis server (and Hono) out of the hot path.
+ const { startVisServer } = await import('@moonshot-ai/vis-server/start');
+ return startVisServer(opts);
+ }),
+ // `openUrl` is a synchronous fire-and-forget; adapt it to the async dep.
+ openUrl:
+ overrides.openUrl ??
+ (async (url: string) => {
+ openUrl(url);
+ }),
+ waitForShutdown: overrides.waitForShutdown ?? waitForSigint,
+ stdout: overrides.stdout ?? process.stdout,
+ stderr: overrides.stderr ?? process.stderr,
+ exit: overrides.exit ?? ((code: number) => process.exit(code)),
+ };
+}
+
+function waitForSigint(): Promise {
+ return new Promise((resolve) => {
+ const onSig = (): void => {
+ process.off('SIGINT', onSig);
+ resolve();
+ };
+ process.on('SIGINT', onSig);
+ });
+}
diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts
new file mode 100644
index 0000000000000000000000000000000000000000..210a257de78f0d44fa5acc3ddb63d369b5303fb5
--- /dev/null
+++ b/apps/kimi-code/src/cli/telemetry.ts
@@ -0,0 +1,131 @@
+import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
+import {
+ KimiAuthFacade,
+ loadRuntimeConfigSafe,
+ log,
+ resolveConfigPath,
+ resolveKimiHome,
+ type KimiConfig,
+ type TelemetryClient,
+} from '@moonshot-ai/kimi-code-sdk';
+
+import type { PromptHarness } from './prompt-session';
+import {
+ initializeTelemetry,
+ setTelemetryContext,
+ track,
+ withTelemetryContext,
+} from '@moonshot-ai/kimi-telemetry';
+
+import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app';
+import { currentKimiProfile } from '#/utils/region';
+
+import { createKimiCodeHostIdentity } from './version';
+
+export interface CliTelemetryBootstrap {
+ readonly homeDir: string;
+ readonly deviceId: string;
+ readonly firstLaunch: boolean;
+}
+
+export interface InitializeCliTelemetryOptions {
+ readonly harness: PromptHarness;
+ readonly bootstrap: CliTelemetryBootstrap;
+ readonly config: Pick;
+ readonly version: string;
+ readonly uiMode: string;
+ readonly model?: string;
+ readonly sessionId?: string;
+}
+
+export function createCliTelemetryBootstrap(): CliTelemetryBootstrap {
+ let firstLaunch = false;
+ const homeDir = resolveKimiHome();
+ const deviceId = createKimiDeviceId(homeDir, {
+ onFirstLaunch: () => {
+ firstLaunch = true;
+ },
+ });
+ return { homeDir, deviceId, firstLaunch };
+}
+
+export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): void {
+ initializeTelemetry({
+ homeDir: options.harness.homeDir,
+ deviceId: options.bootstrap.deviceId,
+ enabled: options.config.telemetry !== false,
+ appName: CLI_USER_AGENT_PRODUCT,
+ version: options.version,
+ uiMode: options.uiMode,
+ model: options.model ?? options.config.defaultModel,
+ sessionId: options.sessionId,
+ endpoint: () => currentKimiProfile().telemetryEndpoint,
+ getAccessToken: async () =>
+ (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
+ onUnexpectedError: (error) => log.warn('telemetry property dropped', { error: String(error) }),
+ });
+ if (options.bootstrap.firstLaunch) {
+ options.harness.track('first_launch');
+ }
+}
+
+export interface InitializeServerTelemetryOptions {
+ readonly version: string;
+}
+
+/**
+ * Bootstrap telemetry for the `kimi web` host.
+ *
+ * Mirrors {@link initializeCliTelemetry}: mints the device id, reads config to
+ * honor the `telemetry` toggle and pick up the default model, and attaches the
+ * sink with `ui_mode = "web"`.
+ *
+ * The returned client wraps the `@moonshot-ai/kimi-telemetry` module
+ * functions, so the module-level `track` / `withTelemetryContext` (used to
+ * fire the startup event) share the same underlying client + sink.
+ */
+export function initializeServerTelemetry(
+ options: InitializeServerTelemetryOptions,
+): TelemetryClient {
+ const bootstrap = createCliTelemetryBootstrap();
+ const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir });
+ const config = readServerTelemetryConfig(configPath);
+ const auth = new KimiAuthFacade({
+ homeDir: bootstrap.homeDir,
+ configPath,
+ identity: createKimiCodeHostIdentity(options.version),
+ });
+
+ initializeTelemetry({
+ homeDir: bootstrap.homeDir,
+ deviceId: bootstrap.deviceId,
+ enabled: config.telemetry !== false,
+ appName: CLI_USER_AGENT_PRODUCT,
+ version: options.version,
+ uiMode: WEB_UI_MODE,
+ model: config.defaultModel,
+ endpoint: () => currentKimiProfile().telemetryEndpoint,
+ getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
+ onUnexpectedError: (error) => log.warn('telemetry property dropped', { error: String(error) }),
+ });
+
+ return {
+ track,
+ withContext: withTelemetryContext,
+ setContext: setTelemetryContext,
+ };
+}
+
+function readServerTelemetryConfig(
+ configPath: string,
+): Pick {
+ try {
+ const { config, fileError } = loadRuntimeConfigSafe(configPath);
+ // A broken config fails the server on its own inside KimiCore; for
+ // telemetry just degrade to "enabled, no model" so we never block startup.
+ if (fileError !== undefined) return {};
+ return config;
+ } catch {
+ return {};
+ }
+}
diff --git a/apps/kimi-code/src/cli/update/cache.ts b/apps/kimi-code/src/cli/update/cache.ts
new file mode 100644
index 0000000000000000000000000000000000000000..63885e2bb5a518f2d0c88fe662c6592d0eba3dd9
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/cache.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod';
+
+import { getUpdateStateFile } from '#/utils/paths';
+import { readJsonFile, writeJsonFile } from '#/utils/persistence';
+
+import { UpdateManifestSchema } from './cdn';
+import { emptyUpdateCache, type UpdateCache } from './types';
+
+// Stays `.strict()` (we own this file), but a malformed manifest is treated
+// as no manifest so one bad optional field does not discard the whole cache.
+const UpdateCacheSchema = z
+ .object({
+ source: z.literal('cdn'),
+ checkedAt: z.string().min(1).nullable(),
+ latest: z.string().min(1).nullable(),
+ manifest: z.preprocess((value) => {
+ const parsed = UpdateManifestSchema.nullable().safeParse(value === undefined ? null : value);
+ return parsed.success ? parsed.data : null;
+ }, z.union([UpdateManifestSchema, z.null()])),
+ })
+ .strict();
+
+export async function readUpdateCache(
+ filePath: string = getUpdateStateFile(),
+): Promise {
+ try {
+ return await readJsonFile(filePath, UpdateCacheSchema, emptyUpdateCache());
+ } catch {
+ return emptyUpdateCache();
+ }
+}
+
+export async function writeUpdateCache(
+ value: UpdateCache,
+ filePath: string = getUpdateStateFile(),
+): Promise {
+ await writeJsonFile(filePath, UpdateCacheSchema, value);
+}
diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0aca3f33997e1df444a4fcc9893a5df626a102f1
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/cdn.ts
@@ -0,0 +1,106 @@
+import { valid } from 'semver';
+import { z } from 'zod';
+
+import { kimiCodeCdnLatestJsonUrl, kimiCodeCdnLatestUrl } from '#/constant/app';
+
+import type { UpdateManifest } from './types';
+
+const CDN_FETCH_TIMEOUT_MS = 3_000;
+
+const RolloutBatchSchema = z.object({
+ percent: z.number().int().min(0).max(100),
+ delaySeconds: z.number().int().min(0),
+});
+
+/**
+ * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown
+ * fields are ignored so future manifest additions never break shipped
+ * clients (the plain-text `/latest` taught us that hard-failing on
+ * unexpected content bricks the update path forever).
+ */
+export const UpdateManifestSchema = z.object({
+ version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
+ publishedAt: z
+ .string()
+ .refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }),
+ rollout: z.array(RolloutBatchSchema).readonly().default([]),
+});
+
+export interface FetchLatestResult {
+ /** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */
+ readonly latest: string;
+ /** Null when the JSON manifest was unavailable and we fell back to plain text. */
+ readonly manifest: UpdateManifest | null;
+}
+
+async function fetchWithTimeout(
+ fetchImpl: typeof fetch,
+ input: string,
+ timeoutMs: number,
+): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => {
+ controller.abort();
+ }, timeoutMs);
+ try {
+ return await fetchImpl(input, { signal: controller.signal });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+/**
+ * Fetch the latest published Kimi Code version from the CDN.
+ *
+ * **Throws** on any failure (network error, non-2xx, empty body, non-semver
+ * text). Callers must catch — `refreshUpdateCache` deliberately lets the
+ * error propagate so the existing cache stays intact instead of being
+ * overwritten with a null `latest` on a transient blip.
+ *
+ * `fetchImpl` is injectable for tests; defaults to the global `fetch`.
+ */
+export async function fetchLatestVersionFromCdn(
+ fetchImpl: typeof fetch = fetch,
+ timeoutMs: number = CDN_FETCH_TIMEOUT_MS,
+): Promise {
+ const response = await fetchWithTimeout(fetchImpl, kimiCodeCdnLatestUrl(), timeoutMs);
+ if (!response.ok) {
+ throw new Error(`CDN /latest returned HTTP ${response.status}`);
+ }
+ const raw = (await response.text()).trim();
+ if (valid(raw) === null) {
+ throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`);
+ }
+ return raw;
+}
+
+async function fetchUpdateManifestFromCdn(
+ fetchImpl: typeof fetch,
+ timeoutMs: number,
+): Promise {
+ const response = await fetchWithTimeout(fetchImpl, kimiCodeCdnLatestJsonUrl(), timeoutMs);
+ if (!response.ok) {
+ throw new Error(`CDN /latest.json returned HTTP ${response.status}`);
+ }
+ return UpdateManifestSchema.parse(JSON.parse(await response.text()));
+}
+
+/**
+ * Fetch the rollout manifest, falling back to the plain-text `/latest` when
+ * `latest.json` is unavailable or malformed. The fallback removes any
+ * deployment-order coupling between client releases and the CDN file, and a
+ * null manifest means "fully rolled out" — exactly the pre-rollout behavior.
+ *
+ * **Throws** only when both sources fail; callers must catch (see above).
+ */
+export async function fetchLatestFromCdn(
+ fetchImpl: typeof fetch = fetch,
+ timeoutMs: number = CDN_FETCH_TIMEOUT_MS,
+): Promise {
+ const manifest = await fetchUpdateManifestFromCdn(fetchImpl, timeoutMs).catch(() => null);
+ if (manifest !== null) {
+ return { latest: manifest.version, manifest };
+ }
+ const latest = await fetchLatestVersionFromCdn(fetchImpl, timeoutMs);
+ return { latest, manifest: null };
+}
diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f42042ac3a4da59a44a60394cee78306fa0bc806
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/install-lock.ts
@@ -0,0 +1,254 @@
+import { mkdir, readFile, stat, unlink } from 'node:fs/promises';
+import { dirname } from 'node:path';
+
+import { getUpdateInstallLockFile } from '#/utils/paths';
+import { createFileIfAbsent } from '#/utils/persistence';
+
+const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000;
+
+/**
+ * A takeover's critical section is a few syscalls (microseconds), so a
+ * takeover lock older than this is crash residue and may be swept freely.
+ */
+const TAKEOVER_LOCK_STALE_MS = 60_000;
+
+/**
+ * On filesystems without hard links the lock is published by an exclusive
+ * create + write (see createFileIfAbsent), which IS observable between create
+ * and write. A young unparseable lock is almost always that publish window,
+ * not corruption — only an unparseable lock older than this is swept as
+ * crash residue.
+ */
+const LOCK_PUBLISH_GRACE_MS = 60_000;
+
+export interface UpdateInstallLockRequest {
+ readonly version: string;
+ readonly now?: Date;
+}
+
+export interface UpdateInstallLockHandle {
+ readonly filePath: string;
+ /** The exact contents this handle published — its ownership identity. */
+ readonly content: string;
+ release(): Promise;
+}
+
+function isNotFound(error: unknown): boolean {
+ return (
+ typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT'
+ );
+}
+
+function isAlreadyExists(error: unknown): boolean {
+ return (
+ typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST'
+ );
+}
+
+/**
+ * Liveness probe for the lock holder. Signal 0 delivers nothing; ESRCH means
+ * the process is gone, EPERM means it exists but may not be signalled — which
+ * still counts as alive.
+ */
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
+ }
+}
+
+interface LockInspection {
+ readonly content: string;
+ readonly mtimeMs: number;
+}
+
+/** Read the lock file's content and mtime; null when it is gone/unreadable. */
+async function inspectLockFile(filePath: string): Promise {
+ const content = await readFile(filePath, 'utf-8').catch(() => null);
+ if (content === null) return null;
+ const info = await stat(filePath).catch(() => null);
+ if (info === null) return null;
+ return { content, mtimeMs: info.mtimeMs };
+}
+
+/**
+ * Staleness check over the lock file's CONTENTS. Shapeless content counts as
+ * stale (crash residue). Unparseable content is also crash residue — but only
+ * once it is older than the publish grace: on filesystems without hard links
+ * a fallback publish is observable mid-write (see LOCK_PUBLISH_GRACE_MS), and
+ * sweeping that window would break exclusivity. A holder that is gone can
+ * never release its lock (a killed process skips its finally) nor make
+ * progress — stale at ANY age; the atomic publish guarantees the pid was
+ * written complete by a then-live process, so a dead pid means the holder
+ * died afterwards. Past the age threshold a LIVE holder still survives: a
+ * native download is idle-bounded but intentionally not duration-bounded, so
+ * a slow link legitimately exceeds it. (A pid reused by an unrelated process
+ * can pin the lock until that process exits — a delayed update, never a
+ * corrupt one.)
+ */
+function isStaleLock(inspection: LockInspection, now: Date): boolean {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(inspection.content);
+ } catch {
+ return now.getTime() - inspection.mtimeMs > LOCK_PUBLISH_GRACE_MS;
+ }
+ if (typeof parsed !== 'object' || parsed === null) return true;
+ const lock = parsed as { readonly startedAt?: unknown; readonly pid?: unknown };
+ if (typeof lock.startedAt !== 'string') return true;
+ const startedAt = Date.parse(lock.startedAt);
+ if (!Number.isFinite(startedAt)) return true;
+ if (typeof lock.pid === 'number' && !isProcessAlive(lock.pid)) return true;
+ if (now.getTime() - startedAt <= UPDATE_INSTALL_LOCK_STALE_MS) return false;
+ return typeof lock.pid !== 'number';
+}
+
+async function createLockFile(
+ filePath: string,
+ request: UpdateInstallLockRequest,
+): Promise {
+ const now = request.now ?? new Date();
+ const content = `${JSON.stringify({
+ version: request.version,
+ pid: process.pid,
+ startedAt: now.toISOString(),
+ }, null, 2)}\n`;
+ // Publish atomically and only into a still-free path (EEXIST propagates to
+ // the caller's inspection flow). The lock file is never observable empty
+ // on filesystems with hard links; elsewhere the exclusive-create fallback
+ // leaves a brief publish window, which the inspection side covers with
+ // LOCK_PUBLISH_GRACE_MS.
+ await createFileIfAbsent(filePath, content);
+ // A racing stale-takeover may have removed our just-published lock and
+ // published its own; only the survivor may proceed.
+ const published = await readFile(filePath, 'utf-8').catch(() => null);
+ if (published !== content) return null;
+
+ return {
+ filePath,
+ content,
+ release: async (): Promise => {
+ // Release only the lock instance we own: a stale takeover may have
+ // replaced the file since we published it.
+ const current = await readFile(filePath, 'utf-8').catch(() => null);
+ if (current !== content) return;
+ await unlink(filePath).catch((error: unknown) => {
+ if (!isNotFound(error)) throw error;
+ });
+ },
+ };
+}
+
+export async function tryAcquireUpdateInstallLock(
+ request: UpdateInstallLockRequest,
+ filePath: string = getUpdateInstallLockFile(),
+): Promise {
+ await mkdir(dirname(filePath), { recursive: true });
+ try {
+ return await createLockFile(filePath, request);
+ } catch (error) {
+ if (!isAlreadyExists(error)) throw error;
+ }
+
+ // A lock file exists. Inspect it once to decide whether it is stale.
+ const inspected = await inspectLockFile(filePath);
+ if (inspected !== null && !isStaleLock(inspected, request.now ?? new Date())) {
+ return null;
+ }
+ if (inspected === null) {
+ // Vanished between create and read — retry the create once.
+ try {
+ return await createLockFile(filePath, request);
+ } catch (error) {
+ if (isAlreadyExists(error)) return null;
+ throw error;
+ }
+ }
+
+ // Stale lock. A pathname-level delete can never be conditioned on the file
+ // still being the inspected instance, so delete+publish MUST NOT run
+ // concurrently: serialize takeovers through a secondary create-if-absent
+ // lock and re-validate staleness inside that section.
+ const takeoverPath = `${filePath}.takeover`;
+ if (!(await acquireTakeoverLock(takeoverPath))) return null;
+ try {
+ const current = await inspectLockFile(filePath);
+ if (current !== null && !isStaleLock(current, request.now ?? new Date())) {
+ // A fresh lock appeared while we waited for the takeover section.
+ return null;
+ }
+ if (current !== null) {
+ await unlink(filePath).catch(() => {});
+ }
+ try {
+ // A fast-path creator may still win the briefly-free path — its lock is
+ // legitimate (the path really was free), we simply lose.
+ return await createLockFile(filePath, request);
+ } catch (error) {
+ if (isAlreadyExists(error)) return null;
+ throw error;
+ }
+ } finally {
+ await unlink(takeoverPath).catch(() => {});
+ }
+}
+
+/**
+ * The takeover lock serializes stale-lock recovery. create-if-absent via the
+ * shared primitive (hard link, or an exclusive create where unsupported); an
+ * ancient holder is crash residue (a live section lasts microseconds) and is
+ * swept, then retried once.
+ */
+async function acquireTakeoverLock(takeoverPath: string): Promise {
+ if (await publishTakeoverMarker(takeoverPath)) return true;
+ const info = await stat(takeoverPath).catch(() => null);
+ if (info !== null && Date.now() - info.mtimeMs <= TAKEOVER_LOCK_STALE_MS) return false;
+ await unlink(takeoverPath).catch(() => {});
+ return publishTakeoverMarker(takeoverPath);
+}
+
+/** Create-if-absent publish of a small lock marker file. */
+async function publishTakeoverMarker(target: string): Promise {
+ // Unique marker content doubles as the ownership identity below.
+ const marker = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
+ try {
+ await createFileIfAbsent(target, marker);
+ } catch (error) {
+ if (isAlreadyExists(error)) return false;
+ throw error;
+ }
+ // The stale-marker sweep races this publish: it may unlink our fresh marker
+ // and publish its own. Verify ownership so only the survivor of that race
+ // proceeds. (A delete landing after this read is the irreducible residual
+ // of pathname-only locking — there is no conditional-delete syscall; its
+ // worst case is a duplicated download cycle, never a corrupt install,
+ // because swap claims guard the executable independently.)
+ const published = await readFile(target, 'utf-8').catch(() => null);
+ return published === marker;
+}
+
+/**
+ * Return the version recorded in the held lock file, or undefined when the
+ * lock is gone or unreadable. Lets a downloader that failed to acquire the
+ * lock distinguish "another instance is staging the SAME version" (its
+ * outcome is ours — report success) from "a different version is in flight"
+ * (must not be reported as success to a foreground `kimi upgrade`).
+ */
+export async function readUpdateInstallLockVersion(
+ filePath: string = getUpdateInstallLockFile(),
+): Promise {
+ let raw: string;
+ try {
+ raw = await readFile(filePath, 'utf-8');
+ } catch {
+ return undefined;
+ }
+ try {
+ const version: unknown = (JSON.parse(raw) as { version?: unknown }).version;
+ return typeof version === 'string' && version.length > 0 ? version : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/apps/kimi-code/src/cli/update/install-state.ts b/apps/kimi-code/src/cli/update/install-state.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7edc1b675b6ab04bf012df694682e24c2730624d
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/install-state.ts
@@ -0,0 +1,64 @@
+import { z } from 'zod';
+
+import { getUpdateInstallStateFile } from '#/utils/paths';
+import { readJsonFile, writeJsonFile } from '#/utils/persistence';
+
+import { emptyUpdateInstallState, type InstallSource, type UpdateInstallState } from './types';
+
+const InstallSourceSchema: z.ZodType = z.enum([
+ 'npm-global',
+ 'pnpm-global',
+ 'yarn-global',
+ 'bun-global',
+ 'homebrew',
+ 'native',
+ 'unsupported',
+]);
+
+const UpdateInstallStateSchema: z.ZodType = z
+ .object({
+ active: z
+ .object({
+ version: z.string().min(1),
+ source: InstallSourceSchema,
+ startedAt: z.string().min(1),
+ })
+ .strict()
+ .nullable(),
+ lastFailure: z
+ .object({
+ version: z.string().min(1),
+ failedAt: z.string().min(1),
+ attempts: z.number().int().min(1),
+ })
+ .strict()
+ .nullable(),
+ lastSuccess: z
+ .object({
+ version: z.string().min(1),
+ installedAt: z.string().min(1),
+ notifiedAt: z.string().min(1).nullable(),
+ })
+ .strict()
+ .nullable(),
+ })
+ .strict();
+
+export { emptyUpdateInstallState };
+
+export async function readUpdateInstallState(
+ filePath: string = getUpdateInstallStateFile(),
+): Promise {
+ try {
+ return await readJsonFile(filePath, UpdateInstallStateSchema, emptyUpdateInstallState());
+ } catch {
+ return emptyUpdateInstallState();
+ }
+}
+
+export async function writeUpdateInstallState(
+ value: UpdateInstallState,
+ filePath: string = getUpdateInstallStateFile(),
+): Promise {
+ await writeJsonFile(filePath, UpdateInstallStateSchema, value);
+}
diff --git a/apps/kimi-code/src/cli/update/native-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eb81c948418301bfcb2d8241b678ce2293089ff7
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/native-manifest.ts
@@ -0,0 +1,115 @@
+/**
+ * Per-release native artifact manifest (`/binaries//manifest.json`).
+ *
+ * Published alongside the release and consumed by the install scripts; the
+ * staged updater reuses the same file so checksums and file names have a
+ * single source of truth. Entries point at the bare platform binary
+ * (`kimi-code-[.exe]`), not an archive; an entry may additionally
+ * carry `zstd` (or the legacy `compressed` field), pointing at the
+ * zstd-compressed variant of that binary.
+ */
+
+import { valid } from 'semver';
+import { z } from 'zod';
+
+import { kimiCodeCdnBinariesBase } from '#/constant/app';
+
+const MANIFEST_FETCH_TIMEOUT_MS = 10_000;
+
+const PlatformEntrySchema = z.object({
+ filename: z.string().min(1),
+ checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
+ zstd: z
+ .object({
+ file: z.string().min(1),
+ sha256: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
+ })
+ .optional(),
+ compressed: z
+ .object({
+ filename: z.string().min(1),
+ checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
+ })
+ .optional(),
+});
+
+/**
+ * Deliberately NOT `.strict()` — unknown fields are ignored so future
+ * manifest additions never break shipped clients (same contract philosophy
+ * as the rollout manifest in `cdn.ts`).
+ */
+export const NativeReleaseManifestSchema = z.object({
+ version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
+ platforms: z.record(z.string(), PlatformEntrySchema),
+});
+
+export type NativeReleaseManifest = z.infer;
+export type NativePlatformEntry = z.infer;
+
+export function nativeManifestUrl(version: string): string {
+ return `${kimiCodeCdnBinariesBase()}/${version}/manifest.json`;
+}
+
+export function nativeBinaryUrl(version: string, filename: string): string {
+ return `${kimiCodeCdnBinariesBase()}/${version}/${filename}`;
+}
+
+/**
+ * Fetch and parse the per-release manifest. **Throws** on any failure
+ * (network, non-2xx, malformed body, unknown version) — callers treat a
+ * throw as "staging failed" and record an install failure.
+ *
+ * `version` goes into the URL, so it must be a valid semver (it always is:
+ * upstream sources are the CDN `latest.json` / the `upgrade` command).
+ * `fetchImpl` is injectable for tests.
+ */
+export async function fetchNativeReleaseManifest(
+ version: string,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ if (valid(version) === null) {
+ throw new Error(`invalid semver for native manifest lookup: ${JSON.stringify(version)}`);
+ }
+ const controller = new AbortController();
+ const timeout = setTimeout(() => {
+ controller.abort();
+ }, MANIFEST_FETCH_TIMEOUT_MS);
+ // The timeout must stay armed until the BODY is fully consumed: a CDN or
+ // proxy can deliver headers within the limit and then stall mid-body, and
+ // resolving `fetch()` alone would clear the timer and hang the worker.
+ try {
+ const response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal });
+ if (!response.ok) {
+ throw new Error(`native manifest for ${version} returned HTTP ${response.status}`);
+ }
+ const manifest = NativeReleaseManifestSchema.parse(JSON.parse(await response.text()));
+ // A stale or mispublished endpoint can answer with ANOTHER release's
+ // manifest: its checksums would then be applied to this version's binary
+ // and every download would fail verification. Reject the mismatch here.
+ if (manifest.version !== version) {
+ throw new Error(`manifest for ${version} served content for ${manifest.version}`);
+ }
+ return manifest;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+/**
+ * Pick the entry for the running platform. The release pipeline keys
+ * platforms by `-` (win32-x64, darwin-arm64, …).
+ * **Throws** when the platform is missing — a silent skip would strand the
+ * update in a retry loop.
+ */
+export function selectPlatformEntry(
+ manifest: NativeReleaseManifest,
+ platform: NodeJS.Platform,
+ arch: string,
+): NativePlatformEntry {
+ const target = `${platform}-${arch}`;
+ const entry = manifest.platforms[target];
+ if (entry === undefined) {
+ throw new Error(`platform ${target} not found in native manifest for ${manifest.version}`);
+ }
+ return entry;
+}
diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..33bcd6a53b4c7074fb5d71ea002f930b34d3a932
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/native-stage.ts
@@ -0,0 +1,556 @@
+/**
+ * Native staged update: download + verify into `/.staging/`,
+ * without touching the running executable. The actual swap happens on the
+ * next startup (see `native-swap.ts`).
+ *
+ * The CDN serves the bare platform binary (e.g. `kimi-code-win32-x64.exe`),
+ * whose sha256 comes from the per-release manifest over HTTPS — a staged
+ * binary is byte-exact what the release pipeline produced.
+ */
+
+import { createHash } from 'node:crypto';
+import { createReadStream } from 'node:fs';
+import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink } from 'node:fs/promises';
+import { basename, join } from 'node:path';
+import { createZstdDecompress } from 'node:zlib';
+
+import { valid } from 'semver';
+import { z } from 'zod';
+
+import { KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME } from '#/constant/app';
+import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths';
+import { writeJsonFile } from '#/utils/persistence';
+
+import {
+ fetchNativeReleaseManifest,
+ nativeBinaryUrl,
+ selectPlatformEntry,
+} from './native-manifest';
+
+const StagedNativeUpdateSchema = z
+ .object({
+ version: z.string().min(1),
+ target: z.string().min(1),
+ /** Base name of the staged executable inside `.staging/`. */
+ exeFileName: z
+ .string()
+ .min(1)
+ .refine((value) => basename(value) === value, { error: 'must be a plain file name' }),
+ /** sha256 of the staged binary (the manifest's checksum). */
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
+ exeSize: z.number().int().min(1),
+ stagedAt: z.string().min(1),
+ /**
+ * True when the stage was produced by an explicit user-initiated
+ * `kimi upgrade` (vs the passive background downloader): manual stages
+ * still apply when automatic updates are opted out via env.
+ */
+ manual: z.boolean().optional(),
+ })
+ .strict();
+
+export type StagedNativeUpdate = z.infer;
+
+export function stagedExeFileName(version: string, platform: NodeJS.Platform): string {
+ return platform === 'win32' ? `kimi-${version}.exe` : `kimi-${version}`;
+}
+
+/** Uniquifies the published staged-exe name across concurrent in-process workers. */
+let stageTempCounter = 0;
+
+/**
+ * The name a stage is published under: the base name plus a unique per-worker
+ * infix (`kimi-...[.exe]`). Once published, a
+ * staged executable is NEVER replaced — a same-version re-download publishes
+ * a new generation and the atomic metadata write retargets the pointer — so
+ * the pathname a swap validates at claim time is stable: no concurrent
+ * publisher can exchange the bytes between validation and install.
+ */
+function uniqueStagedExeFileName(version: string, platform: NodeJS.Platform): string {
+ const infix = `.${process.pid}.${Date.now()}.${stageTempCounter}`;
+ stageTempCounter += 1;
+ return platform === 'win32' ? `kimi-${version}${infix}.exe` : `kimi-${version}${infix}`;
+}
+
+export function stagedExePath(exePath: string, staged: StagedNativeUpdate): string {
+ return join(getNativeStagingDir(exePath), staged.exeFileName);
+}
+
+/** Parse staged-update metadata from raw text; null when malformed. */
+export function parseStagedNativeUpdate(raw: string): StagedNativeUpdate | null {
+ let json: unknown;
+ try {
+ json = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ const parsed = StagedNativeUpdateSchema.safeParse(json);
+ return parsed.success ? parsed.data : null;
+}
+
+/**
+ * Read the staged-update metadata, returning null when anything is off:
+ * missing/corrupt `staged.json`, or the staged exe went away / changed size.
+ * A null result makes callers behave as if no update was ever staged.
+ */
+export async function readStagedNativeUpdate(
+ exePath: string,
+ filePath: string = getNativeStagedStateFile(exePath),
+): Promise {
+ let raw: string;
+ try {
+ raw = await readFile(filePath, 'utf-8');
+ } catch {
+ return null;
+ }
+ const staged = parseStagedNativeUpdate(raw);
+ if (staged === null) return null;
+ const info = await stat(stagedExePath(exePath, staged)).catch(() => null);
+ if (info === null || info.size !== staged.exeSize) return null;
+ return staged;
+}
+
+/**
+ * Two staged records are the same generation when every field matches —
+ * ignoring only the `manual` marker that promotion flips. Used to make sure
+ * a read-modify-write still acts on the record it read.
+ */
+function isSameStagedRecord(a: StagedNativeUpdate, b: StagedNativeUpdate): boolean {
+ return (
+ a.version === b.version &&
+ a.target === b.target &&
+ a.exeFileName === b.exeFileName &&
+ a.sha256 === b.sha256 &&
+ a.exeSize === b.exeSize &&
+ a.stagedAt === b.stagedAt
+ );
+}
+
+/**
+ * Mark the adopted staged update as manual, confirming the marker actually
+ * persisted. Used when an explicit `kimi upgrade` adopts a payload the
+ * passive downloader staged (already on disk, or still downloading): the
+ * marker lets the startup swap apply it even under the env opt-out.
+ *
+ * `expected` is the record the caller read and decided to adopt. The promote
+ * write only happens while the on-disk metadata still IS that record — a
+ * concurrent downloader may have published a different stage meanwhile, and
+ * overwriting its record would orphan a payload whose worker already
+ * reported success. (Pathname-only writes cannot compare-and-swap, so a
+ * residual publish-between-check-and-write window remains; the identity
+ * re-read narrows it to that gap.)
+ *
+ * Returns false when the record changed / is concurrently claimed by a
+ * startup swap (nothing to promote) or a confirming read never sees the
+ * promoted record — callers must NOT report adoption for a promotion that
+ * never landed. The write and the confirmation use the same atomic metadata
+ * path as staging; a swap that claims the PROMOTED file proceeds with the
+ * marker, which is the desired outcome anyway.
+ */
+export async function promoteStagedUpdateToManual(
+ exePath: string,
+ expected: StagedNativeUpdate,
+): Promise {
+ if (expected.manual === true) return true;
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const staged = await readStagedNativeUpdate(exePath);
+ if (staged === null || !isSameStagedRecord(staged, expected)) return false;
+ // Another promoter already marked this exact record — our work is done.
+ if (staged.manual === true) return true;
+ await writeJsonFile(getNativeStagedStateFile(exePath), StagedNativeUpdateSchema, {
+ ...staged,
+ manual: true,
+ });
+ // Confirm: a concurrent claim/restore cycle could leave unpromoted
+ // content behind (the restore never overwrites, so a confirmed marker
+ // cannot be displaced afterwards). The confirmation must see the
+ // promoted ADOPTION CANDIDATE itself, not just any manual record.
+ const confirmed = await readStagedNativeUpdate(exePath);
+ if (confirmed?.manual === true && isSameStagedRecord(confirmed, expected)) return true;
+ }
+ return false;
+}
+
+/** Stream a file's sha256 as hex; null when the file cannot be read. */
+export async function hashFileSha256(filePath: string): Promise {
+ try {
+ const hash = createHash('sha256');
+ for await (const chunk of createReadStream(filePath)) {
+ hash.update(chunk as Buffer);
+ }
+ return hash.digest('hex');
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Whether a `.staging/` entry is an updater-owned artifact: a staged
+ * executable (`kimi-[...][.exe]`) or a download
+ * intermediate (the same plus `.part`, optionally with a `.zst` infix).
+ * Ownership derives from the semver/file-name contract (prerelease and
+ * build metadata included), so foreign files in the directory are never
+ * matched.
+ */
+function isUpdaterOwnedStagingFile(entry: string): boolean {
+ if (!entry.startsWith('kimi-')) return false;
+ let name = entry.slice('kimi-'.length);
+ if (name.endsWith('.part')) name = name.slice(0, -'.part'.length);
+ if (name.endsWith('.zst')) name = name.slice(0, -'.zst'.length);
+ if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length);
+ // Published artifacts may carry a unique per-worker infix after the
+ // version (..., or the older ..) — try with and
+ // without stripping it (the infix is dot-numeric, which is ambiguous with
+ // prerelease suffixes, so every candidate is checked).
+ const candidates = [
+ name,
+ name.replace(/\.\d+\.\d+$/, ''),
+ name.replace(/\.\d+\.\d+\.\d+$/, ''),
+ ];
+ return candidates.some((candidate) => valid(candidate) !== null);
+}
+
+/**
+ * An unreferenced artifact is only deleted once it is older than this. A
+ * concurrent worker's payload publishes BEFORE its metadata, so a freshly
+ * renamed staged exe can look like an orphan for a moment; publication takes
+ * milliseconds, so anything unreferenced AND old is definitively abandoned.
+ */
+const STAGING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
+
+/**
+ * Remove files in `.staging/` that nothing references: interrupted downloads
+ * (`.part`), and staged exes whose `staged.json` never landed (downloader
+ * killed between the two writes) — each such orphan is ~180 MB and would
+ * otherwise accumulate forever. The exe referenced by the CURRENT
+ * `staged.json` is preserved (a superseded record is only replaced by the
+ * final atomic write, so its payload is still the applicable update while
+ * this run downloads), and so are swap claim files (`staged.json.swap-*`)
+ * with the exes they reference: another instance may be mid-swap.
+ */
+async function cleanupStagingOrphans(stagingDir: string): Promise {
+ let entries: string[];
+ try {
+ entries = await readdir(stagingDir);
+ } catch {
+ return;
+ }
+ const keep = new Set([KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME]);
+ for (const entry of entries) {
+ // The current record and every swap claim pin the exe they reference.
+ if (
+ entry !== KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME &&
+ !entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)
+ ) {
+ continue;
+ }
+ keep.add(entry);
+ const raw = await readFile(join(stagingDir, entry), 'utf-8').catch(() => null);
+ if (raw === null) continue;
+ try {
+ const exeFileName: unknown = (JSON.parse(raw) as { exeFileName?: unknown }).exeFileName;
+ if (typeof exeFileName === 'string' && exeFileName.length > 0) {
+ // basename(): the metadata contract is a plain file name — never let
+ // a hand-crafted path escape the staging dir.
+ keep.add(basename(exeFileName));
+ }
+ } catch {
+ // Unparseable record/claim: keep the file itself, touch nothing else.
+ }
+ }
+ for (const entry of entries) {
+ if (keep.has(entry)) continue;
+ // Only ever unlink updater-owned artifact names (files, never
+ // directories): the staging dir sits next to the exe and may contain
+ // data that is not ours.
+ if (!isUpdaterOwnedStagingFile(entry)) continue;
+ const full = join(stagingDir, entry);
+ const info = await stat(full).catch(() => null);
+ if (info === null) continue;
+ // Too young to be abandoned — a concurrent worker may be about to
+ // publish its metadata.
+ if (Date.now() - info.mtimeMs < STAGING_ORPHAN_GRACE_MS) continue;
+ await unlink(full).catch(() => {});
+ }
+}
+
+export interface StageNativeUpdateOptions {
+ readonly version: string;
+ /** Path of the installed executable the staged binary will later replace. */
+ readonly exePath: string;
+ readonly platform?: NodeJS.Platform;
+ readonly arch?: string;
+ readonly fetchImpl?: typeof fetch;
+ /** Download progress (bytes so far, Content-Length total when known). */
+ readonly onProgress?: (downloadedBytes: number, totalBytes: number | null) => void;
+ /** Test hook: override the download idle timeout (default 30 s). */
+ readonly idleTimeoutMs?: number;
+ /** True when the stage answers an explicit user-initiated `kimi upgrade`. */
+ readonly manual?: boolean;
+}
+
+export type StageNativeUpdateStatus = 'already-staged' | 'staged';
+
+export interface StageNativeUpdateResult {
+ readonly status: StageNativeUpdateStatus;
+ readonly staged: StagedNativeUpdate;
+}
+
+/**
+ * Idle timeout for the binary stream: any 30 s without a arriving chunk
+ * aborts the download. Total duration is intentionally unbounded — slow
+ * networks may take as long as they need as long as bytes keep flowing.
+ */
+const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
+
+async function downloadAndHash(
+ url: string,
+ partPath: string,
+ expectedSha256: string,
+ fetchImpl: typeof fetch,
+ onProgress?: (downloadedBytes: number, totalBytes: number | null) => void,
+ idleTimeoutMs: number = DOWNLOAD_IDLE_TIMEOUT_MS,
+): Promise {
+ const controller = new AbortController();
+ let idleTimeout: ReturnType | undefined;
+ const armIdleTimeout = (): void => {
+ if (idleTimeout !== undefined) clearTimeout(idleTimeout);
+ idleTimeout = setTimeout(() => {
+ controller.abort(new Error(`download stalled: no data for ${idleTimeoutMs}ms`));
+ }, idleTimeoutMs);
+ };
+ armIdleTimeout();
+ let response: Response;
+ try {
+ response = await fetchImpl(url, { signal: controller.signal });
+ } catch (error) {
+ clearTimeout(idleTimeout);
+ throw error;
+ }
+ if (!response.ok || response.body === null) {
+ clearTimeout(idleTimeout);
+ throw new Error(`native binary download returned HTTP ${response.status}`);
+ }
+ const contentLength = response.headers.get('content-length');
+ const total =
+ contentLength !== null && /^\d+$/.test(contentLength) ? Number(contentLength) : null;
+ const hash = createHash('sha256');
+ let size = 0;
+ const file = await open(partPath, 'w');
+ try {
+ for await (const chunk of response.body as AsyncIterable) {
+ armIdleTimeout();
+ hash.update(chunk);
+ size += chunk.length;
+ // FileHandle.write may persist FEWER bytes than requested (a short
+ // write, e.g. near disk exhaustion) while the hash and size above
+ // already account for the whole chunk — an unretried short write would
+ // publish a truncated binary under a valid checksum. Loop until the
+ // chunk is fully on disk.
+ let offset = 0;
+ while (offset < chunk.length) {
+ const { bytesWritten } = await file.write(chunk, offset);
+ if (bytesWritten === 0) {
+ throw new Error('failed to write the native binary to disk (disk full?)');
+ }
+ offset += bytesWritten;
+ }
+ onProgress?.(size, total);
+ }
+ } finally {
+ clearTimeout(idleTimeout);
+ await file.close();
+ }
+ const digest = hash.digest('hex');
+ if (digest !== expectedSha256) {
+ throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`);
+ }
+ return size;
+}
+
+/**
+ * Inflate a downloaded `.zst` artifact into `destPath`, hashing the plain
+ * bytes as they stream through; **throws** when the result does not match
+ * the manifest's bare-binary checksum. Returns the decompressed size.
+ */
+async function decompressAndHash(
+ zstPath: string,
+ destPath: string,
+ expectedSha256: string,
+): Promise {
+ const hash = createHash('sha256');
+ let size = 0;
+ const file = await open(destPath, 'w');
+ try {
+ for await (const chunk of createReadStream(zstPath).pipe(createZstdDecompress())) {
+ hash.update(chunk as Buffer);
+ size += (chunk as Buffer).length;
+ // Same short-write loop as downloadAndHash: FileHandle.write may
+ // persist fewer bytes than requested, so loop until the chunk is
+ // fully on disk.
+ let offset = 0;
+ while (offset < (chunk as Buffer).length) {
+ const { bytesWritten } = await file.write(chunk as Buffer, offset);
+ if (bytesWritten === 0) {
+ throw new Error('failed to write the native binary to disk (disk full?)');
+ }
+ offset += bytesWritten;
+ }
+ }
+ } finally {
+ await file.close();
+ }
+ const digest = hash.digest('hex');
+ if (digest !== expectedSha256) {
+ throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`);
+ }
+ return size;
+}
+
+/**
+ * Download + verify `version` next to the running executable.
+ *
+ * Short-circuits with `already-staged` when the same version is ready on
+ * disk (repeat `kimi upgrade`, or foreground/background overlap). **Throws**
+ * on any failure after cleaning up this version's leftovers — the caller
+ * records an install failure.
+ */
+export async function stageNativeUpdate(
+ options: StageNativeUpdateOptions,
+): Promise {
+ const platform = options.platform ?? process.platform;
+ const arch = options.arch ?? process.arch;
+ // Validate BEFORE anything derives a filesystem path from the version: the
+ // hidden download command takes it from argv, and a non-semver could carry
+ // path traversal into the cleanup paths below.
+ if (valid(options.version) === null) {
+ throw new Error(`invalid semver for native staging: ${JSON.stringify(options.version)}`);
+ }
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const target = `${platform}-${arch}`;
+ // Unique per-worker publish name — see uniqueStagedExeFileName: a staged
+ // exe is never replaced once published, so the pathname a swap validates
+ // at claim time cannot be exchanged by a concurrent publisher.
+ const exeFileName = uniqueStagedExeFileName(options.version, platform);
+
+ const existing = await readStagedNativeUpdate(options.exePath);
+ if (existing !== null && existing.version === options.version) {
+ // readStagedNativeUpdate checks only the recorded size — a same-size
+ // corruption after the download (disk damage, a non-durable write)
+ // would still be adopted here and reported as success, only for the
+ // startup swap's claim-time re-verify to reject and discard it. Compare
+ // the actual digest before adopting; a mismatch falls through and
+ // re-stages from the CDN (published under a new generation name — the
+ // damaged exe is left for the age-gated orphan cleanup).
+ const digest = await hashFileSha256(stagedExePath(options.exePath, existing));
+ if (digest === existing.sha256) {
+ // An explicit upgrade adopts an auto-staged payload — but only report
+ // the adoption once the manual marker is confirmed persisted. A stage
+ // currently being claimed by a startup swap cannot be promoted here;
+ // fall through and stage afresh instead.
+ if (options.manual === true && existing.manual !== true) {
+ if (await promoteStagedUpdateToManual(options.exePath, existing)) {
+ return { status: 'already-staged', staged: { ...existing, manual: true } };
+ }
+ } else {
+ return { status: 'already-staged', staged: existing };
+ }
+ }
+ }
+
+ // A different version was staged earlier and never swapped (skipped
+ // rollout, user stayed offline, …), or the same version's payload failed
+ // the integrity check above. The old record is LEFT IN PLACE until the
+ // atomic metadata write below replaces it: a pathname-level delete could
+ // remove a concurrent worker's freshly published record (orphaning a
+ // payload whose worker already reported success), and a swap claiming the
+ // old stage meanwhile applies a still-valid update. The old exe stays too
+ // — an unreferenced one is reaped by the age-gated orphan cleanup.
+ const stagingDir = getNativeStagingDir(options.exePath);
+ await mkdir(stagingDir, { recursive: true });
+ // Drop orphans from interrupted earlier runs before writing ours.
+ await cleanupStagingOrphans(stagingDir);
+
+ const staged: StagedNativeUpdate = {
+ version: options.version,
+ target,
+ exeFileName,
+ sha256: '',
+ exeSize: 0,
+ stagedAt: new Date().toISOString(),
+ manual: options.manual === true ? true : undefined,
+ };
+
+ // The .part intermediate is just the publish name plus the suffix — the
+ // name already carries this worker's unique infix, so concurrent workers
+ // never interleave writes into a shared path.
+ const partPath = join(stagingDir, `${exeFileName}.part`);
+ try {
+ const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl);
+ const entry = selectPlatformEntry(manifest, platform, arch);
+ const compressed = entry.zstd === undefined
+ ? entry.compressed
+ : { filename: entry.zstd.file, checksum: entry.zstd.sha256 };
+ // Prefer the zstd-compressed artifact when the manifest carries one and
+ // the runtime can inflate it (~4x smaller than the bare binary). Any
+ // failure in the compressed path falls back to the bare download below.
+ let size: number | undefined;
+ if (compressed !== undefined && typeof createZstdDecompress === 'function') {
+ const zstPartPath = join(stagingDir, `${exeFileName}.zst.part`);
+ try {
+ await downloadAndHash(
+ nativeBinaryUrl(options.version, compressed.filename),
+ zstPartPath,
+ compressed.checksum,
+ fetchImpl,
+ options.onProgress,
+ options.idleTimeoutMs,
+ );
+ size = await decompressAndHash(zstPartPath, partPath, entry.checksum);
+ await rm(zstPartPath, { force: true });
+ } catch (error) {
+ console.warn(
+ `[update] compressed artifact unavailable, falling back to uncompressed download: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ await rm(zstPartPath, { force: true }).catch(() => {});
+ await rm(partPath, { force: true }).catch(() => {});
+ }
+ }
+ size ??= await downloadAndHash(
+ nativeBinaryUrl(options.version, entry.filename),
+ partPath,
+ entry.checksum,
+ fetchImpl,
+ options.onProgress,
+ options.idleTimeoutMs,
+ );
+ // sha256 matched the manifest. Make the private .part file executable
+ // BEFORE publishing it: a concurrent swap may move the staged exe into
+ // the install path the instant it appears at its published name, so a
+ // post-publish chmod could land on a path that is already gone — leaving
+ // a non-executable installation behind.
+ await chmod(partPath, 0o755);
+ await rename(partPath, stagedExePath(options.exePath, staged));
+
+ staged.sha256 = entry.checksum;
+ staged.exeSize = size;
+ // Atomic write: staged.json only ever appears complete and consistent.
+ await writeJsonFile(
+ getNativeStagedStateFile(options.exePath),
+ StagedNativeUpdateSchema,
+ staged,
+ );
+ return { status: 'staged', staged };
+ } catch (error) {
+ // Remove only what THIS attempt privately owns: its unique .part file.
+ // If the failure landed after the publishing rename, this attempt's exe
+ // is already at its unique name with no metadata pointing at it — left
+ // in place (a just-published exe may belong to a concurrent metadata
+ // write) and reaped by the age-gated orphan cleanup.
+ await rm(partPath, { force: true }).catch(() => {});
+ // Best effort: drop the staging dir itself when empty (a concurrent
+ // worker's files keep it around — rmdir only removes empty dirs).
+ await rmdir(getNativeStagingDir(options.exePath)).catch(() => {});
+ throw error;
+ }
+}
diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9b477efababa0137f614801a7c2b2c27059c05e6
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/native-swap.ts
@@ -0,0 +1,642 @@
+/**
+ * Native staged swap, executed at the very top of startup.
+ *
+ * When a staged update is ready (`.staging/staged.json` next to the running
+ * exe), swap it in atomically and re-exec so the user session runs the new
+ * binary immediately. Everything here is best-effort: any failure leaves the
+ * current exe intact (rollback from `.bak`) and startup continues normally.
+ *
+ * Windows semantics make this safe: a running exe can be renamed but not
+ * overwritten, so the sequence is `rename exe→.bak` (the running process is
+ * unaffected), `rename staged→exe`, then delete `.bak` (best effort — a
+ * concurrent old instance keeps it locked until it exits). This is the same
+ * mechanism install.ps1 already relies on, and the Squirrel/NSIS-style
+ * "next launch performs the swap" pattern. Leftovers a swap cannot remove
+ * (its own `.bak` while still running, crash residue in `.staging/`) are
+ * swept best-effort on every launch.
+ */
+
+import { spawn } from 'node:child_process';
+import { readdir, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises';
+import { constants as osConstants } from 'node:os';
+import { basename, dirname, join } from 'node:path';
+
+import { gt } from 'semver';
+
+import { log } from '@moonshot-ai/kimi-code-sdk';
+
+import {
+ KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME,
+ KIMI_CODE_UPDATE_REEXEC_ENV,
+} from '#/constant/app';
+
+import { readUpdateInstallState, writeUpdateInstallState } from './install-state';
+import {
+ hashFileSha256,
+ parseStagedNativeUpdate,
+ readStagedNativeUpdate,
+ stagedExePath,
+ type StagedNativeUpdate,
+} from './native-stage';
+import { isAutoUpdateDisabledByEnv, shouldAutoInstallUpdates } from './preflight';
+import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths';
+import { createFileIfAbsent } from '#/utils/persistence';
+
+export interface NativeSwapDeps {
+ readonly exePath: string;
+ readonly argv: readonly string[];
+ readonly env: NodeJS.ProcessEnv;
+ readonly currentVersion: string;
+ readonly isNative: boolean;
+ readonly spawnImpl?: typeof spawn;
+ readonly exitImpl?: (code: number) => void;
+}
+
+export interface SpawnedChild {
+ once(event: 'error', listener: (error: Error) => void): void;
+ once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
+ once(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
+}
+
+function isTruthy(value: string | undefined): boolean {
+ return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
+}
+
+function isNotFound(error: unknown): boolean {
+ return (
+ typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT'
+ );
+}
+
+function isAlreadyExists(error: unknown): boolean {
+ return (
+ typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST'
+ );
+}
+
+/**
+ * A `staged.json.swap-` claim file younger than this marks a swap in
+ * progress in another instance; older ones are crash residue. The bound
+ * comfortably exceeds the slowest swap (smoke-check timeout included).
+ */
+const SWAP_CLAIM_STALE_MS = 5 * 60 * 1000;
+
+/**
+ * The swap's executable-renaming critical section is a few filesystem ops
+ * (well under a second), so a swap mutex older than this is crash residue.
+ */
+const SWAP_MUTEX_STALE_MS = 60_000;
+
+/**
+ * A young unparseable `staged.json` may be an in-flight exclusive-create
+ * publish (observable mid-write on filesystems without hard links — see
+ * createFileIfAbsent), not corruption. The publish gap is microscopic, so
+ * only records younger than this get the benefit of the doubt.
+ */
+const STAGED_PUBLISH_GRACE_MS = 60_000;
+
+// First launch of a fresh ~150 MB unsigned exe can sit in an antivirus scan;
+// give Windows extra headroom so a slow scan is not misread as a broken binary.
+const SMOKE_CHECK_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 15_000;
+
+function logSwap(message: string, payload: Record): void {
+ try {
+ log.info(`native update swap: ${message}`, payload);
+ } catch {
+ // Diagnostics must never affect startup.
+ }
+}
+
+/** Record a swap failure so preflight stops re-staging the same bad version. */
+async function recordSwapFailure(version: string): Promise {
+ try {
+ const state = await readUpdateInstallState();
+ const attempts =
+ (state.lastFailure?.version === version ? state.lastFailure.attempts : 0) + 1;
+ await writeUpdateInstallState({
+ ...state,
+ active: null,
+ lastFailure: { version, failedAt: new Date().toISOString(), attempts },
+ });
+ } catch {
+ // Never block startup on bookkeeping.
+ }
+}
+
+/**
+ * Run `exe --version` as a smoke check: exit code 0 and the EXACT staged
+ * version as the output (commander prints `\n`). A substring check
+ * would let a mispublished binary satisfy the wrong target (`1.2.30`
+ * contains `1.2.3`) — and the manifest checksum cannot catch that case when
+ * it also describes the wrong artifact.
+ */
+function smokeCheck(
+ exePath: string,
+ staged: StagedNativeUpdate,
+ spawnImpl: typeof spawn,
+): Promise {
+ return new Promise((resolve) => {
+ let stdout = '';
+ let settled = false;
+ const finish = (ok: boolean): void => {
+ if (settled) return;
+ settled = true;
+ resolve(ok);
+ };
+ let child: SpawnedChild & { readonly stdout?: NodeJS.ReadableStream | null; kill(): void };
+ try {
+ child = spawnImpl(exePath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) as unknown as typeof child;
+ } catch {
+ finish(false);
+ return;
+ }
+ const timeout = setTimeout(() => {
+ try {
+ child.kill();
+ } catch {
+ // Already gone.
+ }
+ finish(false);
+ }, SMOKE_CHECK_TIMEOUT_MS);
+ child.stdout?.on('data', (chunk: Buffer) => {
+ stdout += chunk.toString('utf-8');
+ });
+ child.once('error', () => {
+ clearTimeout(timeout);
+ finish(false);
+ });
+ // 'close', not 'exit': stdio may still be flushing when 'exit' fires, and
+ // the check needs the complete version output.
+ child.once('close', (code) => {
+ clearTimeout(timeout);
+ finish(code === 0 && stdout.trim() === staged.version);
+ });
+ });
+}
+
+interface ClaimedStaged {
+ readonly staged: StagedNativeUpdate;
+ readonly claimedPath: string;
+}
+
+/**
+ * Atomically claim the staged metadata file (rename is atomic on both NTFS
+ * and POSIX, so exactly one of several concurrently starting instances wins),
+ * THEN parse the claimed contents. Claim-first matters: a concurrent
+ * downloader may supersede `staged.json` at any moment, so validating before
+ * the rename could act on metadata this swap never claimed.
+ *
+ * Returns null when there is nothing staged, the file disappeared under us,
+ * or the claimed metadata failed consistency checks. A claimed record that is
+ * UNPARSEABLE but was young at claim time may be an in-flight
+ * exclusive-create publish (observable mid-write where hard links are
+ * unsupported): it is put back with the same inode so the writer completes
+ * it, never destroyed. Aged corrupt residue and well-formed records whose exe
+ * is gone/changed are deterministically dead and discarded.
+ */
+async function claimStagedUpdate(exePath: string): Promise {
+ const stateFile = getNativeStagedStateFile(exePath);
+ const claimedPath = `${stateFile}.swap-${process.pid}`;
+ // Capture the record's age BEFORE the stamp below rewrites it.
+ const before = await stat(stateFile).catch(() => null);
+ const youngAtClaim =
+ before === null || Date.now() - before.mtimeMs <= STAGED_PUBLISH_GRACE_MS;
+ try {
+ // The metadata's mtime can be arbitrarily old — the download may have
+ // finished hours before this launch. Stamp it BEFORE the rename so the
+ // claim is born fresh: a concurrent launch's sweep never observes a live
+ // claim that looks like crash residue (and would delete the staged exe
+ // plus this swap's rollback backup). Stamping the state file itself is
+ // harmless — nothing reads its mtime.
+ await utimes(stateFile, new Date(), new Date()).catch(() => {});
+ await rename(stateFile, claimedPath);
+ } catch {
+ return null;
+ }
+ // Parse exactly the metadata we claimed.
+ const staged = await readStagedNativeUpdate(exePath, claimedPath);
+ if (staged === null) {
+ const raw = await readFile(claimedPath, 'utf-8').catch(() => null);
+ const wellFormed = raw !== null && parseStagedNativeUpdate(raw) !== null;
+ if (!wellFormed && youngAtClaim) {
+ // Possible in-flight publish: put the SAME inode back so the writer's
+ // pending write completes it. rename can overwrite a concurrently
+ // published newer record — bounded to this parse-failure window, and
+ // the loser is a newer stage that simply re-downloads, never a corrupt
+ // install.
+ await rename(claimedPath, stateFile).catch(() => {});
+ return null;
+ }
+ await unlink(claimedPath).catch(() => {});
+ return null;
+ }
+ return { staged, claimedPath };
+}
+
+/**
+ * Put a claimed stage's metadata back so a later launch can retry — but only
+ * into a still-free state-file path: a downloader may have published a NEWER
+ * stage meanwhile, and an unconditional restore would silently replace it.
+ * The publish is create-if-absent (hard link, or an exclusive create on
+ * filesystems without hard-link support), so the restore never overwrites.
+ *
+ * The claim file is removed only when the restore landed or the path was
+ * taken by a newer stage (ours is superseded either way). A transient
+ * failure (ENOSPC, EACCES, …) RETAINS the claim: discarding it would orphan
+ * the staged exe with no newer stage to show for it, and the stale-claim
+ * sweep retries the restore on a later launch.
+ */
+async function restoreClaimedUpdate(exePath: string, claimedPath: string): Promise {
+ const content = await readFile(claimedPath, 'utf-8').catch(() => null);
+ if (content === null) {
+ // Nothing readable to restore — drop the residue.
+ await unlink(claimedPath).catch(() => {});
+ return;
+ }
+ try {
+ await createFileIfAbsent(getNativeStagedStateFile(exePath), content);
+ } catch (error) {
+ if (!isAlreadyExists(error)) return;
+ // EEXIST: a concurrently published newer stage won the path.
+ }
+ await unlink(claimedPath).catch(() => {});
+}
+
+/**
+ * Discard a claimed stage: only the claimed metadata file is removed — never
+ * the staged exe. A same-version downloader may have just renamed its fresh
+ * payload onto that path (payloads publish before their metadata), and
+ * genuinely unreferenced exes are reaped by the downloader's own orphan
+ * cleanup before its next stage.
+ */
+async function discardClaimedUpdate(claimedPath: string): Promise {
+ await unlink(claimedPath).catch(() => {});
+}
+
+async function rollback(bakPath: string, exePath: string): Promise {
+ try {
+ await rename(bakPath, exePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export interface SwapMutexHandle {
+ release(): Promise;
+}
+
+/**
+ * Serialize the swap's executable-renaming critical section across CLI
+ * processes. The fresh-claim sweep is only a directory SNAPSHOT: two
+ * processes can both pass it before either claims, then claim different
+ * stage generations and rename the same installed exe concurrently —
+ * deleting or replacing each other's `.bak` rollback source. The mutex is
+ * create-if-absent (via createFileIfAbsent); an aged holder is crash residue
+ * (the section lasts well under a second) and is swept, then retried once.
+ */
+async function acquireSwapMutex(stagingDir: string): Promise {
+ const mutexPath = join(stagingDir, 'swap.lock');
+ const marker = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ await createFileIfAbsent(mutexPath, marker);
+ } catch (error) {
+ if (!isAlreadyExists(error)) {
+ // Transient IO failure (ENOSPC, EACCES, …): defer the swap rather
+ // than abort it — the caller restores the claim for a later launch.
+ return null;
+ }
+ if (attempt === 1) return null;
+ // Held — or crash residue: only an AGED mutex may be swept.
+ const info = await stat(mutexPath).catch(() => null);
+ if (info !== null && Date.now() - info.mtimeMs <= SWAP_MUTEX_STALE_MS) return null;
+ await unlink(mutexPath).catch(() => {});
+ continue;
+ }
+ // The stale sweep races this publish; only the survivor proceeds (same
+ // irreducible residual as the install lock's takeover marker).
+ const published = await readFile(mutexPath, 'utf-8').catch(() => null);
+ if (published !== marker) return null;
+ return {
+ release: async (): Promise => {
+ // Release only the mutex instance we own.
+ const current = await readFile(mutexPath, 'utf-8').catch(() => null);
+ if (current !== marker) return;
+ await unlink(mutexPath).catch(() => {});
+ },
+ };
+ }
+ return null;
+}
+
+/**
+ * Remove leftover `.bak` siblings of the exe from earlier swaps/installs.
+ * Only names the updater itself creates are removed: the exact `.bak`
+ * and the numeric PID fallback `..bak` — anything else with the
+ * prefix (`kimi.config.bak`, …) belongs to the user. A `.bak` still mapped
+ * by a running old instance cannot be deleted on Windows — it is simply
+ * left for a later launch.
+ */
+async function cleanupBackups(exePath: string, keepPath?: string): Promise {
+ const dir = dirname(exePath);
+ const base = basename(exePath);
+ let entries: string[];
+ try {
+ entries = await readdir(dir);
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (!entry.startsWith(`${base}.`) || !entry.endsWith('.bak')) continue;
+ const middle = entry.slice(base.length + 1, -'.bak'.length);
+ if (middle !== '' && !/^\d+$/.test(middle)) continue;
+ const full = join(dir, entry);
+ if (full === keepPath) continue;
+ await unlink(full).catch(() => {});
+ }
+}
+
+/**
+ * Recover `staged.json.swap-` claim files left by instances that died
+ * mid-swap (or kept by a restore that hit a transient error). An AGED claim
+ * is restored back onto the state-file path — create-if-absent, so a newer
+ * published stage is never overwritten — and this very launch can then claim
+ * and retry the swap; the claim file is dropped once the record is restored
+ * or superseded, and retained on transient errors. The referenced exes are
+ * never touched here: they may belong to a freshly published stage, and
+ * genuinely unreferenced ones are reaped by the downloader's own orphan
+ * cleanup before its next stage. Returns true when a FRESH claim file was
+ * seen — i.e. another instance is swapping right now.
+ */
+async function cleanupStaleSwapClaims(exePath: string): Promise {
+ const stagingDir = getNativeStagingDir(exePath);
+ let entries: string[];
+ try {
+ entries = await readdir(stagingDir);
+ } catch {
+ return false;
+ }
+ let swapInProgress = false;
+ for (const entry of entries) {
+ if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue;
+ const full = join(stagingDir, entry);
+ const info = await stat(full).catch(() => null);
+ if (info === null) continue;
+ if (Date.now() - info.mtimeMs < SWAP_CLAIM_STALE_MS) {
+ swapInProgress = true;
+ continue;
+ }
+ await restoreClaimedUpdate(exePath, full);
+ }
+ return swapInProgress;
+}
+
+/**
+ * Best-effort startup hygiene for update leftovers, run on every native
+ * launch. The swap itself can never fully clean up after its own run — the
+ * old process still holds its renamed image (`.bak`) on Windows — so later
+ * launches sweep what the previous run could not.
+ *
+ * Returns true when another instance holds a fresh swap claim or swap mutex:
+ * every artifact is then left alone and the caller must not start a second
+ * swap.
+ */
+async function sweepStaleNativeUpdateArtifacts(exePath: string): Promise {
+ try {
+ if (await cleanupStaleSwapClaims(exePath)) {
+ // Another instance is mid-swap: leave every artifact alone — the `.bak`
+ // next to the exe is its rollback source.
+ return true;
+ }
+ // A live swap critical section holds the mutex: same deference. (Only a
+ // snapshot, but the swap re-checks the mutex after claiming, so a
+ // freshly-started swap is never entered concurrently.)
+ const mutexInfo = await stat(join(getNativeStagingDir(exePath), 'swap.lock')).catch(
+ () => null,
+ );
+ if (mutexInfo !== null && Date.now() - mutexInfo.mtimeMs <= SWAP_MUTEX_STALE_MS) {
+ return true;
+ }
+ await cleanupBackups(exePath);
+ } catch {
+ // Hygiene must never affect startup.
+ }
+ return false;
+}
+
+/**
+ * Re-exec the (newly swapped) exe with the original argv, forwarding its exit
+ * code so the swap is invisible to the caller. Returns false when the spawn
+ * itself failed — the caller then continues startup with the old in-memory
+ * code; the binary on disk is already the new version.
+ */
+function reexec(
+ deps: NativeSwapDeps & { readonly spawnImpl: typeof spawn },
+): Promise {
+ return new Promise((resolve) => {
+ let child: SpawnedChild;
+ try {
+ child = deps.spawnImpl(deps.exePath, deps.argv.slice(2), {
+ stdio: 'inherit',
+ env: { ...deps.env, [KIMI_CODE_UPDATE_REEXEC_ENV]: '1' },
+ }) as unknown as SpawnedChild;
+ } catch (error) {
+ logSwap('re-exec spawn threw', { error: String(error) });
+ resolve(false);
+ return;
+ }
+ child.once('error', (error) => {
+ logSwap('re-exec spawn failed', { error: error.message });
+ resolve(false);
+ });
+ child.once('exit', (code, signal) => {
+ resolve(true);
+ const exitImpl = deps.exitImpl ?? ((exitCode: number) => process.exit(exitCode));
+ if (code !== null) {
+ exitImpl(code);
+ return;
+ }
+ // Terminated by a signal (OOM kill, external SIGKILL, …): mirror the
+ // shell's 128 + signo convention so the wrapper never reports a killed
+ // run as a successful CLI invocation.
+ const signo = signal !== null ? (osConstants.signals[signal] ?? 0) : 0;
+ exitImpl(signo > 0 ? 128 + signo : 1);
+ });
+ });
+}
+
+/**
+ * Swap in a staged native update and re-exec when one is ready.
+ *
+ * Returns true only when the process was re-launched (the caller must not
+ * continue startup — the exit handler fires once the child exits). Every
+ * other outcome returns false so startup proceeds untouched.
+ */
+export async function maybeRelaunchWithStagedNativeUpdate(
+ deps: NativeSwapDeps,
+): Promise {
+ if (!deps.isNative) return false;
+ const swapInProgress = await sweepStaleNativeUpdateArtifacts(deps.exePath);
+ if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) {
+ // Read-once guard: drop it so this session's children (and any nested
+ // kimi launches from them) do not inherit the swap skip.
+ delete deps.env[KIMI_CODE_UPDATE_REEXEC_ENV];
+ return false;
+ }
+ if (swapInProgress) {
+ // Another instance holds a fresh swap claim and finishes (or rolls back)
+ // on its own. Starting a second swap here would rename the install path
+ // from under it and let each launcher delete the `.bak` the other may
+ // still need for rollback. Its re-exec — or our next launch — lands the
+ // update, so this session simply runs the current exe.
+ logSwap('another instance is mid-swap, skipping', { exePath: deps.exePath });
+ return false;
+ }
+
+ const claimed = await claimStagedUpdate(deps.exePath);
+ if (claimed === null) return false;
+ const { staged, claimedPath } = claimed;
+ const spawnImpl = deps.spawnImpl ?? spawn;
+
+ const discard = async (): Promise => {
+ await discardClaimedUpdate(claimedPath);
+ return false;
+ };
+
+ // Downgrade guard: the staged version must be newer than what is running.
+ // (The user may have installed a newer build manually after we staged.)
+ if (!gt(staged.version, deps.currentVersion)) {
+ logSwap('discarding staged update (not newer)', {
+ staged: staged.version,
+ current: deps.currentVersion,
+ });
+ return discard();
+ }
+
+ // Automatic stages apply only while automatic updates are enabled — both
+ // the env opt-out and the persisted `[upgrade] auto_install = false`
+ // preference gate them. Evaluated on the CLAIMED metadata: a pre-claim
+ // snapshot could be replaced by a downloader before the claim, smuggling an
+ // automatic payload past the gate. A manually requested stage always
+ // applies. When disabled, restore the claim (never overwriting a newer
+ // stage) so a later launch without the opt-out can still apply it.
+ if (
+ staged.manual !== true &&
+ (isAutoUpdateDisabledByEnv(deps.env) || !(await shouldAutoInstallUpdates()))
+ ) {
+ await restoreClaimedUpdate(deps.exePath, claimedPath);
+ return false;
+ }
+
+ // Re-verify the staged bytes against the recorded checksum: the exe could
+ // have been damaged on disk after the download verified it (corruption, a
+ // non-durable interrupted write), and the `--version` smoke check alone
+ // would not catch every such case. Only paid once the swap actually
+ // proceeds. A mismatch discards the stage so a later cycle re-downloads
+ // it — this is not a swap failure.
+ const digest = await hashFileSha256(stagedExePath(deps.exePath, staged));
+ if (digest !== staged.sha256) {
+ logSwap('staged exe failed checksum verification, discarding', {
+ version: staged.version,
+ });
+ return discard();
+ }
+
+ const stagedExe = stagedExePath(deps.exePath, staged);
+
+ // 1. Smoke-check the staged exe BEFORE touching the install path: a staged
+ // binary that cannot start (or lies about its version) is discarded with
+ // the running exe never moved — the safest possible failure shape.
+ if (!(await smokeCheck(stagedExe, staged, spawnImpl))) {
+ logSwap('smoke check failed, discarding staged update', { version: staged.version });
+ await recordSwapFailure(staged.version);
+ return discard();
+ }
+
+ // The fresh-claim sweep at startup is only a directory snapshot — another
+ // instance may have begun its swap after our sweep ran. Take the swap
+ // mutex before touching the install path so two swaps never rename the
+ // same exe concurrently (each would delete the other's `.bak` rollback
+ // source). The staged payload is immutable (unique generation name), so
+ // nothing validated above can change while we contend here.
+ const swapMutex = await acquireSwapMutex(getNativeStagingDir(deps.exePath));
+ if (swapMutex === null) {
+ logSwap('another instance is in its swap critical section, deferring', {
+ exePath: deps.exePath,
+ });
+ await restoreClaimedUpdate(deps.exePath, claimedPath);
+ return false;
+ }
+ try {
+ // 2. Pick a backup slot and move the running exe aside (rename of a running
+ // exe is legal on Windows and POSIX alike; overwriting is not).
+ //
+ // Crash window: if the process dies between this rename and step 3, the
+ // install path is left empty and no CLI code can run to self-heal. Each
+ // rename is atomic, the window is two adjacent syscalls, and recovery is
+ // `mv .bak ` or re-running the install script.
+ let bakPath = `${deps.exePath}.bak`;
+ try {
+ await unlink(bakPath);
+ } catch (error) {
+ if (!isNotFound(error)) {
+ // The leftover `.bak` is locked by a still-running old instance (or
+ // undeletable for another reason) — take a unique backup name, the same
+ // fallback install.ps1 uses. It is best-effort cleaned up on later runs.
+ bakPath = `${deps.exePath}.${process.pid}.bak`;
+ }
+ }
+ try {
+ await rename(deps.exePath, bakPath);
+ } catch (error) {
+ // Nothing was moved: startup continues with the old exe. Restore the
+ // claimed metadata so a later launch retries the swap (transient locks
+ // clear on reboot) — but only into a still-free state-file path: a
+ // downloader may have published a NEWER stage while we smoke-checked,
+ // and an unconditional restore would silently replace it. The restore is
+ // create-if-absent, so it can never overwrite; when the path is taken,
+ // the newer stage wins and ours is discarded.
+ logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) });
+ await restoreClaimedUpdate(deps.exePath, claimedPath);
+ return false;
+ }
+
+ // 3. Move the staged exe into place; roll back on failure.
+ if ((await rename(stagedExe, deps.exePath).catch(() => null)) === null) {
+ logSwap('failed to move staged exe into place, rolling back', { exePath: deps.exePath });
+ if (!(await rollback(bakPath, deps.exePath))) {
+ // Rollback failed too (transient file lock, AV, …): the install path is
+ // now absent and no next launch can start. Keep every artifact instead
+ // of discarding — the `.bak` IS the old exe and the staged payload is a
+ // second recovery copy, so `mv .bak ` or re-running the
+ // installer still recovers.
+ logSwap('rollback failed, keeping recovery artifacts', {
+ exePath: deps.exePath,
+ bakPath,
+ });
+ await recordSwapFailure(staged.version);
+ return false;
+ }
+ await recordSwapFailure(staged.version);
+ return await discard();
+ }
+
+ // 4. Success: clean up, STILL INSIDE the mutex — a swap that acquires it
+ // the instant we release could rename the exe we just installed to the
+ // shared `.bak` path, and this cleanup would delete that rollback
+ // source. Then re-exec into the new binary.
+ await unlink(claimedPath).catch(() => {});
+ await unlink(bakPath).catch(() => {});
+ await cleanupBackups(deps.exePath, bakPath);
+ logSwap('swap succeeded, re-launching', { version: staged.version });
+ } finally {
+ await swapMutex.release();
+ }
+ // Cosmetic, now that the release removed our mutex file: drop the staging
+ // dir when empty. And re-exec OUTSIDE the critical section: the child runs
+ // the user session, so awaiting it inside the try would hold the mutex for
+ // its whole lifetime.
+ await rmdir(getNativeStagingDir(deps.exePath)).catch(() => {});
+ return reexec({ ...deps, spawnImpl });
+}
diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9d6d1f17249bc3f4b1293f5eb8f9782c1691fe0d
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/preflight.ts
@@ -0,0 +1,924 @@
+import { spawn } from 'node:child_process';
+
+import { log, type Logger } from '@moonshot-ai/kimi-code-sdk';
+import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry';
+
+import {
+ kimiCodeOfficialInstallUrl,
+ nativeInstallCommandUnix,
+ nativeInstallCommandWin,
+} from '#/constant/app';
+import { loadTuiConfig } from '#/tui/config';
+import { resolveCommandPath } from '#/utils/process/resolve-command';
+
+import { readUpdateCache } from './cache';
+import { tryAcquireUpdateInstallLock } from './install-lock';
+import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state';
+import {
+ CHANGELOG_URL,
+ promptForInstallChoice,
+ type InstallPromptChoiceValue,
+ type InstallPromptOptions,
+} from './prompt';
+import { refreshUpdateCache } from './refresh';
+import {
+ appendRolloutDecisionLog,
+ decidePassiveUpdateTarget,
+ isRolloutBypassedByExperimentalEnv,
+ resolveUpdateDeviceId,
+ rolloutBucket,
+ rolloutDelayForBucket,
+ type PassiveUpdateDecision,
+} from './rollout';
+import { detectInstallSource } from './source';
+import {
+ NPM_PACKAGE_NAME,
+ type InstallSource,
+ type UpdateDecision,
+ type UpdateInstallState,
+ type UpdateManifest,
+ type UpdatePreflightResult,
+ type UpdateTarget,
+} from './types';
+
+export type { UpdatePreflightResult } from './types';
+
+export interface RunUpdatePreflightOptions {
+ readonly stdout?: { write(chunk: string): boolean };
+ readonly stderr?: { write(chunk: string): boolean };
+ readonly isTTY?: boolean;
+ readonly track?: (event: string, properties?: TelemetryProperties) => void;
+ readonly logger?: UpdateLogger;
+}
+
+const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2;
+const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000;
+const USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS = 1_000;
+
+type UpdateLogger = Pick;
+
+function withCmdSuffix(base: string, platform: NodeJS.Platform): string {
+ return platform === 'win32' ? `${base}.cmd` : base;
+}
+
+function bunCommand(platform: NodeJS.Platform): string {
+ return platform === 'win32' ? 'bun.exe' : 'bun';
+}
+
+export function installCommandFor(
+ source: InstallSource,
+ version: string,
+ platform: NodeJS.Platform,
+): string {
+ switch (source) {
+ case 'npm-global':
+ return `npm install -g ${NPM_PACKAGE_NAME}@${version}`;
+ case 'pnpm-global':
+ return `pnpm add -g ${NPM_PACKAGE_NAME}@${version}`;
+ case 'yarn-global':
+ return `yarn global add ${NPM_PACKAGE_NAME}@${version}`;
+ case 'bun-global':
+ return `bun add -g ${NPM_PACKAGE_NAME}@${version}`;
+ case 'homebrew':
+ return 'brew upgrade kimi-code';
+ case 'native':
+ return platform === 'win32' ? nativeInstallCommandWin() : nativeInstallCommandUnix();
+ case 'unsupported':
+ return `npm install -g ${NPM_PACKAGE_NAME}@${version}`;
+ }
+}
+
+export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform): boolean {
+ switch (source) {
+ case 'npm-global':
+ case 'pnpm-global':
+ case 'yarn-global':
+ case 'bun-global':
+ return true;
+ case 'homebrew':
+ // Homebrew upgrade may mutate other dependents and the formula can lag
+ // behind the CDN release — prompt the user to run `brew upgrade` manually.
+ return false;
+ case 'native':
+ // Staged-swap self update works on every platform (win32 included).
+ return true;
+ case 'unsupported':
+ return false;
+ }
+}
+
+interface SpawnCommand {
+ readonly cmd: string;
+ readonly args: readonly string[];
+}
+
+export function spawnForSource(
+ source: InstallSource,
+ version: string,
+ platform: NodeJS.Platform,
+): SpawnCommand {
+ switch (source) {
+ case 'npm-global':
+ return { cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
+ case 'pnpm-global':
+ return { cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
+ case 'yarn-global':
+ return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] };
+ case 'bun-global':
+ return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
+ case 'homebrew':
+ return { cmd: 'brew', args: ['upgrade', 'kimi-code'] };
+ case 'native':
+ // Native installs self-spawn the hidden downloader sub-command, which
+ // stages the binary next to the exe (verified against the release
+ // manifest's sha256); the swap happens on the next startup. This
+ // replaces the old `curl|bash` / `irm|iex` re-install dance — no shell,
+ // no pipeline exit-status loss, no PowerShell dependency on Windows.
+ return { cmd: process.execPath, args: ['__update_download', version] };
+ case 'unsupported':
+ throw new Error('unsupported install source cannot be auto-installed');
+ }
+}
+
+function formatErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+/**
+ * Resolve a spawn target from `spawnForSource` to an absolute executable path
+ * via PATH, refusing hits inside the current working directory: the update
+ * preflight runs before the workspace trust gate, so a package-manager binary
+ * planted in an untrusted workspace must never be executed. On win32 the
+ * resolved path is quoted because the spawn goes through cmd.exe (shell:
+ * true) and paths like `C:\Program Files\...` would otherwise split. Returns
+ * undefined when the command cannot be safely resolved.
+ */
+function resolveSpawnCommand(cmd: string, platform: NodeJS.Platform): string | undefined {
+ const resolved = resolveCommandPath(cmd);
+ if (resolved === undefined) return undefined;
+ return platform === 'win32' ? `"${resolved}"` : resolved;
+}
+
+/**
+ * Resolve the spawn target for an install. Package managers are resolved from
+ * `PATH` to an absolute executable via `resolveSpawnCommand` (workspace-trust
+ * safety, see above). The native self-spawn instead uses `process.execPath`
+ * verbatim — already absolute — and never goes through a shell. Returns the
+ * shell flag alongside, since Windows package-manager shims (.cmd) still
+ * need one.
+ */
+function resolveInstallSpawn(
+ source: InstallSource,
+ version: string,
+ platform: NodeJS.Platform,
+ options?: { readonly manual?: boolean },
+): { readonly resolvedCmd: string; readonly args: readonly string[]; readonly shell: boolean } | undefined {
+ const { cmd, args } = spawnForSource(source, version, platform);
+ if (source === 'native') {
+ // A user-confirmed install marks the stage as manual so the startup swap
+ // applies it even when automatic updates are opted out via env.
+ return { resolvedCmd: cmd, args: options?.manual === true ? [...args, '--manual'] : args, shell: false };
+ }
+ const resolvedCmd = resolveSpawnCommand(cmd, platform);
+ if (resolvedCmd === undefined) return undefined;
+ return { resolvedCmd, args, shell: platform === 'win32' };
+}
+
+// Built per call: the official-installer URL follows the current region.
+function thirdPartySourceNote(): string {
+ return (
+ '\nNote: Third-party sources may lag behind the official release.\n' +
+ `For the latest updates, use the official installer: ${kimiCodeOfficialInstallUrl()}\n`
+ );
+}
+
+export function renderManualUpdateMessage(
+ currentVersion: string,
+ target: UpdateTarget,
+ source: InstallSource,
+ installCommand: string,
+): string {
+ let sourceDesc: string;
+ switch (source) {
+ case 'npm-global':
+ case 'pnpm-global':
+ case 'yarn-global':
+ case 'bun-global':
+ sourceDesc = source;
+ break;
+ case 'homebrew':
+ sourceDesc = 'homebrew';
+ break;
+ case 'native':
+ sourceDesc = 'native installer';
+ break;
+ case 'unsupported':
+ sourceDesc = 'unsupported package manager or layout.';
+ break;
+ }
+ return (
+ `A newer version of ${NPM_PACKAGE_NAME} is available ` +
+ `(${currentVersion} -> ${target.version}).\n` +
+ `Detected install source: ${sourceDesc}\n` +
+ `To update manually, run: ${installCommand}\n` +
+ (source === 'homebrew' ? thirdPartySourceNote() : '')
+ );
+}
+
+export function renderInstallSuccessMessage(target: UpdateTarget): string {
+ return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`;
+}
+
+function renderBackgroundInstallSuccessNotice(version: string): string {
+ const displayVersion = version.startsWith('v') ? version : `v${version}`;
+ return `Kimi Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`;
+}
+
+function refreshInBackground(): void {
+ void refreshUpdateCache().catch(() => {});
+}
+
+/** Telemetry properties describing where this device sits in the rollout. */
+interface RolloutTelemetry {
+ readonly rollout_bucket: number;
+ readonly rollout_delay_seconds: number;
+ readonly rollout_from_manifest: boolean;
+ readonly rollout_bypassed: boolean;
+}
+
+function rolloutTelemetryFor(
+ deviceId: string,
+ targetVersion: string,
+ manifest: UpdateManifest | null,
+ bypassRollout: boolean,
+): RolloutTelemetry {
+ const bucket = rolloutBucket(deviceId, targetVersion);
+ return {
+ rollout_bucket: bucket,
+ rollout_delay_seconds:
+ manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket),
+ rollout_from_manifest: manifest !== null,
+ rollout_bypassed: bypassRollout,
+ };
+}
+
+type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh';
+
+/** Record which case a passive version check hit in `updates/rollout.log`. */
+function logRolloutDecision(
+ phase: RolloutCheckPhase,
+ currentVersion: string,
+ latest: string | null,
+ manifest: UpdateManifest | null,
+ decision: PassiveUpdateDecision,
+): void {
+ void appendRolloutDecisionLog({
+ ts: nowIso(),
+ phase,
+ reason: decision.reason,
+ current: currentVersion,
+ latest,
+ target: decision.target?.version ?? null,
+ manifestPresent: manifest !== null,
+ publishedAt: manifest?.publishedAt ?? null,
+ bucket: decision.bucket,
+ delaySeconds: decision.delaySeconds,
+ eligibleAt: decision.eligibleAt,
+ });
+}
+
+function refreshAndMaybeInstallInBackground(
+ currentVersion: string,
+ deviceId: string,
+ bypassRollout: boolean,
+ isInteractive: boolean,
+ installState: UpdateInstallState,
+ platform: NodeJS.Platform,
+ track: RunUpdatePreflightOptions['track'],
+ logger: UpdateLogger,
+): void {
+ void (async () => {
+ const refreshed = await refreshUpdateCache();
+ if (!isInteractive) return;
+ const decision = decidePassiveUpdateTarget(
+ currentVersion,
+ refreshed.latest,
+ refreshed.manifest,
+ deviceId,
+ new Date(),
+ bypassRollout,
+ );
+ logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
+ const target = decision.target;
+ if (target === null) return;
+ const source = await detectInstallSource().catch(() => 'unsupported' as const);
+ await tryStartAutomaticBackgroundInstall(
+ installState,
+ currentVersion,
+ target,
+ source,
+ platform,
+ track,
+ logger,
+ rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout),
+ );
+ })().catch(() => {});
+}
+
+interface UserVisibleUpdateTarget {
+ readonly target: UpdateTarget | null;
+ readonly manifest: UpdateManifest | null;
+}
+
+async function refreshUserVisibleUpdateTarget(
+ currentVersion: string,
+ deviceId: string,
+ bypassRollout: boolean,
+ fallbackTarget: UpdateTarget,
+ fallbackManifest: UpdateManifest | null,
+): Promise {
+ let timeout: ReturnType | undefined;
+ const fallback: UserVisibleUpdateTarget = {
+ target: fallbackTarget,
+ manifest: fallbackManifest,
+ };
+ try {
+ const refresh = refreshUpdateCache()
+ .then((refreshed) => {
+ const decision = decidePassiveUpdateTarget(
+ currentVersion,
+ refreshed.latest,
+ refreshed.manifest,
+ deviceId,
+ new Date(),
+ bypassRollout,
+ );
+ logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
+ return {
+ target: decision.target,
+ manifest: refreshed.manifest,
+ };
+ })
+ .catch(() => fallback);
+ const timeoutFallback = new Promise((resolve) => {
+ timeout = setTimeout(() => {
+ resolve(fallback);
+ }, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS);
+ });
+ return await Promise.race([refresh, timeoutFallback]);
+ } catch {
+ return fallback;
+ } finally {
+ if (timeout !== undefined) {
+ clearTimeout(timeout);
+ }
+ }
+}
+
+function nowIso(): string {
+ return new Date().toISOString();
+}
+
+function failureAttemptsFor(state: UpdateInstallState, target: UpdateTarget): number {
+ return state.lastFailure?.version === target.version ? state.lastFailure.attempts : 0;
+}
+
+function hasFreshActiveInstall(state: UpdateInstallState, target: UpdateTarget): boolean {
+ const active = state.active;
+ if (active === null || active.version !== target.version) return false;
+ const startedAt = Date.parse(active.startedAt);
+ if (!Number.isFinite(startedAt)) return false;
+ return Date.now() - startedAt < AUTO_INSTALL_ACTIVE_TTL_MS;
+}
+
+/**
+ * A fresh-looking `active` record is not proof of work for native installs:
+ * the parent that wrote it may have exited before the spawned downloader's
+ * exit event (or the downloader died before doing anything), and the 6 h TTL
+ * would then silently block every retry. Past the spawn grace window — the
+ * worker needs a moment to self-acquire the lock — lock liveness IS the
+ * truth: held ⇒ a download is running; free ⇒ the record is an orphan and
+ * the caller may start a new attempt. Package-manager sources have no such
+ * liveness signal and keep the TTL behavior above.
+ */
+const NATIVE_INSTALL_SPAWN_GRACE_MS = 60_000;
+
+async function hasNativeInstallInFlight(
+ state: UpdateInstallState,
+ target: UpdateTarget,
+): Promise {
+ const active = state.active;
+ if (active === null || active.version !== target.version) return false;
+ const startedAt = Date.parse(active.startedAt);
+ if (Number.isFinite(startedAt) && Date.now() - startedAt < NATIVE_INSTALL_SPAWN_GRACE_MS) {
+ return true;
+ }
+ const probe = await tryAcquireUpdateInstallLock({ version: target.version });
+ if (probe === null) return true;
+ await probe.release().catch(() => {});
+ return false;
+}
+
+async function hasInstallInFlight(
+ source: InstallSource,
+ state: UpdateInstallState,
+ target: UpdateTarget,
+): Promise {
+ return source === 'native'
+ ? hasNativeInstallInFlight(state, target)
+ : hasFreshActiveInstall(state, target);
+}
+
+async function showPendingBackgroundInstallNotice(
+ state: UpdateInstallState,
+ currentVersion: string,
+ stdout: { write(chunk: string): boolean },
+ track: RunUpdatePreflightOptions['track'],
+ logger: UpdateLogger,
+): Promise {
+ const success = state.lastSuccess;
+ if (success !== null && success.notifiedAt === null && success.version === currentVersion) {
+ stdout.write(renderBackgroundInstallSuccessNotice(success.version));
+ trackUpdateEvent(track, 'update_success_notice_shown', {
+ version: success.version,
+ inferred_from_active: false,
+ });
+ logUpdateInfo(logger, 'background update success notice shown', {
+ version: success.version,
+ inferredFromActive: false,
+ });
+ const nextState: UpdateInstallState = {
+ ...state,
+ active: null,
+ lastFailure: null,
+ lastSuccess: {
+ ...success,
+ notifiedAt: nowIso(),
+ },
+ };
+ await writeUpdateInstallState(nextState).catch(() => {});
+ return nextState;
+ }
+
+ const active = state.active;
+ if (active === null || active.version !== currentVersion) return state;
+ if (success !== null && success.version === currentVersion && success.notifiedAt !== null) {
+ return state;
+ }
+
+ const notifiedAt = nowIso();
+ stdout.write(renderBackgroundInstallSuccessNotice(active.version));
+ trackUpdateEvent(track, 'update_success_notice_shown', {
+ version: active.version,
+ inferred_from_active: true,
+ });
+ logUpdateInfo(logger, 'background update success notice shown', {
+ version: active.version,
+ inferredFromActive: true,
+ });
+ const nextState: UpdateInstallState = {
+ ...state,
+ active: null,
+ lastFailure: null,
+ lastSuccess: {
+ version: active.version,
+ installedAt: notifiedAt,
+ notifiedAt,
+ },
+ };
+ await writeUpdateInstallState(nextState).catch(() => {});
+ return nextState;
+}
+
+/**
+ * `KIMI_CODE_NO_AUTO_UPDATE` (or the legacy `KIMI_CLI_NO_AUTO_UPDATE` alias)
+ * fully disables automatic update behavior — no check, no background install,
+ * no prompt, and no staged-swap at startup (see `native-swap.ts`). Migrated
+ * from kimi-cli, where the variable gated all auto-update behavior. Accepts
+ * the usual truthy values (`1`/`true`/`yes`/`on`).
+ */
+export function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
+ const truthy = (value?: string): boolean =>
+ ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
+ return truthy(env['KIMI_CODE_NO_AUTO_UPDATE']) || truthy(env['KIMI_CLI_NO_AUTO_UPDATE']);
+}
+
+/**
+ * The persisted `[upgrade].auto_install` preference (defaults to true when
+ * the config cannot be read). Gates the passive background install — and the
+ * startup swap of automatically staged payloads (see `native-swap.ts`).
+ */
+export async function shouldAutoInstallUpdates(): Promise {
+ try {
+ const config = await loadTuiConfig();
+ return config.upgrade.autoInstall;
+ } catch {
+ return true;
+ }
+}
+
+function trackUpdatePrompted(
+ track: RunUpdatePreflightOptions['track'],
+ currentVersion: string,
+ target: UpdateTarget,
+ source: InstallSource,
+ decision: UpdateDecision,
+ rolloutTelemetry: RolloutTelemetry,
+): void {
+ trackUpdateEvent(track, 'update_prompted', {
+ current_version: currentVersion,
+ target_version: target.version,
+ source,
+ decision,
+ ...rolloutTelemetry,
+ });
+}
+
+function trackUpdateEvent(
+ track: RunUpdatePreflightOptions['track'],
+ event: string,
+ properties: TelemetryProperties,
+): void {
+ try {
+ track?.(event, properties);
+ } catch {
+ // Telemetry must never affect update prompting.
+ }
+}
+
+function logUpdateInfo(logger: UpdateLogger, message: string, payload: Record): void {
+ try {
+ logger.info(message, payload);
+ } catch {
+ // Diagnostic logging must never affect update prompting.
+ }
+}
+
+function logUpdateWarn(logger: UpdateLogger, message: string, payload: Record): void {
+ try {
+ logger.warn(message, payload);
+ } catch {
+ // Diagnostic logging must never affect update prompting.
+ }
+}
+
+async function promptInstall(
+ currentVersion: string,
+ target: UpdateTarget,
+ source: InstallSource,
+ installCommand: string,
+): Promise {
+ const options: InstallPromptOptions = {
+ currentVersion,
+ target,
+ installSource: source,
+ installCommand,
+ };
+ return promptForInstallChoice(options);
+}
+
+export async function installUpdate(
+ source: InstallSource,
+ version: string,
+ platform: NodeJS.Platform,
+): Promise {
+ // installUpdate only runs after an explicit user choice (the `upgrade`
+ // command or the interactive prompt) — mark the stage as manual.
+ const spawnTarget = resolveInstallSpawn(source, version, platform, { manual: true });
+ if (spawnTarget === undefined) {
+ throw new Error(
+ `${spawnForSource(source, version, platform).cmd} was not found in PATH; cannot install the update`,
+ );
+ }
+ await new Promise((resolve, reject) => {
+ // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the
+ // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without
+ // a shell, so run through the shell on win32. The version is a validated
+ // semver and the package name is a constant, so args are shell-safe. The
+ // native self-spawn is an .exe and needs no shell.
+ const child = spawn(spawnTarget.resolvedCmd, [...spawnTarget.args], {
+ stdio: 'inherit',
+ shell: spawnTarget.shell ? true : undefined,
+ });
+ child.once('error', reject);
+ child.once('exit', (code, signal) => {
+ if (code === 0) {
+ resolve();
+ return;
+ }
+ const detail = signal !== null ? `signal ${signal}` : `code ${String(code)}`;
+ reject(new Error(`update install exited with ${detail}`));
+ });
+ });
+}
+
+async function startBackgroundInstall(
+ state: UpdateInstallState,
+ currentVersion: string,
+ target: UpdateTarget,
+ source: InstallSource,
+ platform: NodeJS.Platform,
+ track: RunUpdatePreflightOptions['track'],
+ logger: UpdateLogger,
+ rolloutTelemetry: RolloutTelemetry,
+): Promise {
+ // The native self-spawned downloader holds the install lock itself for the
+ // whole download — taking it here too would race the child (it starts before
+ // this function's finally releases) into a false success. Package-manager
+ // installs keep the outer lock, which only guards against duplicate spawns.
+ const lock =
+ source === 'native'
+ ? { filePath: '', release: async (): Promise => {} }
+ : await tryAcquireUpdateInstallLock({ version: target.version });
+ if (lock === null) return;
+
+ try {
+ const freshState = await readUpdateInstallState().catch(() => state);
+ if (
+ (await hasInstallInFlight(source, freshState, target)) ||
+ failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD
+ ) {
+ return;
+ }
+
+ const startedState: UpdateInstallState = {
+ ...freshState,
+ active: {
+ version: target.version,
+ source,
+ startedAt: nowIso(),
+ },
+ };
+ await writeUpdateInstallState(startedState);
+ trackUpdateEvent(track, 'update_background_install_started', {
+ current_version: currentVersion,
+ target_version: target.version,
+ source,
+ ...rolloutTelemetry,
+ });
+ logUpdateInfo(logger, 'background update install started', {
+ currentVersion,
+ targetVersion: target.version,
+ source,
+ });
+
+ const spawnTarget = resolveInstallSpawn(source, target.version, platform);
+ let settled = false;
+
+ const finish = (succeeded: boolean): void => {
+ if (settled) return;
+ settled = true;
+ const attempts = failureAttemptsFor(startedState, target) + 1;
+
+ const nextState: UpdateInstallState = succeeded
+ ? {
+ ...startedState,
+ active: null,
+ lastFailure: null,
+ lastSuccess: {
+ version: target.version,
+ installedAt: nowIso(),
+ notifiedAt: null,
+ },
+ }
+ : {
+ ...startedState,
+ active: null,
+ lastFailure: {
+ version: target.version,
+ failedAt: nowIso(),
+ attempts,
+ },
+ };
+ void writeUpdateInstallState(nextState).catch(() => {});
+ if (succeeded) {
+ trackUpdateEvent(track, 'update_background_install_succeeded', {
+ target_version: target.version,
+ source,
+ });
+ logUpdateInfo(logger, 'background update install succeeded', {
+ targetVersion: target.version,
+ source,
+ });
+ return;
+ }
+ trackUpdateEvent(track, 'update_background_install_failed', {
+ target_version: target.version,
+ source,
+ attempts,
+ });
+ logUpdateWarn(logger, 'background update install failed', {
+ targetVersion: target.version,
+ source,
+ attempts,
+ });
+ };
+
+ if (spawnTarget === undefined) {
+ // The package manager cannot be resolved to an absolute path outside
+ // the cwd — record a normal install failure instead of spawning a bare
+ // command name that Windows would resolve into the untrusted workspace.
+ finish(false);
+ return;
+ }
+ const child = spawn(spawnTarget.resolvedCmd, [...spawnTarget.args], {
+ detached: true,
+ stdio: 'ignore',
+ shell: spawnTarget.shell ? true : undefined,
+ // On Windows a detached child gets its own console window; with shell:true
+ // that window would flash during a passive background update. Hide it so
+ // the silent updater stays silent.
+ windowsHide: platform === 'win32' ? true : undefined,
+ });
+ child.once('error', () => { finish(false); });
+ child.once('exit', (code) => { finish(code === 0); });
+ child.unref();
+ } finally {
+ await lock.release().catch(() => {});
+ }
+}
+
+async function tryStartAutomaticBackgroundInstall(
+ installState: UpdateInstallState,
+ currentVersion: string,
+ target: UpdateTarget,
+ source: InstallSource,
+ platform: NodeJS.Platform,
+ track: RunUpdatePreflightOptions['track'],
+ logger: UpdateLogger,
+ rolloutTelemetry: RolloutTelemetry,
+): Promise {
+ const sourceCanAutoInstall = canAutoInstall(source, platform);
+ const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false;
+ if (!autoInstallUpdates || !sourceCanAutoInstall) return false;
+ if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) {
+ return false;
+ }
+ if (!(await hasInstallInFlight(source, installState, target))) {
+ await startBackgroundInstall(
+ installState,
+ currentVersion,
+ target,
+ source,
+ platform,
+ track,
+ logger,
+ rolloutTelemetry,
+ ).catch(() => {});
+ }
+ return true;
+}
+
+export function decideUpdateAction(
+ target: UpdateTarget | null,
+ isInteractive: boolean,
+ source: InstallSource,
+ platform: NodeJS.Platform,
+): UpdateDecision {
+ if (target === null || !isInteractive) return 'none';
+ return canAutoInstall(source, platform) ? 'prompt-install' : 'manual-command';
+}
+
+export async function runUpdatePreflight(
+ currentVersion: string,
+ options: RunUpdatePreflightOptions = {},
+): Promise {
+ const stdout = options.stdout ?? process.stdout;
+ const stderr = options.stderr ?? process.stderr;
+ const logger = options.logger ?? log;
+ const platform = process.platform;
+
+ if (isAutoUpdateDisabledByEnv()) {
+ return 'continue';
+ }
+
+ try {
+ const isInteractive =
+ options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
+ const deviceId = resolveUpdateDeviceId();
+ const bypassRollout = isRolloutBypassedByExperimentalEnv();
+ let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState());
+ if (isInteractive) {
+ installState = await showPendingBackgroundInstallNotice(
+ installState,
+ currentVersion,
+ stdout,
+ options.track,
+ logger,
+ );
+ }
+
+ const cache = await readUpdateCache().catch(() => null);
+ const cachedManifest = cache?.manifest ?? null;
+ const cachedDecision = decidePassiveUpdateTarget(
+ currentVersion,
+ cache?.latest ?? null,
+ cachedManifest,
+ deviceId,
+ new Date(),
+ bypassRollout,
+ );
+ logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision);
+ const target = cachedDecision.target;
+ if (target === null) {
+ refreshAndMaybeInstallInBackground(
+ currentVersion,
+ deviceId,
+ bypassRollout,
+ isInteractive,
+ installState,
+ platform,
+ options.track,
+ logger,
+ );
+ return 'continue';
+ }
+
+ const source: InstallSource =
+ !isInteractive
+ ? 'unsupported'
+ : await detectInstallSource().catch(() => 'unsupported' as const);
+
+ const decision = decideUpdateAction(target, isInteractive, source, platform);
+ if (decision === 'none') {
+ refreshInBackground();
+ return 'continue';
+ }
+
+ if (
+ await tryStartAutomaticBackgroundInstall(
+ installState,
+ currentVersion,
+ target,
+ source,
+ platform,
+ options.track,
+ logger,
+ rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout),
+ )
+ ) {
+ refreshInBackground();
+ return 'continue';
+ }
+
+ const userVisibleUpdate = await refreshUserVisibleUpdateTarget(
+ currentVersion,
+ deviceId,
+ bypassRollout,
+ target,
+ cachedManifest,
+ );
+ const userVisibleTarget = userVisibleUpdate.target;
+ if (userVisibleTarget === null) return 'continue';
+ const userVisibleRollout = rolloutTelemetryFor(
+ deviceId,
+ userVisibleTarget.version,
+ userVisibleUpdate.manifest,
+ bypassRollout,
+ );
+ if (
+ await tryStartAutomaticBackgroundInstall(
+ installState,
+ currentVersion,
+ userVisibleTarget,
+ source,
+ platform,
+ options.track,
+ logger,
+ userVisibleRollout,
+ )
+ ) {
+ return 'continue';
+ }
+
+ const installCommand = installCommandFor(source, userVisibleTarget.version, platform);
+ trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout);
+
+ if (decision === 'manual-command') {
+ stdout.write(renderManualUpdateMessage(
+ currentVersion,
+ userVisibleTarget,
+ source,
+ installCommand,
+ ));
+ return 'continue';
+ }
+
+ const choice = await promptInstall(currentVersion, userVisibleTarget, source, installCommand);
+ if (choice === 'skip') return 'continue';
+
+ try {
+ await installUpdate(source, userVisibleTarget.version, platform);
+ stdout.write(renderInstallSuccessMessage(userVisibleTarget));
+ return 'exit';
+ } catch (error) {
+ stderr.write(
+ `warning: failed to install ${NPM_PACKAGE_NAME}@${userVisibleTarget.version}: ` +
+ `${formatErrorMessage(error)}\n`,
+ );
+ return 'continue';
+ }
+ } catch {
+ return 'continue';
+ }
+}
diff --git a/apps/kimi-code/src/cli/update/prompt.ts b/apps/kimi-code/src/cli/update/prompt.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3b48ad07229183f4367eb656c762d78a3f948841
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/prompt.ts
@@ -0,0 +1,188 @@
+import { clearLine, cursorTo, emitKeypressEvents, moveCursor } from 'node:readline';
+
+import chalk from 'chalk';
+
+import { PRODUCT_NAME } from '#/constant/app';
+import { HIDE_CURSOR, SHOW_CURSOR } from '#/constant/terminal';
+import {
+ UPDATE_PROMPT_MUTED,
+ UPDATE_PROMPT_PRIMARY,
+ UPDATE_PROMPT_SUCCESS,
+ UPDATE_PROMPT_TEXT_DIM,
+ UPDATE_PROMPT_WARNING,
+} from '#/constant/update';
+
+import { type InstallSource, type UpdateTarget } from './types';
+
+export const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html';
+
+export type InstallPromptChoiceValue = 'install' | 'skip';
+
+export interface InstallPromptChoice {
+ readonly value: InstallPromptChoiceValue;
+ readonly label: string;
+}
+
+export interface InstallPromptOptions {
+ readonly currentVersion: string;
+ readonly target: UpdateTarget;
+ readonly installCommand: string;
+ readonly installSource: InstallSource;
+ readonly input?: NodeJS.ReadStream;
+ readonly output?: NodeJS.WriteStream;
+}
+
+const INSTALL_HINT = 'Install update now';
+const SKIP_HINT = 'Continue with current version';
+
+export function createInstallPromptChoices(target: UpdateTarget): readonly InstallPromptChoice[] {
+ return [
+ { value: 'install', label: `${INSTALL_HINT} (${target.version})` },
+ { value: 'skip', label: SKIP_HINT },
+ ];
+}
+
+export function getDefaultInstallPromptSelection(choices: readonly InstallPromptChoice[]): number {
+ const installIndex = choices.findIndex((choice) => choice.value === 'install');
+ return Math.max(installIndex, 0);
+}
+
+export function moveInstallPromptSelection(
+ currentIndex: number,
+ direction: 'up' | 'down',
+ choiceCount: number,
+): number {
+ if (direction === 'up') {
+ return Math.max(0, currentIndex - 1);
+ }
+ return Math.min(choiceCount - 1, currentIndex + 1);
+}
+
+function renderInstallPrompt(
+ options: InstallPromptOptions,
+ choices: readonly InstallPromptChoice[],
+ selectedIndex: number,
+): readonly string[] {
+ const label = chalk.hex(UPDATE_PROMPT_TEXT_DIM).bold;
+ const currentVersion = chalk.hex(UPDATE_PROMPT_WARNING).bold(options.currentVersion);
+ const targetVersion = chalk.hex(UPDATE_PROMPT_SUCCESS).bold(options.target.version);
+ const sourceLabel = chalk.hex(UPDATE_PROMPT_PRIMARY).bold(options.installSource);
+ const command = chalk.hex(UPDATE_PROMPT_PRIMARY)(options.installCommand);
+ const changelogText = chalk.hex(UPDATE_PROMPT_PRIMARY).underline(`View changelog: ${CHANGELOG_URL}`);
+ const lines = [
+ chalk.hex(UPDATE_PROMPT_PRIMARY).bold('Kimi Code Update Available'),
+ chalk.hex(UPDATE_PROMPT_MUTED)(`${PRODUCT_NAME} has a newer release ready.`),
+ `]8;;${CHANGELOG_URL}\\${changelogText}]8;;\\`,
+ '',
+ `${label('Current')} ${currentVersion}`,
+ `${label('Target ')} ${targetVersion}`,
+ `${label('Source ')} ${sourceLabel}`,
+ `${label('Command')} ${command}`,
+ '',
+ chalk.hex(UPDATE_PROMPT_MUTED)('↑↓ choose · Enter confirm · Esc continue'),
+ '',
+ ];
+
+ for (let i = 0; i < choices.length; i++) {
+ const choice = choices[i];
+ if (choice === undefined) continue;
+ const isSelected = i === selectedIndex;
+ const pointer = isSelected ? '❯' : ' ';
+ const content = ` ${pointer} ${choice.label}`;
+ if (isSelected) {
+ lines.push(chalk.hex(UPDATE_PROMPT_PRIMARY).bold(content));
+ continue;
+ }
+ lines.push(chalk.hex(UPDATE_PROMPT_TEXT_DIM)(content));
+ }
+
+ return lines;
+}
+
+function writePromptFrame(
+ output: NodeJS.WriteStream,
+ lines: readonly string[],
+ previousLineCount: number,
+): number {
+ if (previousLineCount > 0) {
+ moveCursor(output, 0, -(previousLineCount - 1));
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ clearLine(output, 0);
+ cursorTo(output, 0);
+ output.write(lines[i] ?? '');
+ if (i < lines.length - 1) {
+ output.write('\n');
+ }
+ }
+
+ return lines.length;
+}
+
+export async function promptForInstallChoice(
+ options: InstallPromptOptions,
+): Promise {
+ const input = options.input ?? process.stdin;
+ const output = options.output ?? process.stdout;
+ const choices = createInstallPromptChoices(options.target);
+ let selectedIndex = getDefaultInstallPromptSelection(choices);
+
+ return new Promise((resolve) => {
+ let lineCount = 0;
+ const hadRawMode = 'isRaw' in input ? input.isRaw : false;
+ const canSetRawMode = typeof input.setRawMode === 'function';
+
+ const cleanup = (): void => {
+ input.off('keypress', onKeypress);
+ if (canSetRawMode) {
+ input.setRawMode(hadRawMode);
+ }
+ output.write(SHOW_CURSOR);
+ output.write('\n');
+ };
+
+ const finish = (choice: InstallPromptChoiceValue): void => {
+ cleanup();
+ resolve(choice);
+ };
+
+ const render = (): void => {
+ lineCount = writePromptFrame(
+ output,
+ renderInstallPrompt(options, choices, selectedIndex),
+ lineCount,
+ );
+ };
+
+ const onKeypress = (_input: string, key: { name?: string; ctrl?: boolean }): void => {
+ if (key.name === 'up') {
+ selectedIndex = moveInstallPromptSelection(selectedIndex, 'up', choices.length);
+ render();
+ return;
+ }
+ if (key.name === 'down') {
+ selectedIndex = moveInstallPromptSelection(selectedIndex, 'down', choices.length);
+ render();
+ return;
+ }
+ if (key.name === 'return' || key.name === 'enter') {
+ const chosen = choices[selectedIndex]?.value ?? 'skip';
+ finish(chosen);
+ return;
+ }
+ if (key.name === 'escape' || (key.ctrl === true && key.name === 'c')) {
+ finish('skip');
+ }
+ };
+
+ emitKeypressEvents(input);
+ if (canSetRawMode) {
+ input.setRawMode(true);
+ }
+ input.resume();
+ input.on('keypress', onKeypress);
+ output.write(HIDE_CURSOR);
+ render();
+ });
+}
diff --git a/apps/kimi-code/src/cli/update/refresh.ts b/apps/kimi-code/src/cli/update/refresh.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a9ec67cecbfb74b7311275bfbb68d74900ed0ff9
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/refresh.ts
@@ -0,0 +1,36 @@
+import { writeUpdateCache } from './cache';
+import { fetchLatestFromCdn, type FetchLatestResult } from './cdn';
+import { type UpdateCache } from './types';
+
+export interface RefreshUpdateCacheDeps {
+ /** Resolves with the latest version + rollout manifest. **Throws** on any
+ * failure — callers (including the default background invocation in
+ * preflight) must catch. Errors intentionally skip `writeCache` so a
+ * transient CDN blip does not overwrite a previously known `latest` with
+ * `null`. */
+ readonly fetchLatest: () => Promise;
+ readonly writeCache: (cache: UpdateCache) => Promise;
+ readonly now: () => Date;
+ readonly timeoutMs?: number;
+}
+
+export async function refreshUpdateCache(
+ overrides: Partial = {},
+): Promise {
+ const resolved: RefreshUpdateCacheDeps = {
+ fetchLatest:
+ overrides.fetchLatest ?? (() => fetchLatestFromCdn(undefined, overrides.timeoutMs)),
+ writeCache: overrides.writeCache ?? writeUpdateCache,
+ now: overrides.now ?? (() => new Date()),
+ };
+
+ const { latest, manifest } = await resolved.fetchLatest();
+ const cache: UpdateCache = {
+ source: 'cdn',
+ checkedAt: resolved.now().toISOString(),
+ latest,
+ manifest,
+ };
+ await resolved.writeCache(cache);
+ return cache;
+}
diff --git a/apps/kimi-code/src/cli/update/rollout.ts b/apps/kimi-code/src/cli/update/rollout.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a4ca5025659dc19939216b116bdfa7e8440955c6
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/rollout.ts
@@ -0,0 +1,210 @@
+import { createHash, randomUUID } from 'node:crypto';
+import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises';
+import { dirname } from 'node:path';
+
+import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
+import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk';
+
+import { getUpdateRolloutLogFile } from '#/utils/paths';
+
+import { selectUpdateTarget } from './select';
+import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types';
+
+/**
+ * Hard ceiling for any rollout delay. Combined with the uncovered-bucket
+ * fallback below, it guarantees every device sees a release no later than
+ * `publishedAt + 24h`, no matter what the published plan says.
+ */
+export const MAX_ROLLOUT_DELAY_SECONDS = 86_400;
+
+/**
+ * Deterministic 0-99 bucket for a device. The version is mixed into the hash
+ * so each release reshuffles which devices land in the early batches.
+ */
+export function rolloutBucket(deviceId: string, version: string): number {
+ const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest();
+ return digest.readUInt32BE(0) % 100;
+}
+
+/**
+ * Delay assigned to a bucket by the published plan. Batches claim bucket
+ * ranges in array order; buckets left uncovered (percents summing under 100)
+ * fall into the slowest cohort, and oversized delays are clamped to 24h.
+ */
+export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number {
+ let cumulative = 0;
+ for (const batch of rollout) {
+ cumulative += batch.percent;
+ if (bucket < cumulative) {
+ return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS);
+ }
+ }
+ if (rollout.length === 0) return 0;
+ return MAX_ROLLOUT_DELAY_SECONDS;
+}
+
+export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number {
+ return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version));
+}
+
+export function isRolloutEligible(
+ manifest: UpdateManifest,
+ deviceId: string,
+ now: Date,
+): boolean {
+ const publishedAt = Date.parse(manifest.publishedAt);
+ // Schema validation rejects unparseable timestamps before they get here;
+ // fail open defensively so a defect can never block updates indefinitely.
+ if (!Number.isFinite(publishedAt)) return true;
+ const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000;
+ return now.getTime() >= publishedAt + delayMs;
+}
+
+/** Which case a passive update check hit; written to the rollout log. */
+export type PassiveUpdateReason =
+ /** Nothing known yet (no cache / CDN never reached). */
+ | 'no-latest'
+ /** Known version is not an upgrade over the running one. */
+ | 'not-newer'
+ /** Plain-text fallback or legacy cache: visible immediately, no gating. */
+ | 'no-manifest'
+ /** Gated: this device's batch delay has not elapsed yet. */
+ | 'held'
+ /** Gated and the batch delay has elapsed: update is visible. */
+ | 'eligible'
+ /** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */
+ | 'experimental';
+
+export interface PassiveUpdateDecision {
+ readonly target: UpdateTarget | null;
+ readonly reason: PassiveUpdateReason;
+ readonly bucket: number | null;
+ readonly delaySeconds: number | null;
+ readonly eligibleAt: string | null;
+}
+
+/**
+ * Update decision for the passive surfaces (background install, startup
+ * prompt, manual-command notice). Devices whose batch is not yet eligible see
+ * no update at all. A null manifest (plain-text fallback or legacy cache)
+ * keeps the pre-rollout behavior: the latest version is visible immediately.
+ *
+ * `kimi upgrade` must NOT go through this gate — it selects directly from the
+ * raw latest version.
+ */
+export function decidePassiveUpdateTarget(
+ currentVersion: string,
+ latest: string | null,
+ manifest: UpdateManifest | null,
+ deviceId: string,
+ now: Date,
+ bypassRollout = false,
+): PassiveUpdateDecision {
+ if (bypassRollout) {
+ if (latest === null) {
+ return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
+ }
+ const target = selectUpdateTarget(currentVersion, latest);
+ return {
+ target,
+ reason: target === null ? 'not-newer' : 'experimental',
+ bucket: null,
+ delaySeconds: null,
+ eligibleAt: null,
+ };
+ }
+
+ if (manifest === null) {
+ if (latest === null) {
+ return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
+ }
+ const target = selectUpdateTarget(currentVersion, latest);
+ return {
+ target,
+ reason: target === null ? 'not-newer' : 'no-manifest',
+ bucket: null,
+ delaySeconds: null,
+ eligibleAt: null,
+ };
+ }
+
+ const target = selectUpdateTarget(currentVersion, manifest.version);
+ if (target === null) {
+ return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null };
+ }
+
+ const bucket = rolloutBucket(deviceId, manifest.version);
+ const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket);
+ const publishedAt = Date.parse(manifest.publishedAt);
+ const eligibleAt = Number.isFinite(publishedAt)
+ ? new Date(publishedAt + delaySeconds * 1000).toISOString()
+ : null;
+ const eligible = isRolloutEligible(manifest, deviceId, now);
+ return {
+ target: eligible ? target : null,
+ reason: eligible ? 'eligible' : 'held',
+ bucket,
+ delaySeconds,
+ eligibleAt,
+ };
+}
+
+export function selectPassiveUpdateTarget(
+ currentVersion: string,
+ latest: string | null,
+ manifest: UpdateManifest | null,
+ deviceId: string,
+ now: Date,
+): UpdateTarget | null {
+ return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target;
+}
+
+const ROLLOUT_LOG_MAX_BYTES = 256 * 1024;
+
+/**
+ * Append one JSON line describing a passive update decision to
+ * `/updates/rollout.log`. Best-effort diagnostics: any I/O failure
+ * is swallowed — logging must never affect update prompting. The file is
+ * reset once it grows past a small cap so it cannot grow unbounded.
+ */
+export async function appendRolloutDecisionLog(
+ entry: Record,
+ filePath: string = getUpdateRolloutLogFile(),
+): Promise {
+ try {
+ await mkdir(dirname(filePath), { recursive: true });
+ const line = `${JSON.stringify(entry)}\n`;
+ const size = await stat(filePath).then((s) => s.size, () => 0);
+ if (size > ROLLOUT_LOG_MAX_BYTES) {
+ await writeFile(filePath, line, 'utf-8');
+ return;
+ }
+ await appendFile(filePath, line, 'utf-8');
+ } catch {
+ // Diagnostic logging must never affect the update flow.
+ }
+}
+
+/**
+ * Stable per-installation id used for bucketing when telemetry has already
+ * minted one. Missing ids stay ephemeral here so update preflight never
+ * creates the telemetry device_id before telemetry can emit first_launch.
+ */
+export function resolveUpdateDeviceId(): string {
+ return readKimiDeviceId(resolveKimiHome()) ?? randomUUID();
+}
+
+/**
+ * The experimental master switch opts a device out of staged rollouts: the
+ * newest version is always visible to the passive update surfaces, exactly as
+ * if every release were fully rolled out. Read directly from the env (same
+ * truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) — the update preflight runs
+ * before the harness exists, so the core flag registry is not consulted.
+ * `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in.
+ */
+export function isRolloutBypassedByExperimentalEnv(
+ env: Readonly> = process.env,
+): boolean {
+ const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase();
+ return ['1', 'true', 'yes', 'on'].includes(value);
+}
diff --git a/apps/kimi-code/src/cli/update/select.ts b/apps/kimi-code/src/cli/update/select.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bf241ed809246f2a9ae5b9c8f382aa346504fecc
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/select.ts
@@ -0,0 +1,13 @@
+import { gt, valid } from 'semver';
+
+import { type UpdateTarget } from './types';
+
+export function selectUpdateTarget(
+ currentVersion: string,
+ latest: string | null,
+): UpdateTarget | null {
+ if (latest === null) return null;
+ if (valid(currentVersion) === null || valid(latest) === null) return null;
+ if (!gt(latest, currentVersion)) return null;
+ return { version: latest };
+}
diff --git a/apps/kimi-code/src/cli/update/source.ts b/apps/kimi-code/src/cli/update/source.ts
new file mode 100644
index 0000000000000000000000000000000000000000..464e3231864ee389c878653a8355ed4d9a48d93c
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/source.ts
@@ -0,0 +1,172 @@
+import { execFile } from 'node:child_process';
+import { realpathSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { join, resolve } from 'node:path';
+
+import { getHostPackageRoot } from '#/cli/version';
+import { resolveCommandPath } from '#/utils/process/resolve-command';
+
+import { NPM_PACKAGE_NAME, type InstallSource } from './types';
+
+const nodeRequire = createRequire(import.meta.url);
+
+interface NodeSeaModule {
+ isSea(): boolean;
+}
+
+let cachedSea: NodeSeaModule | null | undefined;
+
+function loadSeaModule(): NodeSeaModule | null {
+ if (cachedSea !== undefined) return cachedSea;
+ try {
+ cachedSea = nodeRequire('node:sea') as NodeSeaModule;
+ } catch {
+ cachedSea = null;
+ }
+ return cachedSea;
+}
+
+/** Runtime SEA detection — true when running as a packaged native binary. */
+export function detectNativeInstall(): boolean {
+ const sea = loadSeaModule();
+ if (sea === null) return false;
+ try {
+ return sea.isSea();
+ } catch {
+ return false;
+ }
+}
+
+// Path heuristic markers (compared in lowercase; both forward and backward slashes accepted).
+const PNPM_PATH_SEGMENT = 'pnpm/global/';
+const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/'];
+const BUN_PATH_SEGMENT = '.bun/install/global/';
+// Homebrew installs formulae under its Cellar directory. Avoid matching the
+// broader /homebrew/ prefix — on Apple Silicon, npm itself lives under
+// /opt/homebrew/, so `npm install -g` paths also contain /homebrew/.
+const HOMEBREW_PATH_SEGMENT = '/cellar/';
+
+function normalizeForHeuristic(filePath: string): string {
+ return filePath.replaceAll('\\', '/').toLowerCase();
+}
+
+/**
+ * Heuristic classification by package root path segments. Returns the
+ * matching `InstallSource` or `null` if no heuristic matches (caller should
+ * fall through to npm-prefix comparison).
+ */
+export function classifyByPathHeuristic(packageRoot: string): InstallSource | null {
+ const normalized = normalizeForHeuristic(packageRoot);
+ if (normalized.includes(PNPM_PATH_SEGMENT)) return 'pnpm-global';
+ for (const seg of YARN_PATH_SEGMENTS) {
+ if (normalized.includes(seg)) return 'yarn-global';
+ }
+ if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global';
+ if (normalized.includes(HOMEBREW_PATH_SEGMENT)) return 'homebrew';
+ return null;
+}
+
+export interface DetectInstallSourceDeps {
+ readonly getPackageRoot: () => string;
+ readonly getGlobalPrefix: () => Promise;
+ readonly detectNative: () => boolean;
+ readonly platform: NodeJS.Platform;
+}
+
+function npmCommand(platform: NodeJS.Platform): string {
+ return platform === 'win32' ? 'npm.cmd' : 'npm';
+}
+
+// The install-source detection runs before the workspace trust gate, so the
+// npm binary must be resolved through PATH to an absolute path — a bare name
+// would let cmd.exe pick up an `npm.cmd` planted in the current directory.
+function npmGlobalPrefix(platform: NodeJS.Platform): Promise {
+ const resolved = resolveCommandPath(npmCommand(platform));
+ if (resolved === undefined) {
+ return Promise.reject(new Error('npm was not found in PATH'));
+ }
+ return execFileText(resolved, ['prefix', '-g']).then((text) => text.trim());
+}
+
+function execFileText(command: string, args: readonly string[]): Promise {
+ return new Promise((resolveOutput, reject) => {
+ execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolveOutput(stdout);
+ });
+ });
+}
+
+function normalizePathForComparison(filePath: string, platform: NodeJS.Platform): string | null {
+ const trimmed = filePath.trim();
+ if (trimmed.length === 0) return null;
+ try {
+ return normalizeResolvedPath(realpathSync(trimmed), platform);
+ } catch {
+ return normalizeResolvedPath(resolve(trimmed), platform);
+ }
+}
+
+function normalizeResolvedPath(filePath: string, platform: NodeJS.Platform): string {
+ const resolvedPath = resolve(filePath);
+ return platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
+}
+
+function candidateGlobalPackageDirs(
+ globalPrefix: string,
+ platform: NodeJS.Platform,
+): readonly string[] {
+ if (platform === 'win32') {
+ return [join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME)];
+ }
+ return [
+ join(globalPrefix, 'lib', 'node_modules', NPM_PACKAGE_NAME),
+ join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME),
+ ];
+}
+
+export function classifyInstallSource(
+ packageRoot: string,
+ globalPrefix: string,
+ platform: NodeJS.Platform = process.platform,
+): InstallSource {
+ const normalizedPackageRoot = normalizePathForComparison(packageRoot, platform);
+ if (normalizedPackageRoot === null) return 'unsupported';
+
+ for (const candidate of candidateGlobalPackageDirs(globalPrefix, platform)) {
+ if (normalizePathForComparison(candidate, platform) === normalizedPackageRoot) {
+ return 'npm-global';
+ }
+ }
+ return 'unsupported';
+}
+
+export async function detectInstallSource(
+ deps: Partial = {},
+): Promise {
+ const platform = deps.platform ?? process.platform;
+ const resolved: DetectInstallSourceDeps = {
+ getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot,
+ getGlobalPrefix:
+ deps.getGlobalPrefix ??
+ (() => npmGlobalPrefix(platform)),
+ detectNative: deps.detectNative ?? detectNativeInstall,
+ platform,
+ };
+
+ if (resolved.detectNative()) return 'native';
+
+ const packageRoot = resolved.getPackageRoot();
+ const heuristic = classifyByPathHeuristic(packageRoot);
+ if (heuristic !== null) return heuristic;
+
+ try {
+ const globalPrefix = await resolved.getGlobalPrefix();
+ return classifyInstallSource(packageRoot, globalPrefix, resolved.platform);
+ } catch {
+ return 'unsupported';
+ }
+}
diff --git a/apps/kimi-code/src/cli/update/types.ts b/apps/kimi-code/src/cli/update/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b03af767d175fd56a0f8956d0f04fedbfae75af3
--- /dev/null
+++ b/apps/kimi-code/src/cli/update/types.ts
@@ -0,0 +1,85 @@
+import { NPM_PACKAGE_NAME } from '#/constant/app';
+
+export { NPM_PACKAGE_NAME };
+
+/** Where the running CLI was installed from. Drives update command + spawn. */
+export type InstallSource =
+ | 'npm-global'
+ | 'pnpm-global'
+ | 'yarn-global'
+ | 'bun-global'
+ | 'homebrew'
+ | 'native'
+ | 'unsupported';
+
+export interface UpdateTarget {
+ readonly version: string;
+}
+
+/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */
+export interface RolloutBatch {
+ readonly percent: number;
+ readonly delaySeconds: number;
+}
+
+/**
+ * Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array
+ * order; an empty array means the release is fully rolled out immediately.
+ */
+export interface UpdateManifest {
+ readonly version: string;
+ readonly publishedAt: string;
+ readonly rollout: readonly RolloutBatch[];
+}
+
+export interface UpdateCache {
+ readonly source: 'cdn';
+ readonly checkedAt: string | null;
+ readonly latest: string | null;
+ /** Null when the manifest came from the plain-text fallback or a legacy cache file. */
+ readonly manifest: UpdateManifest | null;
+}
+
+export interface UpdateInstallActive {
+ readonly version: string;
+ readonly source: InstallSource;
+ readonly startedAt: string;
+}
+
+export interface UpdateInstallFailure {
+ readonly version: string;
+ readonly failedAt: string;
+ readonly attempts: number;
+}
+
+export interface UpdateInstallSuccess {
+ readonly version: string;
+ readonly installedAt: string;
+ readonly notifiedAt: string | null;
+}
+
+export interface UpdateInstallState {
+ readonly active: UpdateInstallActive | null;
+ readonly lastFailure: UpdateInstallFailure | null;
+ readonly lastSuccess: UpdateInstallSuccess | null;
+}
+
+export type UpdateDecision = 'none' | 'prompt-install' | 'manual-command';
+export type UpdatePreflightResult = 'continue' | 'exit';
+
+export function emptyUpdateCache(): UpdateCache {
+ return {
+ source: 'cdn',
+ checkedAt: null,
+ latest: null,
+ manifest: null,
+ };
+}
+
+export function emptyUpdateInstallState(): UpdateInstallState {
+ return {
+ active: null,
+ lastFailure: null,
+ lastSuccess: null,
+ };
+}
diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3e0e6755a9fdc1015dcb2d30cc1e7fcbe3c29108
--- /dev/null
+++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts
@@ -0,0 +1,1114 @@
+/**
+ * Native v2 `kimi -p` (print mode) runner.
+ *
+ * Unlike the v1 path (and the former `V2PromptHarness` / `V2Session` shim), this
+ * runner talks to agent-core-v2's native DI services directly — no
+ * `PromptHarness`, no SDK-shaped session, no v2→v1 event translation. It:
+ * - `bootstrap()`s the app scope,
+ * - creates / resumes a session and its main agent via native services,
+ * - subscribes to the main agent's per-agent `IEventBus` and renders the
+ * native `Event2` stream (payloads are already v1-protocol-shaped),
+ * - drives a turn through `IAgentLoopService.enqueuePrompt()` and awaits
+ * `Turn.result` for authoritative completion,
+ * - applies the print-mode background policy (config-driven, v1-aligned:
+ * `exit` / `drain` / `steer`) before exiting.
+ */
+
+import { readFile } from 'node:fs/promises';
+
+import {
+ IAgentCronService,
+ IAgentGoalService,
+ IAgentLifecycleService,
+ IAgentLoopService,
+ IAgentPermissionModeService,
+ IAgentProfileService,
+ IAgentTaskService,
+ IAuthSummaryService,
+ IBootstrapService,
+ IConfigService,
+ IEventBus,
+ IEventDispatcher,
+ IHostFileSystem,
+ IOAuthToolkit,
+ ISessionIndex,
+ ISessionManager,
+ ITelemetryService,
+ IWorkspaceInstanceManager,
+ PRINT_MAX_TURNS_DEFAULT,
+ PRINT_WAIT_CEILING_S_DEFAULT,
+ applyPrintModeConfigDefaults,
+ bootstrap,
+ createCloudAppender,
+ ensureMainAgent,
+ resumeSessionById,
+ logSeed,
+ parseAgentFileText,
+ resolveAgentPath,
+ resolveAgentTaskConfig,
+ resolveKimiHome,
+ resolveLoggingConfig,
+ resolvePrintBackgroundMode,
+ setClampedTimeout,
+ type Event2,
+ type IAgentScopeHandle,
+ type ISessionScopeHandle,
+ type LoopRunResult,
+ type McpServerConfig,
+ type PrintBackgroundMode,
+ type Scope,
+} from '@moonshot-ai/agent-core-v2';
+import {
+ loadMcpServersDetailed,
+ resolveMcpJsonPaths,
+} from '@moonshot-ai/agent-core-v2/app/mcpConfig/configLoader';
+import {
+ createKimiDefaultHeaders,
+ createKimiDeviceId,
+ KIMI_CODE_PROVIDER_NAME,
+} from '@moonshot-ai/kimi-code-oauth';
+import {
+ initializeTelemetry,
+ setCrashPhase,
+ setTelemetryContext,
+ setTelemetryModel,
+ shouldEnableTelemetry,
+ shutdownTelemetry,
+} from '@moonshot-ai/kimi-telemetry';
+import type { GoalUpdated } from '@moonshot-ai/agent-core-v2/features/goal/goalOps';
+import type { TurnEnded } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps';
+import type {
+ AssistantDelta,
+ ThinkingDelta,
+ ToolCallDelta,
+} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents';
+import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents';
+import type { HookResult } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService';
+import type {
+ ToolCallStarted,
+ ToolProgress,
+ ToolResultEvent,
+} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents';
+import { resolve } from 'pathe';
+
+import {
+ CLI_SHUTDOWN_TIMEOUT_MS,
+ CLI_USER_AGENT_PRODUCT,
+ PROMPT_CLEANUP_TIMEOUT_MS,
+} from '#/constant/app';
+import { currentKimiProfile } from '#/utils/region';
+
+import {
+ formatGoalSummaryText,
+ goalExitCode,
+ goalSummaryJson,
+ parseHeadlessGoalCreate,
+ type HeadlessGoalCreate,
+} from '../goal-prompt';
+import {
+ type PromptRunIO,
+ configuredModel,
+ installPromptTerminationCleanup,
+ raceWithTimeout,
+ requireConfiguredModel,
+} from '../run-prompt';
+import { createKimiCodeHostIdentity } from '../version';
+
+import { resolveOutputFormat } from '../options';
+import type { CLIOptions, PromptOutputFormat } from '../options';
+import {
+ type PromptOutput,
+ PromptJsonWriter,
+ type PromptTurnWriter,
+ PromptTranscriptWriter,
+ writeExperimentalVersion,
+ writeResumeHint,
+} from '../prompt-render';
+
+const PROMPT_UI_MODE = 'print';
+/** Re-check `goalActive` at least this often while waiting for goal turns. */
+const GOAL_WAIT_POLL_MS = 250;
+/** Re-check each agent's prompt queue while waiting for it to drain at exit. */
+const PROMPT_QUIESCE_POLL_MS = 10;
+/**
+ * Slack on top of a scheduled cron fire time while waiting for the steered
+ * turn: covers the 1s tick poll interval plus fire → inject → turn-launch
+ * latency.
+ */
+const CRON_FIRE_GRACE_MS = 5_000;
+
+export async function runV2Print(
+ opts: CLIOptions,
+ version: string,
+ io: PromptRunIO = {},
+): Promise {
+ const startedAt = Date.now();
+ const stdout = io.stdout ?? process.stdout;
+ const stderr = io.stderr ?? process.stderr;
+ const promptProcess = io.process ?? process;
+ const outputFormat = resolveOutputFormat(opts);
+ const workDir = process.cwd();
+
+ writeExperimentalVersion(version, outputFormat, stdout, stderr);
+
+ const homeDir = resolveKimiHome();
+ let firstLaunch = false;
+ const deviceId = createKimiDeviceId(homeDir, {
+ onFirstLaunch: () => {
+ firstLaunch = true;
+ },
+ });
+ const logging = resolveLoggingConfig({ homeDir, env: process.env });
+ const identity = createKimiCodeHostIdentity(version);
+ const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
+
+ const { app } = bootstrap(
+ {
+ homeDir,
+ clientIdentity: identity,
+ args: {
+ requestHeaders: hostHeaders,
+ nonInteractive: true,
+ // `--skillsDir` (v1 print parity): explicit skill dirs replace default
+ // user / project discovery for this process.
+ skillDirs: opts.skillsDirs,
+ // `--agent-file`: explicit agent definition files, registered with the
+ // highest-precedence source for this process. Passed through unresolved —
+ // the engine expands `~` and resolves relative paths against the session
+ // workDir (mirroring `--skills-dir`).
+ agentFiles: opts.agentFiles,
+ },
+ },
+ [...logSeed(logging)],
+ );
+ const auth = app.accessor.get(IOAuthToolkit);
+
+ const configService = app.accessor.get(IConfigService);
+ await configService.ready;
+ // Print-mode config defaults (task timeouts / loop step cap / subagent
+ // timeout → unbounded) before anything resolves a session; only keys the
+ // user left unset are filled, in the memory layer.
+ await applyPrintModeConfigDefaults(configService);
+ const defaultModel = configService.get('defaultModel') ?? undefined;
+ let configTelemetryEnabled = true;
+ try {
+ configTelemetryEnabled = configService.get('telemetry') !== false;
+ } catch {
+ configTelemetryEnabled = true;
+ }
+ const telemetryEnabled = shouldEnableTelemetry({ enabled: configTelemetryEnabled });
+ for (const diagnostic of configService.diagnostics()) {
+ if (diagnostic.severity === 'warning') {
+ stderr.write(`Warning: ${diagnostic.message}\n`);
+ }
+ }
+
+ let restorePermission = async (): Promise => {};
+ let quiesceAgents = async (): Promise => {};
+ let releaseQuiescence: (() => void) | undefined;
+ let flushWires = async (): Promise => {};
+ let removeTerminationCleanup: (() => void) | undefined;
+ let cleanupPromise: Promise | undefined;
+ let telemetryService: ITelemetryService | undefined;
+ const cleanup = async (): Promise => {
+ const pending = (cleanupPromise ??= (async () => {
+ removeTerminationCleanup?.();
+ setCrashPhase('shutdown');
+ try {
+ await restorePermission();
+ // A termination signal can arrive mid-turn: cancel turns and wait for idle agents first.
+ await raceWithTimeout(quiesceAgents(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {});
+ } finally {
+ try {
+ // Concurrent so the phases' allowances cannot sum past PROMPT_CLEANUP_TIMEOUT_MS.
+ await Promise.all([
+ // The turn's tail records reach the journal only via the wire's
+ // async persist queue; process.exit must not cut off that queue.
+ raceWithTimeout(flushWires(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {}),
+ telemetryService !== undefined
+ ? raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS)
+ : Promise.resolve(),
+ shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}),
+ ]);
+ app.dispose();
+ } finally {
+ // Keep producers frozen until the journals are drained and disposed.
+ releaseQuiescence?.();
+ }
+ }
+ })());
+ await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
+ };
+ removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup);
+
+ try {
+ // Install the appender BEFORE resolving the session: `session_started` and
+ // `session_load_failed` fire inside create()/resume(), so an appender wired
+ // up only after resolveNativeSession() would drop them to the null appender.
+ // The model below is the best known up front; a resumed session's real
+ // model is reconciled once resolved (v2 via setContext, v1 via
+ // setTelemetryModel). The v1 pipeline is initialized here too: the
+ // process-wide crash handlers report through its default client, so its
+ // sink must be attached before the run can crash.
+ telemetryService = app.accessor.get(ITelemetryService);
+ if (telemetryEnabled) {
+ telemetryService.addAppender(
+ createCloudAppender(app.accessor, {
+ deviceId,
+ appName: CLI_USER_AGENT_PRODUCT,
+ uiMode: PROMPT_UI_MODE,
+ model: opts.model ?? defaultModel,
+ getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null,
+ }),
+ );
+ // No `first_launch` on the v1 client: the v2 side already tracks it via
+ // `telemetryService.track2` below, so tracking here would double-send.
+ initializeTelemetry({
+ homeDir,
+ deviceId,
+ appName: CLI_USER_AGENT_PRODUCT,
+ version,
+ uiMode: PROMPT_UI_MODE,
+ model: opts.model ?? defaultModel,
+ endpoint: () => currentKimiProfile().telemetryEndpoint,
+ getAccessToken: async () =>
+ (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
+ onUnexpectedError: (error) => console.error('[unexpected]', error),
+ });
+ }
+
+ // Print mode has no trust prompt, so the engine's workspace-trust gate
+ // would silently drop project-level MCP servers — say so on stderr.
+ try {
+ const gated = await listTrustGatedMcpServers(app, workDir, homeDir);
+ if (gated.length > 0) stderr.write(formatTrustGatedMcpWarning(gated));
+ } catch {
+ // Best-effort: a broken mcp.json or trust store must not fail the run.
+ }
+
+ const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
+ restorePermission = resolved.restorePermission;
+ quiesceAgents = async () => {
+ releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent);
+ };
+ flushWires = () => flushSessionWires(resolved.session, resolved.agent);
+
+ telemetryService.setContext({ session_id: resolved.session.id, model: resolved.telemetryModel });
+ setTelemetryContext({ sessionId: resolved.session.id });
+ setTelemetryModel(resolved.telemetryModel);
+ setCrashPhase('runtime');
+ if (firstLaunch) {
+ telemetryService.track2('first_launch');
+ }
+
+ const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
+ if (goalCreate !== undefined) {
+ await runNativeGoal(
+ app,
+ resolved.session,
+ resolved.agent,
+ goalCreate,
+ resolved.goalModel,
+ outputFormat,
+ stdout,
+ stderr,
+ );
+ } else {
+ await runNativeTurn(
+ app,
+ resolved.session,
+ resolved.agent,
+ opts.prompt!,
+ outputFormat,
+ stdout,
+ stderr,
+ );
+ }
+ writeResumeHint(resolved.session.id, outputFormat, stdout, stderr);
+
+ telemetryService.withContext({ session_id: resolved.session.id }).track2('exit', {
+ duration_ms: Date.now() - startedAt,
+ });
+ } finally {
+ await cleanup();
+ }
+}
+
+interface ResolvedNativeSession {
+ readonly session: ISessionScopeHandle;
+ readonly agent: IAgentScopeHandle;
+ readonly restorePermission: () => Promise;
+ readonly telemetryModel: string | undefined;
+ readonly goalModel: string | undefined;
+}
+
+async function resolveNativeSession(
+ app: Scope,
+ opts: CLIOptions,
+ workDir: string,
+ defaultModel: string | undefined,
+ stderr: PromptOutput,
+): Promise {
+ const sessions = app.accessor.get(ISessionManager);
+ const index = app.accessor.get(ISessionIndex);
+
+ // `--agent` selects a catalog profile by name; otherwise `--agent-file`
+ // implicitly selects the profile that file defines. The file
+ // is parsed here (fatal on error) so a bad file fails before any turn.
+ let agentProfileName = opts.agent;
+ const agentFile = opts.agentFiles[0];
+ if (agentProfileName === undefined && agentFile !== undefined) {
+ const agentFilePath = resolveAgentPath(
+ agentFile,
+ workDir,
+ app.accessor.get(IBootstrapService).osHomeDir,
+ );
+ let agentFileText: string;
+ try {
+ agentFileText = await readFile(agentFilePath, 'utf8');
+ } catch (error) {
+ throw new Error(
+ `Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`,
+ { cause: error },
+ );
+ }
+ try {
+ agentProfileName = parseAgentFileText({
+ path: agentFilePath,
+ source: 'explicit',
+ text: agentFileText,
+ }).name;
+ } catch (error) {
+ throw new Error(
+ `Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`,
+ { cause: error },
+ );
+ }
+ }
+
+ // `--agent` / `--agent-file` are creation-only: validateOptions rejects them
+ // together with --session/--continue, so resume paths only apply an
+ // explicitly requested model — the bound profile is restored by the engine.
+ const applyModelOverride = async (
+ profile: IAgentProfileService,
+ model: string | undefined,
+ ): Promise => {
+ if (model !== undefined) await profile.setModel(model);
+ };
+
+ const resumeById = async (id: string): Promise => {
+ const session = await resumeSessionById(app.accessor, id);
+ if (session === undefined) {
+ throw new Error(`Session "${id}" not found.`);
+ }
+ return session;
+ };
+
+ const forceAuto = (
+ agent: IAgentScopeHandle,
+ ): { readonly restorePermission: () => Promise } => {
+ const permissionMode = agent.accessor.get(IAgentPermissionModeService);
+ const previous = permissionMode.mode;
+ permissionMode.setMode('auto');
+ return {
+ restorePermission: async () => {
+ permissionMode.setMode(previous);
+ },
+ };
+ };
+
+ if (opts.session !== undefined) {
+ const target = await index.get(opts.session);
+ if (target === undefined) {
+ throw new Error(`Session "${opts.session}" not found.`);
+ }
+ if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) {
+ stderr.write(
+ `Session "${opts.session}" was created under a different directory.\n` +
+ ` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`,
+ );
+ throw new Error(`Session "${opts.session}" was created under a different directory.`);
+ }
+ const session = await resumeById(opts.session);
+ const agentContext = await ensureMainAgent(session);
+ const agent = session.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId)!;
+ const profile = agent.accessor.get(IAgentProfileService);
+ await applyModelOverride(profile, opts.model);
+ const currentModel = profile.getModel();
+ const { restorePermission } = forceAuto(agent);
+ return {
+ session,
+ agent,
+ restorePermission,
+ telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
+ goalModel: configuredModel(opts.model, currentModel),
+ };
+ }
+
+ if (opts.continue) {
+ const page = await index.listRecent({});
+ const previous = page.items.find((summary) => summary.cwd === workDir);
+ if (previous !== undefined) {
+ const session = await resumeById(previous.id);
+ const agentContext = await ensureMainAgent(session);
+ const agent = session.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId)!;
+ const profile = agent.accessor.get(IAgentProfileService);
+ await applyModelOverride(profile, opts.model);
+ const currentModel = profile.getModel();
+ const { restorePermission } = forceAuto(agent);
+ return {
+ session,
+ agent,
+ restorePermission,
+ telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
+ goalModel: configuredModel(opts.model, currentModel),
+ };
+ }
+ stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`);
+ }
+
+ const model = requireConfiguredModel(opts.model, defaultModel);
+ const session = await sessions.create({
+ workDir,
+ additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
+ mainAgentBinding: {
+ profile: agentProfileName ?? 'agent',
+ model,
+ },
+ });
+ const agentContext = await ensureMainAgent(session);
+ const agent = session.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId)!;
+ agent.accessor.get(IAgentPermissionModeService).setMode('auto');
+ return {
+ session,
+ agent,
+ restorePermission: async () => {},
+ telemetryModel: model,
+ goalModel: model,
+ };
+}
+
+export interface TrustGatedMcpServer {
+ readonly name: string;
+ readonly target: string;
+}
+
+/**
+ * Project-level MCP servers the workspace-trust gate leaves out in this
+ * folder, identified by the origin of each entry in the final merged config
+ * (mirrors the SDK's `getWorkspaceTrustInfo`). Empty when the folder is trusted
+ * or nothing project-level is declared.
+ */
+export async function listTrustGatedMcpServers(
+ app: Scope,
+ workDir: string,
+ homeDir: string,
+): Promise {
+ const workspace = await app.accessor
+ .get(IWorkspaceInstanceManager)
+ .getOrCreate({ root: workDir });
+ if (await workspace.program.trust.get()) return [];
+ const fs = app.accessor.get(IHostFileSystem);
+ const [paths, loaded] = await Promise.all([
+ resolveMcpJsonPaths({ fs, cwd: workDir, homeDir }),
+ loadMcpServersDetailed({ fs, cwd: workDir, homeDir, includeProject: true }),
+ ]);
+ const projectPaths = new Set([paths.projectRoot, paths.project]);
+ return Object.entries(loaded.servers)
+ .filter(([name]) => projectPaths.has(loaded.origins[name] ?? ''))
+ .map(([name, config]) => ({ name, target: describeMcpTarget(config) }))
+ .toSorted((a, b) => a.name.localeCompare(b.name));
+}
+
+export function formatTrustGatedMcpWarning(servers: readonly TrustGatedMcpServer[]): string {
+ const noun = servers.length === 1 ? 'server' : 'servers';
+ const list = servers.map((server) => `${server.name} (${server.target})`).join(', ');
+ return (
+ `Warning: this folder is not trusted; skipped ${servers.length} project-level MCP ${noun}: ${list}.\n` +
+ ' Run `kimi` here and choose "Trust this folder" to enable them.\n\n'
+ );
+}
+
+function describeMcpTarget(config: McpServerConfig): string {
+ if (config.transport === 'stdio') {
+ const args = config.args === undefined ? '' : ` ${config.args.join(' ')}`;
+ return `stdio: ${config.command}${args}`;
+ }
+ return `${config.transport}: ${config.url}`;
+}
+
+async function runNativeTurn(
+ app: Scope,
+ session: ISessionScopeHandle,
+ agent: IAgentScopeHandle,
+ prompt: string,
+ outputFormat: PromptOutputFormat,
+ stdout: PromptOutput,
+ stderr: PromptOutput,
+): Promise {
+ const writer: PromptTurnWriter =
+ outputFormat === 'stream-json'
+ ? new PromptJsonWriter(stdout)
+ : new PromptTranscriptWriter(stdout, stderr);
+
+ await agent.accessor.get(IAuthSummaryService).ensureReady();
+
+ const turnEndings = createPrintTurnEndings();
+ const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2) => {
+ dispatchNativeEvent(writer, event, stderr);
+ // Arm the turn-endings collector before `turn.result` settles so a
+ // background-task completion that steers a new turn right after the main
+ // turn ends cannot have its `turn.ended` slip past the policy loop.
+ if (event.type === 'turn.ended') turnEndings.push(event as TurnEnded);
+ });
+ try {
+ const loop = agent.accessor.get(IAgentLoopService);
+ const { id } = loop.submit({
+ message: { role: 'user', content: [{ type: 'text', text: prompt }] },
+ meta: { origin: { kind: 'user' }, tracked: true },
+ });
+ const handle = loop.promptHandle(id)!;
+ const turn = await handle.launched;
+ if (turn === undefined) {
+ // A prompt blocked by an onBeforeSubmitPrompt hook never launches a turn.
+ writer.finish();
+ const completion = await handle.completion;
+ throw new Error(
+ completion.state === 'blocked'
+ ? 'Prompt hook blocked the request.'
+ : 'Prompt turn could not be started',
+ );
+ }
+ const result = await turn.result;
+
+ // Turn settled, but `-p` is not done until the print-mode background
+ // policy says so (config-driven: exit / drain / steer). Flush the buffered
+ // assistant message first so a long drain/steer wait does not withhold the
+ // final message.
+ writer.flushAssistant();
+ if (result.type === 'completed') {
+ const skipTurnId = turn.id;
+ if (skipTurnId === undefined) {
+ throw new Error('Prompt turn ended before it started');
+ }
+ const configService = app.accessor.get(IConfigService);
+ const taskConfig = resolveAgentTaskConfig(configService);
+ const goalService = agent.accessor.get(IAgentGoalService);
+ const cronService = agent.accessor.get(IAgentCronService);
+ try {
+ await applyPrintBackgroundPolicy({
+ mode: resolvePrintBackgroundMode(configService),
+ ceilingS: taskConfig?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT,
+ maxTurns: taskConfig?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT,
+ countPending: () => countPendingBackgroundTasks(session),
+ drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS),
+ turnEndings,
+ skipTurnId,
+ warn: (message) => stderr.write(`Warning: ${message}\n`),
+ now: () => Date.now(),
+ goalActive: () => goalService.getGoal().goal?.status === 'active',
+ cronNextFireAt: () => cronService.getNextFireTime(),
+ });
+ } catch (error) {
+ // A steered turn that fails fails the run (v1 parity). Anything else
+ // is best-effort: a wedged background task must not fail the (already
+ // completed) main turn.
+ if (error instanceof PrintSteeredTurnFailedError) {
+ writer.finish();
+ throw error;
+ }
+ stderr.write(
+ `Warning: print background policy failed: ${
+ error instanceof Error ? error.message : String(error)
+ }\n`,
+ );
+ }
+ writer.finish();
+ return;
+ }
+ writer.finish();
+ throw new Error(formatNativeTurnFailure(result));
+ } catch (error) {
+ writer.finish();
+ throw error instanceof Error ? error : new Error(String(error));
+ } finally {
+ subscription.dispose();
+ }
+}
+
+async function runNativeGoal(
+ app: Scope,
+ session: ISessionScopeHandle,
+ agent: IAgentScopeHandle,
+ goal: HeadlessGoalCreate,
+ model: string | undefined,
+ outputFormat: PromptOutputFormat,
+ stdout: PromptOutput,
+ stderr: PromptOutput,
+): Promise {
+ requireConfiguredModel(model);
+ const goalService = agent.accessor.get(IAgentGoalService);
+ await goalService.createGoal({
+ objective: goal.objective,
+ replace: goal.replace,
+ });
+ let completedSnapshot: { readonly status: string } | null = null;
+ const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2) => {
+ if (event.type === 'goal.updated') {
+ const updated = event as unknown as GoalUpdated;
+ if (updated.change?.kind === 'completion' && updated.snapshot !== null) {
+ completedSnapshot = updated.snapshot;
+ }
+ }
+ });
+ try {
+ await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr);
+ } finally {
+ subscription.dispose();
+ const snapshot = completedSnapshot ?? goalService.getGoal().goal;
+ if (outputFormat === 'stream-json') {
+ stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
+ } else {
+ stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
+ }
+ if (snapshot !== null && snapshot.status !== 'complete') {
+ process.exitCode = goalExitCode(snapshot.status);
+ }
+ }
+}
+
+function dispatchNativeEvent(
+ writer: PromptTurnWriter,
+ event: Event2,
+ stderr: PromptOutput,
+): void {
+ switch (event.type) {
+ case 'turn.step.started':
+ case 'turn.step.interrupted':
+ writer.flushAssistant();
+ return;
+ case 'turn.step.retrying':
+ writer.discardAssistant();
+ writer.writeRetrying(event as unknown as TurnStepRetrying);
+ return;
+ case 'assistant.delta':
+ writer.writeAssistantDelta((event as unknown as AssistantDelta).delta);
+ return;
+ case 'hook.result':
+ writer.writeHookResult(event as unknown as HookResult);
+ return;
+ case 'thinking.delta':
+ writer.writeThinkingDelta((event as unknown as ThinkingDelta).delta);
+ return;
+ case 'tool.call.started': {
+ const started = event as unknown as ToolCallStarted;
+ writer.writeToolCall(started.toolCallId, started.name, started.args);
+ return;
+ }
+ case 'tool.call.delta': {
+ const delta = event as unknown as ToolCallDelta;
+ writer.writeToolCallDelta(delta.toolCallId, delta.name, delta.argumentsPart);
+ return;
+ }
+ case 'tool.result': {
+ const result = event as unknown as ToolResultEvent;
+ writer.writeToolResult(result.toolCallId, result.output);
+ return;
+ }
+ case 'tool.progress': {
+ const progress = (event as unknown as ToolProgress).update;
+ if (progress.text !== undefined && progress.text.length > 0) {
+ stderr.write(progress.text.endsWith('\n') ? progress.text : `${progress.text}\n`);
+ }
+ return;
+ }
+ }
+}
+
+export type PrintTurnEnding = TurnEnded;
+
+/**
+ * Source of `turn.ended` events for the print steer loop. `next` resolves with
+ * the next ending (skipping `skipTurnId`, the main turn's own buffered
+ * ending), or `null` when `remainingMs` elapses first.
+ */
+export interface PrintTurnEndings {
+ next(remainingMs: number, skipTurnId: number): Promise;
+}
+
+/**
+ * Buffered `turn.ended` collector fed from the agent event bus. Events that
+ * arrive while no one is waiting are queued, so endings that fire between the
+ * main turn settling and the policy loop starting are not missed.
+ */
+export function createPrintTurnEndings(): PrintTurnEndings & {
+ push: (event: PrintTurnEnding) => void;
+} {
+ const buffer: PrintTurnEnding[] = [];
+ let waiter: ((ending: PrintTurnEnding | null) => void) | undefined;
+ return {
+ push: (event) => {
+ const resolve = waiter;
+ if (resolve !== undefined) {
+ waiter = undefined;
+ resolve(event);
+ return;
+ }
+ buffer.push(event);
+ },
+ next: async (remainingMs, skipTurnId) => {
+ const deadlineAt = Date.now() + remainingMs;
+ const waitOnce = (ms: number): Promise =>
+ new Promise((resolve) => {
+ let settled = false;
+ const settle = (value: PrintTurnEnding | null): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ waiter = undefined;
+ // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
+ resolve(value);
+ };
+ // A delay beyond the host timer ceiling (an explicit
+ // `print_wait_ceiling_s` or a far-future cron fire can still reach
+ // it) is clamped by `setClampedTimeout`, so the timer can expire
+ // early: the loop below treats that as a chunk boundary and
+ // re-arms against the real deadline.
+ const timer = Number.isFinite(ms)
+ ? setClampedTimeout(() => {
+ settle(null);
+ }, ms)
+ : undefined;
+ waiter = settle;
+ });
+ for (;;) {
+ while (buffer.length > 0) {
+ const ending = buffer.shift()!;
+ if (ending.turnId !== skipTurnId) return ending;
+ }
+ const ms = deadlineAt - Date.now();
+ if (ms <= 0) return null;
+ const ending = await waitOnce(ms);
+ // Timer-chunk boundary, not the real deadline: keep waiting.
+ if (ending === null) continue;
+ if (ending.turnId !== skipTurnId) return ending;
+ // The skipped turn's own ending: keep waiting within the same budget.
+ }
+ },
+ };
+}
+
+/** A background-task completion steered a new main turn that did not complete. */
+export class PrintSteeredTurnFailedError extends Error {}
+
+export interface PrintBackgroundPolicyInput {
+ readonly mode: PrintBackgroundMode;
+ readonly ceilingS: number;
+ readonly maxTurns: number;
+ readonly countPending: () => number;
+ readonly drain: () => Promise;
+ readonly turnEndings: PrintTurnEndings;
+ readonly skipTurnId: number;
+ readonly warn: (message: string) => void;
+ readonly now: () => number;
+ /**
+ * Reports whether an agent goal is still `active`. v2 drives goal
+ * continuation as new turns (v1 keeps a single turn alive), so a `-p` goal
+ * run must stay alive until the goal leaves `active`, independent of the
+ * background policy.
+ */
+ readonly goalActive?: () => boolean;
+ /**
+ * Reports the next scheduled cron fire time (epoch ms), or `null` when no
+ * cron task has a future fire. While it returns non-null the policy keeps
+ * the process alive — the cron tick timer itself is unref'd — waiting for
+ * the fire to steer a new turn, then re-evaluating (a fired one-shot task
+ * disappears; a recurring one reports its advanced next fire). Cron
+ * liveness is independent of the background mode: it applies under
+ * `exit`/`drain` too (v1 parity). Omitted = no cron waiting.
+ */
+ readonly cronNextFireAt?: () => number | null;
+}
+
+/**
+ * Apply the print-mode (`kimi -p`) background-resource policy after the main
+ * turn completes. A single loop re-evaluates the Session's live resources in
+ * order on every round and stays alive while any of them is pending:
+ * - goal : while a goal is `active`, keep waiting for its continuation
+ * turns (bounded by `ceilingS` as a safety net), regardless of
+ * the background mode; the goal summary drives the exit code.
+ * - cron : while `cronNextFireAt` reports a future fire, keep waiting —
+ * the cron tick timer is unref'd, so the process must hold the
+ * event loop itself (v1 parity, independent of the mode). The
+ * fire steers a new turn; a steered turn that does not complete
+ * fails the run. Each round re-reads the next fire time, so a
+ * fired one-shot task ends the wait while a recurring one keeps
+ * it. A fire time that stays unchanged and in the past across
+ * two consecutive rounds means the tick is wedged: warn once and
+ * stop cron waiting instead of spinning.
+ * - mode : 'exit' → return immediately;
+ * 'drain' → suppress + drain background tasks, then return;
+ * 'steer' → while background tasks are still pending, stay alive
+ * so task completions steer new main turns; return once
+ * quiescent, or when the wall-clock ceiling (`ceilingS`) or the
+ * turn cap (`maxTurns`) is reached. A steered turn that does not
+ * complete fails the run.
+ * The steer ceiling deadline is set once on entry, so goal/cron waiting
+ * consumes the same budget.
+ */
+export async function applyPrintBackgroundPolicy(
+ input: PrintBackgroundPolicyInput,
+): Promise {
+ const deadline = input.now() + input.ceilingS * 1000;
+ let turns = 0;
+ // Cron anti-spin guard: the last fire time seen already in the past. Two
+ // consecutive rounds with the same past fire time mean the tick never ran.
+ let lastPastFireAt: number | undefined;
+ let cronWedged = false;
+ for (;;) {
+ // (a) goal: while a goal is `active`, keep waiting for its continuation
+ // turns. Also wake on a short poll: a goal can leave `active` without any
+ // further turn.ended (budget block at a turn boundary, or a pause after a
+ // continuation-launch failure), which would otherwise hang the run until
+ // the ceiling. A continuation turn that does not complete pauses/blocks
+ // the goal, so the condition exits on the next check.
+ while (input.goalActive?.() === true) {
+ const ended = await input.turnEndings.next(
+ Math.min(deadline - input.now(), GOAL_WAIT_POLL_MS),
+ input.skipTurnId,
+ );
+ if (ended === null && input.now() >= deadline) {
+ input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`);
+ return;
+ }
+ }
+
+ // (b) cron: keep the process alive until the pending fire steered a turn
+ // (one-shot tasks vanish after firing; recurring ones advance their next
+ // fire), then re-evaluate from the top.
+ if (!cronWedged && input.cronNextFireAt !== undefined) {
+ const fireAt = input.cronNextFireAt();
+ if (fireAt !== null) {
+ if (fireAt <= input.now() && lastPastFireAt === fireAt) {
+ cronWedged = true;
+ input.warn(
+ 'print cron wait: next fire time stuck in the past; cron tick appears wedged, giving up on cron',
+ );
+ } else {
+ if (fireAt <= input.now()) lastPastFireAt = fireAt;
+ const ended = await input.turnEndings.next(
+ Math.max(fireAt - input.now(), 0) + CRON_FIRE_GRACE_MS,
+ input.skipTurnId,
+ );
+ if (ended !== null && ended.reason !== 'completed') {
+ throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
+ }
+ // Fire observed (or its grace elapsed without a turn): re-read the
+ // next fire time from the top.
+ continue;
+ }
+ }
+ }
+
+ // (c) background-task mode.
+ if (input.mode === 'exit') return;
+ if (input.mode === 'drain') {
+ await input.drain();
+ return;
+ }
+
+ // 'steer'
+ turns += 1;
+ if (input.now() >= deadline) {
+ input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`);
+ return;
+ }
+ if (turns > input.maxTurns) {
+ input.warn(`print steer max turns reached (${input.maxTurns}), finishing`);
+ return;
+ }
+ if (input.countPending() === 0) return;
+ const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId);
+ if (ended === null) return;
+ if (ended.reason !== 'completed') {
+ throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
+ }
+ }
+}
+
+function formatTurnEndingFailure(ending: PrintTurnEnding): string {
+ if (ending.error?.code === 'provider.filtered') {
+ return 'Provider safety policy blocked the response.';
+ }
+ if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`;
+ if (ending.reason === 'blocked') {
+ return 'Prompt hook blocked the request.';
+ }
+ return `Prompt turn ended with reason: ${ending.reason}`;
+}
+
+function countPendingBackgroundTasks(session: ISessionScopeHandle): number {
+ let count = 0;
+ const agentManager = session.accessor.get(IAgentLifecycleService);
+ for (const agent of agentManager.list()) {
+ const handle = agentManager.handleOf(agent.agentId);
+ if (handle === undefined) continue;
+ count += handle.accessor.get(IAgentTaskService).list(true).length;
+ }
+ return count;
+}
+
+/** Every agent handle in the session; the main agent is included explicitly since the lifecycle list skips `closing` agents. */
+function collectSessionAgentHandles(
+ session: ISessionScopeHandle,
+ mainAgent: IAgentScopeHandle,
+): IAgentScopeHandle[] {
+ const agentManager = session.accessor.get(IAgentLifecycleService);
+ const handles = new Set([mainAgent]);
+ for (const agent of agentManager.list()) {
+ const handle = agentManager.handleOf(agent.agentId);
+ if (handle !== undefined) handles.add(handle);
+ }
+ return [...handles];
+}
+
+/**
+ * Stop producers, drain prompts, and cancel turns so closing records exist
+ * before the wire flush; returns a release holding a guard per loop.
+ */
+async function quiesceSessionAgents(
+ session: ISessionScopeHandle,
+ mainAgent: IAgentScopeHandle,
+): Promise<(() => void) | undefined> {
+ const handles = collectSessionAgentHandles(session, mainAgent);
+ const loops = handles.flatMap((handle) => {
+ try {
+ return [handle.accessor.get(IAgentLoopService)];
+ } catch {
+ // A torn-down agent scope has no loop to quiesce.
+ return [];
+ }
+ });
+ // Task producers bypass the prompt queue and dispatch termination records
+ // straight to the wire; stop them first so the flush can persist those.
+ await Promise.allSettled(
+ handles.flatMap((handle) => {
+ try {
+ return [handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed')];
+ } catch {
+ return [];
+ }
+ }),
+ );
+ // Repeat until every queue is empty and every loop freezable: a prompt can
+ // still surface from the launch window or a cancelled turn's settle chain.
+ for (;;) {
+ for (const loop of loops) {
+ for (const queueId of loop.snapshot().queue.map((item) => item.meta?.promptId)) {
+ if (queueId !== undefined) loop.cancel({ promptId: queueId });
+ }
+ loop.cancel();
+ }
+ await Promise.allSettled(loops.map((loop) => loop.settled()));
+ const guards: { dispose(): void }[] = [];
+ let frozen = true;
+ for (const loop of loops) {
+ let guard: { dispose(): void } | undefined;
+ try {
+ guard = loop.tryAcquireQuiescence();
+ } catch {
+ // A disposed loop cannot accept new submissions; it needs no guard.
+ continue;
+ }
+ if (guard === undefined) {
+ frozen = false;
+ break;
+ }
+ guards.push(guard);
+ }
+ const busy = loops.some((loop) => {
+ try {
+ const snapshot = loop.snapshot();
+ return snapshot.state === 'running' || snapshot.queue.length > 0;
+ } catch {
+ return false;
+ }
+ });
+ if (frozen && !busy) {
+ return () => {
+ for (const guard of guards) guard.dispose();
+ };
+ }
+ for (const guard of guards) guard.dispose();
+ await new Promise((resolve) => {
+ setTimeout(resolve, PROMPT_QUIESCE_POLL_MS);
+ });
+ }
+}
+
+/** Flush every session agent's wire journal; each flush settles independently. */
+async function flushSessionWires(
+ session: ISessionScopeHandle,
+ mainAgent: IAgentScopeHandle,
+): Promise {
+ await Promise.allSettled(
+ collectSessionAgentHandles(session, mainAgent).map((handle) =>
+ handle.accessor.get(IEventDispatcher).flush(),
+ ),
+ );
+}
+
+async function drainBackgroundTasks(
+ session: ISessionScopeHandle,
+ ceilingS: number | undefined,
+): Promise {
+ const ceilingMs =
+ typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
+ ? ceilingS * 1000
+ : PRINT_WAIT_CEILING_S_DEFAULT * 1000;
+
+ const deadline = Date.now() + ceilingMs;
+ const seen = new Set();
+ const allWaiters: Promise[] = [];
+ while (Date.now() < deadline) {
+ const batch: Promise[] = [];
+ const suppressions: Promise[] = [];
+ let activeCount = 0;
+ const agentManager = session.accessor.get(IAgentLifecycleService);
+ for (const agent of agentManager.list()) {
+ const handle = agentManager.handleOf(agent.agentId);
+ if (handle === undefined) continue;
+ const taskService = handle.accessor.get(IAgentTaskService);
+ for (const task of taskService.list(true)) {
+ activeCount++;
+ if (seen.has(task.taskId)) continue;
+ seen.add(task.taskId);
+ suppressions.push(taskService.suppressTerminalNotification(task.taskId));
+ const remaining = Math.max(1, deadline - Date.now());
+ const waiter = taskService.wait(task.taskId, remaining);
+ batch.push(waiter);
+ allWaiters.push(waiter);
+ }
+ }
+ if (suppressions.length > 0) await Promise.all(suppressions);
+ if (activeCount === 0 || batch.length === 0) break;
+ await Promise.all(batch);
+ }
+ if (allWaiters.length > 0) await Promise.all(allWaiters);
+}
+
+function formatNativeTurnFailure(result: LoopRunResult): string {
+ if (result.type === 'failed') {
+ const error = result.error as { readonly code?: string; readonly message?: string } | undefined;
+ if (error?.code === 'provider.filtered') {
+ return 'Provider safety policy blocked the response.';
+ }
+ if (error?.code !== undefined) {
+ return `${error.code}: ${error.message ?? ''}`.trimEnd();
+ }
+ if (result.error instanceof Error) {
+ return result.error.message;
+ }
+ }
+ return `Prompt turn ended with reason: ${result.type}`;
+}
diff --git a/apps/kimi-code/src/cli/v2/validate-config.ts b/apps/kimi-code/src/cli/v2/validate-config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..04769d9250f2d0f06da3d041eaf7467814dc00c3
--- /dev/null
+++ b/apps/kimi-code/src/cli/v2/validate-config.ts
@@ -0,0 +1,187 @@
+/**
+ * V2 config.toml validation for `kimi doctor`.
+ *
+ * Loaded lazily (dynamic import) by the doctor command on the default
+ * agent-core-v2 path, so the v2 module graph stays off the legacy doctor path.
+ * Validation uses the engine's own section registry instead of the legacy
+ * whole-document strict schema:
+ * importing the package root runs every built-in section's side-effect
+ * registration ("import = register"), and `ConfigRegistry` is then
+ * constructed directly — no DI container, no `ConfigService`, no file IO.
+ *
+ * Semantics deliberately mirror the v2 engine rather than v1:
+ * - a registered section that fails schema validation is an error (the
+ * engine would silently ignore that section at runtime; surfacing it is
+ * doctor's job);
+ * - a top-level key with no registered section passes through the engine
+ * untouched, so it is reported as a non-fatal warning — except the known
+ * schema-less domains the engine consumes directly (`default_model`, …);
+ * - section-declared key renames (`deprecations`) and renamed env vars
+ * (`deprecatedEnv` bindings actually supplying a value) surface as
+ * non-fatal warnings, reusing the engine's own detection
+ * (`collectKeyDeprecations`) and mirroring `ConfigService`'s env-fallback
+ * warning rule.
+ */
+
+import { parse as parseToml } from 'smol-toml';
+import { z } from 'zod';
+
+import {
+ ConfigRegistry,
+ type AnyEnvBindings,
+ type EnvBinding,
+} from '@moonshot-ai/agent-core-v2';
+import { collectKeyDeprecations } from '@moonshot-ai/agent-core-v2/app/config/deprecations';
+import {
+ camelToSnake,
+ describeTomlSyntaxError,
+ isPlainObject,
+ transformTomlData,
+} from '@moonshot-ai/agent-core-v2/app/config/toml';
+
+/**
+ * Top-level domains the v2 engine reads via `IConfigService.get` / `inspect`
+ * without registering a schema (free-form values, structurally validated
+ * nowhere): `defaultModel` / `defaultProvider` (`kosongConfig` default
+ * pointers), `modelOverrides` (`llmRequester` / `profile`), and `telemetry`
+ * (read by the CLI itself).
+ */
+const SCHEMALESS_DOMAINS: ReadonlySet = new Set([
+ 'defaultModel',
+ 'defaultProvider',
+ 'modelOverrides',
+ 'telemetry',
+]);
+
+interface V2ConfigValidationIssue {
+ readonly path: readonly (string | number)[];
+ readonly message: string;
+}
+
+/**
+ * Matches the shape `handleDoctor` extracts from `error.details` (the SDK's
+ * `KimiConfigValidationIssue` list), so the doctor formatter renders v2
+ * issues exactly like v1 ones.
+ */
+class V2ConfigValidationError extends Error {
+ readonly details: { readonly validationIssues: readonly V2ConfigValidationIssue[] };
+
+ constructor(issues: readonly V2ConfigValidationIssue[]) {
+ super('v2 config validation failed');
+ this.details = { validationIssues: issues };
+ }
+}
+
+/**
+ * Validate `text` as config.toml against the v2 engine's section registry.
+ * Throws on TOML syntax errors and on any registered section failing its
+ * schema; returns non-fatal warnings (one per line) for unknown top-level
+ * keys, deprecated config keys, and deprecated env vars in use.
+ */
+export function validateConfigTomlV2(
+ text: string,
+ filePath: string,
+ getEnv: (name: string) => string | undefined = (name) => process.env[name],
+): string | undefined {
+ let data: Record = {};
+ if (text.trim().length > 0) {
+ try {
+ data = parseToml(text) as Record;
+ } catch (error) {
+ throw new Error(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}`, {
+ cause: error,
+ });
+ }
+ }
+
+ const registry = new ConfigRegistry();
+ const transformed = transformTomlData(data, registry);
+
+ const issues: V2ConfigValidationIssue[] = [];
+ const unknownKeys: string[] = [];
+ for (const [domain, value] of Object.entries(transformed)) {
+ if (registry.getSection(domain) === undefined) {
+ if (!SCHEMALESS_DOMAINS.has(domain)) unknownKeys.push(camelToSnake(domain));
+ continue;
+ }
+ try {
+ registry.validate(domain, value);
+ } catch (error) {
+ if (!(error instanceof z.ZodError)) throw error;
+ for (const issue of error.issues) {
+ issues.push({
+ path: [
+ domain,
+ ...issue.path.map((segment) =>
+ typeof segment === 'number' ? segment : String(segment),
+ ),
+ ],
+ message: issue.message,
+ });
+ }
+ }
+ }
+
+ if (issues.length > 0) throw new V2ConfigValidationError(issues);
+
+ const warnings: string[] = [];
+ for (const diagnostic of collectKeyDeprecations(data, registry.listSections())) {
+ warnings.push(diagnostic.message);
+ }
+ warnings.push(...collectEnvDeprecations(registry, getEnv));
+ if (unknownKeys.length > 0) {
+ warnings.push(
+ `Unknown top-level ${unknownKeys.length === 1 ? 'key' : 'keys'} ignored by the v2 engine: ${unknownKeys.join(', ')}.`,
+ );
+ }
+ return warnings.length > 0 ? warnings.join('\n') : undefined;
+}
+
+/**
+ * Warn about renamed env vars that actually supply a value, mirroring
+ * `ConfigService`'s `resolveBinding`: the deprecated name only resolves (and
+ * thus only warns) when the primary var is absent or fails to parse.
+ */
+function collectEnvDeprecations(
+ registry: ConfigRegistry,
+ getEnv: (name: string) => string | undefined,
+): string[] {
+ const warnings = new Set();
+ for (const section of registry.listSections()) {
+ if (section.env === undefined) continue;
+ walkEnvBindings(section.env, (binding) => {
+ if (typeof binding === 'string' || binding.deprecatedEnv === undefined) return;
+ const primary = getEnv(binding.env);
+ if (
+ primary !== undefined &&
+ (binding.parse === undefined || binding.parse(primary) !== undefined)
+ ) {
+ return;
+ }
+ const deprecated = getEnv(binding.deprecatedEnv);
+ if (deprecated === undefined) return;
+ if (binding.parse !== undefined && binding.parse(deprecated) === undefined) return;
+ warnings.add(
+ `Environment variable ${binding.deprecatedEnv} is deprecated; use ${binding.env} instead.`,
+ );
+ });
+ }
+ return [...warnings];
+}
+
+function isEnvBinding(value: AnyEnvBindings): value is EnvBinding {
+ return typeof value === 'string' || (isPlainObject(value) && 'env' in value);
+}
+
+function walkEnvBindings(
+ bindings: AnyEnvBindings,
+ visit: (binding: EnvBinding) => void,
+): void {
+ if (isEnvBinding(bindings)) {
+ visit(bindings);
+ return;
+ }
+ for (const value of Object.values(bindings)) {
+ if (value !== undefined) walkEnvBindings(value, visit);
+ }
+}
diff --git a/apps/kimi-code/src/cli/version.ts b/apps/kimi-code/src/cli/version.ts
new file mode 100644
index 0000000000000000000000000000000000000000..21e1213937da822601aa9820e1984014344a2071
--- /dev/null
+++ b/apps/kimi-code/src/cli/version.ts
@@ -0,0 +1,64 @@
+/**
+ * Kimi Code version helpers.
+ *
+ * `getVersion` reads the host CLI's `package.json#version`.
+ */
+
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+
+import { createKimiUserAgent, KIMI_CODE_PLATFORM, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
+
+import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
+
+import { KIMI_BUILD_INFO } from './build-info';
+
+const MODULE_DIR = import.meta.dirname;
+
+export function getHostPackageJsonPath(): string {
+ // Walk upwards from this file's directory until a `package.json` shows up,
+ // so both dev (`tsx src/main.ts` — this file in `src/cli/`, pkg 2 levels
+ // up) and prod (`node dist/main.mjs` — this code bundled into `dist/`,
+ // pkg 1 level up) resolve correctly.
+ let dir = MODULE_DIR;
+ for (let i = 0; i < 6; i++) {
+ const candidate = resolve(dir, 'package.json');
+ if (existsSync(candidate)) {
+ return candidate;
+ }
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ throw new Error(`Could not locate package.json near ${MODULE_DIR}`);
+}
+
+export function getHostPackageRoot(): string {
+ return dirname(getHostPackageJsonPath());
+}
+
+export function getVersion(): string {
+ if (KIMI_BUILD_INFO.version !== undefined) {
+ return KIMI_BUILD_INFO.version;
+ }
+ const pkg = JSON.parse(readFileSync(getHostPackageJsonPath(), 'utf-8')) as {
+ version: string;
+ };
+ return pkg.version;
+}
+
+export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIdentity {
+ return {
+ productName: CLI_USER_AGENT_PRODUCT,
+ version,
+ platform: KIMI_CODE_PLATFORM,
+ };
+}
+
+/**
+ * Product User-Agent (`kimi-code-cli/`) for ad-hoc outbound fetches
+ * that don't go through the provider pipeline (registry / catalog imports).
+ */
+export function createKimiCodeUserAgent(version = getVersion()): string {
+ return createKimiUserAgent(createKimiCodeHostIdentity(version));
+}
diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts
new file mode 100644
index 0000000000000000000000000000000000000000..154c6f56704e404377e624999510eb377cfdc02c
--- /dev/null
+++ b/apps/kimi-code/src/constant/app.ts
@@ -0,0 +1,152 @@
+import { ErrorCodes, type HostUiCapability } from '@moonshot-ai/kimi-code-sdk';
+
+import { currentKimiProfile } from '#/utils/region';
+
+export const PRODUCT_NAME = 'Kimi Code';
+export const CLI_COMMAND_NAME = 'kimi';
+export const PROCESS_NAME = 'kimi-code';
+
+// Used in telemetry app names and HTTP User-Agent headers.
+export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli';
+export const CLI_UI_MODE = 'shell';
+// UI surfaces the TUI renders; declared to the engine at bootstrap so features that need a
+// host-side surface (the NotifyUser update panel) are offered to this process only.
+export const TUI_HOST_UI_CAPABILITIES: readonly HostUiCapability[] = ['update_panel'];
+// Telemetry ui_mode for the `kimi web` host. Same product
+// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode.
+export const WEB_UI_MODE = 'web';
+// User-Agent suffix for the `kimi web` host: its requests go out as
+// `kimi-code-cli/ (web)` so upstream can tell web-UI traffic
+// apart from direct CLI runs without changing the product token or platform.
+export const WEB_USER_AGENT_SUFFIX = 'web';
+
+// Give telemetry a short flush window without making CLI exit feel stuck.
+export const CLI_SHUTDOWN_TIMEOUT_MS = 3000;
+
+// Upper bound on headless (`kimi -p`) shutdown. A wedged cleanup step (e.g. a
+// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive
+// firewall) must not keep a completed run alive indefinitely — once this elapses
+// we stop waiting on cleanup and let the run return.
+export const PROMPT_CLEANUP_TIMEOUT_MS = 8000;
+
+// Grace after a headless run has fully completed (turn done, cleanup attempted)
+// before force-exiting. `kimi -p` otherwise relies on the event loop draining to
+// exit; a stray ref'd handle (socket/timer/child) left over from the run would
+// wedge it. The guard timer is unref'd, so a healthy run still exits naturally
+// well before this fires.
+export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000;
+
+// Max time to wait for buffered stdout/stderr to flush before arming the
+// force-exit fallback. A slow/piped consumer's still-draining stdio is a
+// legitimate ref'd handle — flushing first prevents the fallback from
+// truncating completed output. Bounded so a permanently-stuck consumer can't
+// re-introduce the hang.
+export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000;
+
+// Published npm package name; this can differ from the executable command.
+export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code';
+
+// App-owned data paths. SDK/core runtime config is intentionally not routed here.
+export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME';
+export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code';
+export const KIMI_CODE_LOG_DIR_NAME = 'logs';
+export const KIMI_CODE_CACHE_DIR_NAME = 'cache';
+export const KIMI_CODE_UPDATE_DIR_NAME = 'updates';
+export const KIMI_CODE_BIN_DIR_NAME = 'bin';
+export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json';
+export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json';
+export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock';
+export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log';
+export const KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME = 'plugin-notices.json';
+// Native staged update: the staged binary + metadata live next to the running
+// executable (`/.staging/`); the re-exec guard env breaks the
+// swap → re-exec → swap loop.
+export const KIMI_CODE_NATIVE_STAGING_DIR_NAME = '.staging';
+export const KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME = 'staged.json';
+export const KIMI_CODE_UPDATE_REEXEC_ENV = 'KIMI_CODE_UPDATE_REEXEC';
+export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
+export const KIMI_CODE_BANNER_DIR_NAME = 'banner';
+export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json';
+export const KIMI_CODE_SURVEY_STATE_FILE_NAME = 'feedback-survey-state.json';
+export const KIMI_CODE_RECOMMENDED_EFFORT_STATE_FILE_NAME = 'recommended-effort-state.json';
+
+// Managed Kimi auth provider key shared with OAuth/SDK config.
+export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code';
+
+// SDK/core error code that tells the TUI to show a login-required startup
+// notice. Derived from sdk's ErrorCodes so a future rename in core
+// auto-propagates instead of silently breaking the startup recovery path.
+export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED;
+
+export const FEEDBACK_ISSUE_URL = 'https://github.com/MoonshotAI/kimi-code/issues';
+// Sign-up / sign-in page offered to signed-out users so they can create an
+// account and submit feedback through the authenticated channel next time.
+export function kimiCodeSignupUrl(): string {
+ return `${currentKimiProfile().siteBase}/code`;
+}
+
+// Sent in the feedback `version` field so the backend can distinguish this
+// TypeScript client from clients that send a bare version.
+export const FEEDBACK_VERSION_PREFIX = 'kimi-code-';
+
+// Telemetry event name; keep stable for dashboard queries.
+export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted';
+
+// CDN source of truth: all version checks and native install scripts pull from here.
+// The off-session endpoints derive from the current region profile so a
+// global login points at the .ai deployment; they are resolved per call so
+// a region switch (login/logout + refreshKimiRegion) takes effect immediately.
+export function kimiCodeCdnBase(): string {
+ return currentKimiProfile().cdnBase;
+}
+export function kimiCodeCdnLatestUrl(): string {
+ return `${kimiCodeCdnBase()}/latest`;
+}
+// Rollout manifest consumed by update checks; the plain-text `/latest` above
+// stays unchanged forever — already-shipped clients hard-fail on non-semver
+// bodies, and the CDN install scripts read it for fresh installs.
+export function kimiCodeCdnLatestJsonUrl(): string {
+ return `${kimiCodeCdnBase()}/latest.json`;
+}
+// Per-release native artifacts: `/binaries//manifest.json` +
+// `/binaries//kimi-code-[.exe]` — the bare platform binary
+// (same layout install.ps1 consumes).
+export function kimiCodeCdnBinariesBase(): string {
+ return `${kimiCodeCdnBase()}/binaries`;
+}
+// The marketplace env override name lives in the shared agent-core-v2 plugin
+// domain (kap-server consumes it from there). Deep-path import: this module is
+// evaluated on every CLI invocation, so it must not pull in the engine root.
+export { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '@moonshot-ai/agent-core-v2/app/plugin/marketplace';
+// The CLI-side default catalog derives from the current region profile; the
+// env override above takes priority at the call site.
+export function kimiCodePluginMarketplaceUrl(): string {
+ return `${kimiCodeCdnBase()}/plugins/marketplace.json`;
+}
+// Bound on each background "latest release" lookup when the TUI fills in
+// marketplace versions. Without it a stalled connection to github.com hangs
+// the version phase for undici's default header timeout (300s).
+export const MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS = 5000;
+export const INTERACTIVE_UPDATE_CHECK_TIMEOUT_MS = 10_000;
+// Official plugins whose usage bills against the user's plan quota. Installing
+// one of these shows a quota note after the install result.
+export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource'];
+export function kimiCodeInstallShUrl(): string {
+ return `${kimiCodeCdnBase()}/install.sh`;
+}
+export function kimiCodeInstallPs1Url(): string {
+ return `${kimiCodeCdnBase()}/install.ps1`;
+}
+// Official download page, referenced by prompt copy that steers users away
+// from third-party install sources.
+export function kimiCodeOfficialInstallUrl(): string {
+ return `${currentKimiProfile().siteBase}/code`;
+}
+
+// Native install commands, split by platform. Use these for prompt copy and spawn calls only; do not assemble the strings elsewhere.
+export function nativeInstallCommandUnix(): string {
+ return `curl -fsSL ${kimiCodeInstallShUrl()} | bash`;
+}
+export function nativeInstallCommandWin(): string {
+ return `irm ${kimiCodeInstallPs1Url()} | iex`;
+}
diff --git a/apps/kimi-code/src/constant/index.ts b/apps/kimi-code/src/constant/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..afa11b2a17b8c558676b902cb9b69d46e6121dff
--- /dev/null
+++ b/apps/kimi-code/src/constant/index.ts
@@ -0,0 +1,4 @@
+export * from './app';
+export * from './startup-error';
+export * from './terminal';
+export * from './update';
diff --git a/apps/kimi-code/src/constant/startup-error.ts b/apps/kimi-code/src/constant/startup-error.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a59cb9093a888375e4b6b29ced9bf2a161a266b5
--- /dev/null
+++ b/apps/kimi-code/src/constant/startup-error.ts
@@ -0,0 +1,2 @@
+// Pre-TUI startup errors render directly to stderr, before theme detection.
+export const STARTUP_ERROR_COLOR = '#E85454';
diff --git a/apps/kimi-code/src/constant/terminal.ts b/apps/kimi-code/src/constant/terminal.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22a4dfdfdb8cbdf9b3391260f10b0c226135e592
--- /dev/null
+++ b/apps/kimi-code/src/constant/terminal.ts
@@ -0,0 +1,8 @@
+// C0/control-sequence bytes used to build terminal protocol messages.
+export const ESC = '\u001B';
+export const BEL = '\u0007';
+export const ST = '\\';
+
+// ANSI cursor visibility toggles used by CLI prompts.
+export const HIDE_CURSOR = `${ESC}[?25l`;
+export const SHOW_CURSOR = `${ESC}[?25h`;
diff --git a/apps/kimi-code/src/constant/update.ts b/apps/kimi-code/src/constant/update.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9881219cf4e66d1e88aee24f3b33d95e1e665684
--- /dev/null
+++ b/apps/kimi-code/src/constant/update.ts
@@ -0,0 +1,7 @@
+// Local palette for the pre-TUI update prompt. TUI colors still come
+// from `src/tui/theme`; do not use these in interactive TUI components.
+export const UPDATE_PROMPT_PRIMARY = '#1783ff';
+export const UPDATE_PROMPT_SUCCESS = '#16A34A';
+export const UPDATE_PROMPT_WARNING = '#CA8A04';
+export const UPDATE_PROMPT_MUTED = '#999999';
+export const UPDATE_PROMPT_TEXT_DIM = '#6B7280';
diff --git a/apps/kimi-code/src/feedback/archive.ts b/apps/kimi-code/src/feedback/archive.ts
new file mode 100644
index 0000000000000000000000000000000000000000..439f68d1be515bdbb49fc01e39e97e05e55234ad
--- /dev/null
+++ b/apps/kimi-code/src/feedback/archive.ts
@@ -0,0 +1,72 @@
+import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+
+import { getCacheDir } from '../utils/paths';
+
+const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours.
+
+/**
+ * A file produced for a feedback attachment upload. Both the session log
+ * archive and the codebase archive share this shape; the generic uploader
+ * consumes it without caring how the file was produced.
+ */
+export interface FeedbackArchive {
+ readonly path: string;
+ readonly size: number;
+ readonly sha256: string;
+ readonly fingerprint: string;
+ readonly fileCount: number;
+ /** Directory created exclusively for this archive and safe to remove after upload. */
+ readonly cleanupDir?: string;
+}
+
+export async function createFeedbackArchivePath(filename: string): Promise<{
+ readonly archivePath: string;
+ readonly cleanupDir: string;
+}> {
+ const archivePath = await createArchivePath(filename);
+ return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) };
+}
+
+/**
+ * Remove feedback-upload archive directories older than 24 hours. Packaging
+ * cleans up its own archive on success and on failure, but a killed process
+ * or an empty parent dir can still leave leftovers behind; this is a
+ * best-effort backstop so the cache dir does not grow without bound.
+ *
+ * `dir` is injectable for tests; production callers leave it as the default.
+ */
+export async function removeStaleFeedbackUploads(
+ options: { readonly now?: number; readonly dir?: string } = {},
+): Promise {
+ const now = options.now ?? Date.now();
+ const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads');
+ const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
+ throw error;
+ });
+ if (entries === null) return;
+
+ const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS;
+ await Promise.all(
+ entries.map(async (entry) => {
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) return;
+ const target = join(dir, entry.name);
+ const targetStat = await stat(target).catch(() => null);
+ if (targetStat === null || targetStat.mtimeMs >= cutoff) return;
+ await rm(target, { recursive: true, force: true }).catch(() => {});
+ }),
+ );
+}
+
+async function createArchivePath(filename: string): Promise {
+ await removeStaleFeedbackUploads();
+ const root = join(getCacheDir(), 'feedback-uploads');
+ await mkdir(root, { recursive: true });
+ const dir = await mkdtemp(join(root, 'upload-'));
+ return join(dir, filename);
+}
+
+function archivePathCleanupDir(archivePath: string): string {
+ return dirname(archivePath);
+}
diff --git a/apps/kimi-code/src/feedback/codebase/filter.ts b/apps/kimi-code/src/feedback/codebase/filter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1df5976e912b9b066995b5f50a2c829b78624bb7
--- /dev/null
+++ b/apps/kimi-code/src/feedback/codebase/filter.ts
@@ -0,0 +1,92 @@
+export const DEFAULT_MAX_FILES = 50000;
+export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
+// Upper bound for the compressed codebase archive, aligned with the backend's
+// per-upload limit. The scanner uses cumulative raw file size as a conservative
+// estimate so the resulting zip stays within this bound.
+export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024;
+
+const IGNORED_DIR_NAMES: ReadonlySet = new Set([
+ '.git',
+ '.hg',
+ '.svn',
+ 'node_modules',
+ 'dist',
+ 'build',
+ 'out',
+ '.next',
+ '.nuxt',
+ '.turbo',
+ '.cache',
+ '.parcel-cache',
+ 'coverage',
+ '.nyc_output',
+ 'target',
+ '__pycache__',
+ '.pytest_cache',
+ '.mypy_cache',
+ '.venv',
+ 'venv',
+ 'env',
+ '.idea',
+]);
+
+const SENSITIVE_DIR_NAMES: ReadonlySet = new Set([
+ '.ssh',
+ '.gnupg',
+ '.aws',
+ '.kube',
+ '.docker',
+]);
+
+const SENSITIVE_FILE_NAMES: ReadonlySet = new Set([
+ '.env',
+ 'id_rsa',
+ 'id_dsa',
+ 'id_ecdsa',
+ 'id_ed25519',
+ 'credentials.json',
+ 'service-account.json',
+ 'serviceAccount.json',
+ '.netrc',
+ '.htpasswd',
+ '.pypirc',
+ '.npmrc',
+ '.envrc',
+ '.yarnrc',
+ '.yarnrc.yml',
+]);
+
+const SENSITIVE_FILE_SUFFIXES: readonly string[] = [
+ '.pem',
+ '.key',
+ '.p12',
+ '.pfx',
+ '.jks',
+ '.keystore',
+];
+
+const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet = new Set(['.example', '.sample', '.template']);
+
+export function isIgnoredDirName(name: string): boolean {
+ return IGNORED_DIR_NAMES.has(name);
+}
+
+export function isSensitivePath(relativePath: string): boolean {
+ const segments = relativePath.split('/');
+ for (let i = 0; i < segments.length - 1; i += 1) {
+ const segment = segments[i];
+ if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true;
+ }
+
+ const base = segments.at(-1);
+ if (base === undefined || base.length === 0) return false;
+ if (SENSITIVE_FILE_NAMES.has(base)) return true;
+ if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true;
+
+ if (base.startsWith('.env.')) {
+ const suffix = base.slice('.env'.length);
+ return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix);
+ }
+
+ return false;
+}
diff --git a/apps/kimi-code/src/feedback/codebase/index.ts b/apps/kimi-code/src/feedback/codebase/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7ef51870ddc9b0cfe03157005d81aefe3dbbedc2
--- /dev/null
+++ b/apps/kimi-code/src/feedback/codebase/index.ts
@@ -0,0 +1,3 @@
+export * from './packager';
+export * from './scanner';
+export * from './types';
diff --git a/apps/kimi-code/src/feedback/codebase/packager.ts b/apps/kimi-code/src/feedback/codebase/packager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cd4bf2c66ebb43907673b8dffdd25ee31c1b798f
--- /dev/null
+++ b/apps/kimi-code/src/feedback/codebase/packager.ts
@@ -0,0 +1,98 @@
+import { createHash } from 'node:crypto';
+import { createWriteStream } from 'node:fs';
+import { mkdir, rm, stat } from 'node:fs/promises';
+import { dirname } from 'node:path';
+
+import { ZipFile } from 'yazl';
+
+import type { FeedbackArchive } from '../archive';
+import type { FeedbackCodebaseScanResult } from './types';
+
+interface PackageEntry {
+ readonly absolutePath: string;
+ readonly archivePath: string;
+ readonly size: number;
+ readonly mtimeMs: number;
+}
+
+/**
+ * Pack the scanned codebase into a zip, with files placed at the zip root.
+ */
+export async function packageCodebase(
+ scan: FeedbackCodebaseScanResult,
+ archivePath: string,
+): Promise {
+ const entries: PackageEntry[] = scan.files.map((file) => ({
+ absolutePath: file.absolutePath,
+ archivePath: file.path,
+ size: file.size,
+ mtimeMs: file.mtimeMs,
+ }));
+ return packageEntries(entries, archivePath);
+}
+
+async function packageEntries(
+ entries: readonly PackageEntry[],
+ archivePath: string,
+): Promise {
+ if (entries.length === 0) {
+ throw new Error('Cannot package an empty feedback archive.');
+ }
+ await mkdir(dirname(archivePath), { recursive: true });
+
+ const zip = new ZipFile();
+ const hash = createHash('sha256');
+ const output = createWriteStream(archivePath);
+
+ try {
+ const done = new Promise