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((resolvePromise, rejectPromise) => { + output.on('finish', resolvePromise); + output.on('error', rejectPromise); + zip.outputStream.on('error', rejectPromise); + }); + + zip.outputStream.on('data', (chunk: Buffer) => { + hash.update(chunk); + }); + zip.outputStream.pipe(output); + + for (const entry of entries) { + zip.addFile(entry.absolutePath, entry.archivePath, { + mtime: new Date(entry.mtimeMs), + mode: 0o100644, + }); + } + zip.end(); + await done; + + const archiveStat = await stat(archivePath); + return { + path: archivePath, + size: archiveStat.size, + sha256: hash.digest('hex'), + fingerprint: fingerprintEntries(entries), + fileCount: entries.length, + }; + } catch (error) { + // A failed zip (e.g. a source file vanished or became unreadable between + // scan and packaging) would otherwise leave a partial archive behind in + // the cache dir. Destroy the stream so the handle is released before we + // remove the file, then best-effort delete it. + output.destroy(); + await rm(archivePath, { force: true }).catch(() => {}); + throw error; + } +} + +function fingerprintEntries(entries: readonly PackageEntry[]): string { + const hash = createHash('sha256'); + for (const entry of entries) { + hash.update(entry.archivePath); + hash.update('\0'); + hash.update(String(entry.size)); + hash.update('\0'); + hash.update(String(Math.trunc(entry.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} diff --git a/apps/kimi-code/src/feedback/codebase/scanner.ts b/apps/kimi-code/src/feedback/codebase/scanner.ts new file mode 100644 index 0000000000000000000000000000000000000000..6df42021723261a3551bf4ae5d9d15f670e23982 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/scanner.ts @@ -0,0 +1,217 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { + DEFAULT_MAX_ARCHIVE_SIZE, + DEFAULT_MAX_FILES, + DEFAULT_MAX_FILE_SIZE, + isIgnoredDirName, + isSensitivePath, +} from './filter'; +import type { + FeedbackCodebaseFile, + FeedbackCodebaseLimitExceeded, + FeedbackCodebaseScanResult, +} from './types'; + +const execFileAsync = promisify(execFile); + +export interface ScanCodebaseLimits { + readonly maxFiles: number; + readonly maxFileSize: number; + readonly maxArchiveSize: number; +} + +export interface ScanCodebaseOptions { + readonly limits?: { + readonly maxFiles?: number; + readonly maxFileSize?: number; + readonly maxArchiveSize?: number; + }; + readonly signal?: AbortSignal; +} + +interface CollectedFiles { + readonly files: FeedbackCodebaseFile[]; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} + +export async function scanCodebase( + rootInput: string, + options: ScanCodebaseOptions = {}, +): Promise { + const root = resolve(rootInput); + const limits = resolveLimits(options.limits); + throwIfAborted(options.signal); + const usedGitIgnore = await isInsideGitWorkTree(root); + const collected = usedGitIgnore + ? await scanWithGit(root, limits, options.signal) + : await scanWithoutFilter(root, limits, options.signal); + const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); + + return { + root, + files: sortedFiles, + fingerprint: fingerprintFiles(sortedFiles), + usedGitIgnore, + exceedsLimit: collected.exceedsLimit, + }; +} + +function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits { + return { + maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES, + maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, + maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE, + }; +} + +async function isInsideGitWorkTree(root: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +async function scanWithGit( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const { stdout } = await execFileAsync( + 'git', + ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], + { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, + ); + + throwIfAborted(signal); + const relativePaths = splitNull(stdout); + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let totalSize = 0; + + for (const relativePath of relativePaths) { + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + break; + } + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + break; + } + files.push(file); + totalSize += file.size; + } + } + + return { files, exceedsLimit }; +} + +async function scanWithoutFilter( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let stopped = false; + let totalSize = 0; + + async function walk(dir: string): Promise { + if (stopped) return; + throwIfAborted(signal); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (stopped) return; + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + stopped = true; + return; + } + if (entry.isSymbolicLink()) continue; + const absolutePath = join(dir, entry.name); + if (entry.isDirectory()) { + if (isIgnoredDirName(entry.name)) continue; + await walk(absolutePath); + if (stopped) return; + continue; + } + if (!entry.isFile()) continue; + const relativePath = toPosixPath(relative(root, absolutePath)); + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + stopped = true; + return; + } + files.push(file); + totalSize += file.size; + } + } + } + + await walk(root); + return { files, exceedsLimit }; +} + +async function statFile(root: string, relativePath: string): Promise { + const absolutePath = resolve(root, relativePath); + // A tracked file can be deleted from the working tree but still listed by + // `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths + // like any other non-regular entry so one bad path does not abort the scan. + const stat = await lstat(absolutePath).catch(() => null); + if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null; + + return { + path: toPosixPath(relativePath), + absolutePath, + size: stat.size, + mtimeMs: stat.mtimeMs, + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const error = new Error('Codebase scan aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string { + const hash = createHash('sha256'); + for (const file of files) { + hash.update(file.path); + hash.update('\0'); + hash.update(String(file.size)); + hash.update('\0'); + hash.update(String(Math.trunc(file.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function splitNull(buffer: Buffer): string[] { + return buffer + .toString('utf8') + .split('\0') + .filter((item) => item.length > 0); +} + +function toPosixPath(value: string): string { + return value.split('\\').join('/'); +} diff --git a/apps/kimi-code/src/feedback/codebase/types.ts b/apps/kimi-code/src/feedback/codebase/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..a611d93a3f5c141f0d98bed3105376fcd8268db7 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/types.ts @@ -0,0 +1,19 @@ +export interface FeedbackCodebaseFile { + readonly path: string; + readonly absolutePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface FeedbackCodebaseLimitExceeded { + readonly reason: 'file-count' | 'total-size'; + readonly limit: number; +} + +export interface FeedbackCodebaseScanResult { + readonly root: string; + readonly files: readonly FeedbackCodebaseFile[]; + readonly fingerprint: string; + readonly usedGitIgnore: boolean; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} diff --git a/apps/kimi-code/src/feedback/feedback-attachments.ts b/apps/kimi-code/src/feedback/feedback-attachments.ts new file mode 100644 index 0000000000000000000000000000000000000000..df3b918abb8a63735a60a11707102f35aefc9838 --- /dev/null +++ b/apps/kimi-code/src/feedback/feedback-attachments.ts @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { detectInstallSource } from '#/cli/update/source'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts'; +import { getLogDir } from '#/utils/paths'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; + +import { createFeedbackArchivePath, type FeedbackArchive } from './archive'; +import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase'; +import { uploadArchive, type FeedbackUploadUrlApi } from './upload'; + +export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; +export const SESSION_ARCHIVE_FILENAME = 'session.zip'; + +const CODEBASE_SCAN_TIMEOUT_MS = 3000; + +/** + * Stage 3 of the `/feedback` flow: prepare and upload each requested attachment + * independently. Attachment failures are non-fatal because the text feedback + * already exists, but any requested artifact that cannot be prepared/uploaded + * is reported as a partial attachment failure instead of silently downgrading + * the request. + * + * Returns `true` when at least one requested attachment failed so the caller + * can surface a partial-failure status. + */ +export async function submitFeedbackWithAttachments( + host: SlashCommandHost, + feedbackId: number, + level: FeedbackAttachmentLevel, +): Promise { + const api = createFeedbackUploadApi(host); + + if (level === 'logs') { + const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId); + return !uploaded; + } + if (level === 'logs+codebase') { + const [sessionDir, scan] = await Promise.all([ + resolveCurrentSessionDir(host), + scanCodebaseForFeedback(host.state.appState.workDir), + ]); + const [uploadedSession, uploadedCodebase] = await Promise.all([ + prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir), + prepareAndUploadCodebaseArchive(api, feedbackId, scan), + ]); + return !uploadedSession || !uploadedCodebase; + } + return false; +} + +async function prepareAndUploadSessionArchive( + host: SlashCommandHost, + api: FeedbackUploadUrlApi, + feedbackId: number, + knownSessionDir?: string, +): Promise { + const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); + if (sessionDir === undefined) { + await logFeedbackUploadError(new Error('cannot locate the current session directory')); + return false; + } + return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => { + const exported = await host.harness.exportSession({ + id: host.state.appState.sessionId, + outputPath: archivePath, + includeGlobalLog: true, + version: host.state.appState.version, + installSource: await detectInstallSource(), + shellEnv: detectShellEnvironment(), + }); + return archiveFromExportedSession(exported.zipPath); + }); +} + +async function prepareAndUploadCodebaseArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + scan: FeedbackCodebaseScanResult | undefined, +): Promise { + if (scan === undefined) return false; + return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) => + packageCodebase(scan, archivePath), + ); +} + +/** + * Shared lifecycle for a single attachment: create a temp archive path, let + * `produce` write the archive to it, upload it, then always remove the temp + * directory — even when `produce` or the upload throws. Both the session log + * archive and the codebase archive flow through here so their cleanup and + * error handling cannot drift apart. Every failure — including archive-path + * creation — is contained here as a non-fatal partial failure. + */ +async function uploadProducedArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + filename: string, + produce: (archivePath: string) => Promise, +): Promise { + let cleanupDir: string | undefined; + try { + const target = await createFeedbackArchivePath(filename); + cleanupDir = target.cleanupDir; + const archive = await produce(target.archivePath); + await uploadArchive(api, { ...archive, cleanupDir: target.cleanupDir }, feedbackId, { + filename, + }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; + } finally { + if (cleanupDir !== undefined) { + await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +async function archiveFromExportedSession(zipPath: string): Promise { + const data = await readFile(zipPath); + const archiveStat = await stat(zipPath); + return { + path: zipPath, + size: archiveStat.size, + sha256: createHash('sha256').update(data).digest('hex'), + fingerprint: createHash('sha256').update(data).digest('hex'), + fileCount: 1, + }; +} + +async function resolveCurrentSessionDir(host: SlashCommandHost): Promise { + try { + const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); + return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; + } catch { + return undefined; + } +} + +async function scanCodebaseForFeedback( + workDir: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, CODEBASE_SCAN_TIMEOUT_MS); + try { + return await scanCodebase(workDir, { signal: controller.signal }); + } catch (error) { + await logFeedbackUploadError(error); + return undefined; + } finally { + clearTimeout(timer); + } +} + +async function logFeedbackUploadError(error: unknown): Promise { + try { + const logDir = getLogDir(); + await mkdir(logDir, { recursive: true }); + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); + } catch { + // best-effort logging only + } +} + +function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { + return { + async createUploadUrl(input) { + const res = await host.harness.auth.createFeedbackUploadUrl(input); + if (res.kind !== 'ok') throw new Error(res.message); + return { + uploadId: res.uploadId, + parts: res.parts, + }; + }, + async completeUpload(input) { + const res = await host.harness.auth.completeFeedbackUpload({ + uploadId: input.uploadId, + parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), + }); + if (res.kind !== 'ok') throw new Error(res.message); + }, + }; +} diff --git a/apps/kimi-code/src/feedback/upload.ts b/apps/kimi-code/src/feedback/upload.ts new file mode 100644 index 0000000000000000000000000000000000000000..629fc6a6f2efe1bf7112e9a652f16400399e368d --- /dev/null +++ b/apps/kimi-code/src/feedback/upload.ts @@ -0,0 +1,208 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; + +import type { FeedbackArchive } from './archive'; + +const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. +const DEFAULT_CONCURRENCY = 3; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_PART_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY_MS = 1_000; + +export interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +export interface CreateFeedbackUploadUrlResult { + readonly uploadId: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompletedUploadPart { + readonly partNumber: number; + readonly etag: string; +} + +export interface CompleteFeedbackUploadUrlInput { + readonly uploadId: number; + readonly parts: readonly CompletedUploadPart[]; +} + +export interface FeedbackUploadUrlApi { + createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise; + completeUpload(input: CompleteFeedbackUploadUrlInput): Promise; +} + +export interface UploadArchiveOptions { + /** Zip entry name sent to the backend. */ + readonly filename: string; + /** Abort a single part PUT if it does not complete within this many milliseconds. */ + readonly timeoutMs?: number; + /** Number of parts to upload concurrently (defaults to 3). */ + readonly concurrency?: number; + /** Per-part retry attempts after the first failure (defaults to 3). */ + readonly maxRetries?: number; + /** Called after each part finishes with the cumulative uploaded bytes. */ + readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void; +} + +export async function uploadArchive( + api: FeedbackUploadUrlApi, + archive: FeedbackArchive, + feedbackId: number, + options: UploadArchiveOptions, +): Promise { + if (archive.size > MAX_ARCHIVE_SIZE) { + throw new Error( + `Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + ); + } + const created = await api.createUploadUrl({ + feedbackId, + filename: options.filename, + size: archive.size, + sha256: archive.sha256, + }); + const completed = await uploadParts(archive.path, created.parts, archive.size, options); + await api.completeUpload({ uploadId: created.uploadId, parts: completed }); +} + +interface PartLayout { + readonly part: FeedbackUploadPart; + readonly start: number; +} + +function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] { + const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber); + let offset = 0; + return sorted.map((part) => { + const start = offset; + offset += part.size; + return { part, start }; + }); +} + +async function uploadParts( + filePath: string, + parts: readonly FeedbackUploadPart[], + totalBytes: number, + options: UploadArchiveOptions, +): Promise { + const layout = layoutParts(parts); + const results: CompletedUploadPart[] = Array.from({ length: layout.length }); + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length)); + let nextIndex = 0; + let uploadedBytes = 0; + + async function worker(): Promise { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= layout.length) return; + const entry = layout[index]; + if (entry === undefined) return; + const completed = await uploadOnePartWithRetry(filePath, entry, options); + results[index] = completed; + uploadedBytes += entry.part.size; + options.onProgress?.(uploadedBytes, totalBytes); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results; +} + +async function uploadOnePartWithRetry( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES); + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await uploadOnePart(filePath, layout, options); + } catch (error) { + lastError = error; + if (attempt === maxRetries || !isRetryable(error)) break; + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } + throw lastError; +} + +async function uploadOnePart( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const { part, start } = layout; + const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const stream = createReadStream(filePath, { start, end: start + part.size - 1 }); + try { + const res = await fetch(part.url, { + method: part.method, + body: Readable.toWeb(stream), + headers: { 'Content-Length': String(part.size) }, + duplex: 'half', + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new UploadPartHttpError(part.partNumber, res.status, text); + } + const etag = res.headers.get('etag'); + if (etag === null || etag.length === 0) { + throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`); + } + return { partNumber: part.partNumber, etag }; + } catch (error) { + stream.destroy(); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +class UploadPartHttpError extends Error { + constructor( + readonly partNumber: number, + readonly status: number, + readonly responseBody: string, + ) { + super( + `Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`, + ); + } +} + +function isRetryable(error: unknown): boolean { + if (error instanceof UploadPartHttpError) { + return error.status >= 500 || error.status === 408 || error.status === 429; + } + // Network errors and timeouts are retryable. + return true; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/apps/kimi-code/src/generated/vis-web-asset.d.ts b/apps/kimi-code/src/generated/vis-web-asset.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b87ddcf5570d75871ccddec39e5a324fe8f36d6 --- /dev/null +++ b/apps/kimi-code/src/generated/vis-web-asset.d.ts @@ -0,0 +1 @@ +export declare const VIS_WEB_GZIP_B64: string; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..f95d57c2a2867f0163c75c4559ad29b555f4c498 --- /dev/null +++ b/apps/kimi-code/src/main.ts @@ -0,0 +1,311 @@ +/** + * Kimi Code entry point. + * + * Parses CLI arguments via Commander.js, validates options, runs the + * outer update preflight, then delegates to the requested UI runner. + */ + +import { + createKimiHarness, + flushDiagnosticLogs, + installGlobalProxyDispatcher, + log, + resolveGlobalLogPath, + resolveKimiHome, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; +import { + installCrashHandlers, + setTelemetryContext, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; + +import { createProgram } from './cli/commands'; +import { finalizeHeadlessRun } from './cli/headless-exit'; +import { startupTrace } from './utils/startup-trace'; +import type { CLIOptions } from './cli/options'; +import { OptionConflictError, validateOptions } from './cli/options'; +import { runPrompt } from './cli/run-prompt'; +import { runShell } from './cli/run-shell'; +import { formatStartupError } from './cli/startup-error'; +import { runPluginNodeEntry } from './cli/sub/plugin-run-node'; +import { runUpdateDownloadCommand } from './cli/sub/update-download'; +import { handleUpgrade } from './cli/sub/upgrade'; +import { createCliTelemetryBootstrap, initializeCliTelemetry } from './cli/telemetry'; +import { runUpdatePreflight } from './cli/update/preflight'; +import { detectNativeInstall } from './cli/update/source'; +import { maybeRelaunchWithStagedNativeUpdate } from './cli/update/native-swap'; +import { createKimiCodeHostIdentity, getVersion } from './cli/version'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; +import { runHeadlessMigrate, type MigrateCommandOptions } from './migration/index'; +import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; +import { installMinidbTextBuildWorker } from './native/minidb-worker'; +import { installKapSearchWorker } from './native/search-worker'; +import { installNativeModuleHook } from './native/module-hook'; +import { runNativeAssetSmokeIfRequested } from './native/smoke'; + +/** + * Outcome of a CLI command run, reported back to the process entrypoint. + * + * `handleMainCommand` is a reusable, unit-tested handler — it must not terminate + * the process itself. It reports here whether a headless (`kimi -p`) run + * completed so the entrypoint (the only place that owns the process) can arm the + * force-exit fallback. + */ +export interface MainCommandOutcome { + readonly headlessCompleted: boolean; +} + +export async function handleMainCommand( + opts: CLIOptions, + version: string, +): Promise { + let validated: ReturnType; + startupTrace('main:enter'); + try { + validated = validateOptions(opts); + } catch (error) { + if (error instanceof OptionConflictError) { + process.stderr.write(`error: ${error.message}\n`); + process.exit(1); + } + throw error; + } + + startupTrace('preflight:begin'); + const preflightResult = await runUpdatePreflight( + version, + validated.uiMode === 'print' ? { track, isTTY: false } : { track }, + ); + startupTrace('preflight:end'); + if (preflightResult === 'exit') { + process.exit(0); + } + + if (validated.uiMode === 'print') { + await runPrompt(validated.options, version); + return { headlessCompleted: true }; + } + + startupTrace('runShell:begin'); + await runShell(validated.options, version); + return { headlessCompleted: false }; +} + +/** `kimi migrate`: launch the migration screen only, then exit. `--run` runs the full migration headlessly with step logs instead. */ +async function handleMigrateCommand( + version: string, + options: MigrateCommandOptions, +): Promise { + if (options.configOnly && !options.run) { + process.stderr.write('error: --config-only requires --run\n'); + process.exitCode = 2; + return; + } + if (options.run) { + // Set the exit code and return normally — an immediate process.exit here + // could terminate before buffered step/report output is flushed when the + // command is piped or redirected. + process.exitCode = await runHeadlessMigrate({ configOnly: options.configOnly }); + return; + } + await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); +} + +export async function handleUpgradeCommand(version: string, yes: boolean): Promise { + const telemetryBootstrap = createCliTelemetryBootstrap(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const harness = createKimiHarness({ + homeDir: telemetryBootstrap.homeDir, + identity: createKimiCodeHostIdentity(version), + telemetry: telemetryClient, + }); + let exitCode = 1; + try { + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + initializeCliTelemetry({ + harness, + bootstrap: telemetryBootstrap, + config, + version, + uiMode: CLI_UI_MODE, + }); + exitCode = await handleUpgrade(version, { track, logger: log, yes }); + } finally { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); + await harness.close().catch(() => {}); + } + process.exit(exitCode); +} + +/** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */ +const MIGRATE_CLI_OPTIONS: CLIOptions = { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], +}; + +export function main(): void { + process.title = PROCESS_NAME; + installCrashHandlers(); + // A staged native update is swapped in and re-exec'd here, before any other + // initialization, so the user session immediately runs the new binary (and + // the old process never replaces itself while running). Every failure path + // inside falls back to a normal startup with the current exe. + void maybeRelaunchWithStagedNativeUpdate({ + exePath: process.execPath, + argv: process.argv, + env: process.env, + currentVersion: getVersion(), + isNative: detectNativeInstall(), + }) + .catch(() => false) + .then((relaunched) => { + if (!relaunched) bootstrap(); + }); +} + +function bootstrap(): void { + // Route all outbound fetch through HTTP_PROXY/HTTPS_PROXY (honoring NO_PROXY) + // before any client is constructed. No-op when no proxy variable is set; an + // invalid proxy URL is reported and ignored rather than aborting startup. + installGlobalProxyDispatcher(); + installNativeModuleHook(); + // Best-effort SEA worker installation. Diagnostics are trace-only and avoid + // exposing the user's cache path; failure keeps MiniDb's bounded inline mode. + const workerInstall = installMinidbTextBuildWorker(); + startupTrace( + workerInstall.status === 'installed' + ? `minidb-worker:installed basename=${workerInstall.basename} sha256=${workerInstall.assetSha256}` + : workerInstall.status === 'failed' + ? `minidb-worker:failed code=${workerInstall.errorCode} sha256=${workerInstall.assetSha256 ?? 'unknown'}` + : `minidb-worker:${workerInstall.status}`, + ); + // Same pattern for the global-search worker: extracted from the SEA blob so + // the search index runs off the main thread; a failure leaves the search + // surface degraded ([database] search = false restores the inline host). + const searchWorkerInstall = installKapSearchWorker(); + startupTrace( + searchWorkerInstall.status === 'installed' + ? `search-worker:installed basename=${searchWorkerInstall.basename} sha256=${searchWorkerInstall.assetSha256}` + : searchWorkerInstall.status === 'failed' + ? `search-worker:failed code=${searchWorkerInstall.errorCode} sha256=${searchWorkerInstall.assetSha256 ?? 'unknown'}` + : `search-worker:${searchWorkerInstall.status}`, + ); + if (runNativeAssetSmokeIfRequested()) return; + + // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. + queueMicrotask(() => { + try { + cleanupStaleNativeCacheForCurrent(); + } catch { + // ignore: cache GC must never affect process startup + } + }); + + const version = getVersion(); + + const program = createProgram( + version, + (opts) => { + void handleMainCommand(opts, version) + .then(async (outcome) => { + // Only the process entrypoint disposes of the process. Print mode + // relies on the event loop draining to exit; flush any buffered output + // and then arm an unref'd fallback so a stray ref'd handle left over + // from the run can't wedge a completed `kimi -p` until an external + // timeout. A healthy run drains and exits before the fallback fires. + if (outcome.headlessCompleted) { + await finalizeHeadlessRun( + process, + [process.stdout, process.stderr], + () => Number(process.exitCode) || 0, + ); + } + }) + .catch(async (error: unknown) => { + // Set the failure exit code synchronously, before any `await`. The + // terminal `process.exit(1)` below is our intended exit, but it sits + // behind `await logStartupFailure(...)`; by the time we reach that + // await, the failed run's `finally` cleanup has already torn down its + // ref'd handles (sockets, timers, background tasks). If the event loop + // drains during the await, Node exits on its own with the DEFAULT code + // 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures + // would then exit 0 nondeterministically. Setting `process.exitCode` + // up front makes that drain-exit report failure too. + process.exitCode = 1; + const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; + await logStartupFailure(operation, error); + process.stderr.write( + formatStartupError(error, { + operation, + }), + ); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, + (migrateOptions) => { + void handleMigrateCommand(version, migrateOptions).catch(async (error: unknown) => { + await logStartupFailure('run migration', error); + process.stderr.write(formatStartupError(error, { operation: 'run migration' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, + (entry, args) => { + void runPluginNodeEntry(entry, args).catch(async (error: unknown) => { + await logStartupFailure('run plugin node entry', error); + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); + }, + (yes) => { + void handleUpgradeCommand(version, yes).catch(async (error: unknown) => { + await logStartupFailure('upgrade', error); + process.stderr.write(formatStartupError(error, { operation: 'upgrade' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, + (targetVersion, manual) => { + void runUpdateDownloadCommand(targetVersion, manual).then( + (code) => { + process.exit(code); + }, + async (error: unknown) => { + await logStartupFailure('download update', error); + process.exit(1); + }, + ); + }, + ); + + program.parse(process.argv); +} + +main(); + +async function logStartupFailure(operation: string, error: unknown): Promise { + log.error('startup failed', { operation, error }); + try { + await flushDiagnosticLogs(); + } catch { + // Best-effort diagnostic flush only. + } +} diff --git a/apps/kimi-code/src/migration/badge.ts b/apps/kimi-code/src/migration/badge.ts new file mode 100644 index 0000000000000000000000000000000000000000..47b3fabb61a0de2695dbab557b7ec38860a2efea --- /dev/null +++ b/apps/kimi-code/src/migration/badge.ts @@ -0,0 +1,27 @@ +/** + * Pure helpers for composing session labels in the session picker. + * + * Detection rule for the `[imported]` badge: `metadata.imported_from_kimi_cli` + * is strictly the boolean `true`. This mirrors the value written by + * `migration-legacy` into the session's `state.json` `custom` block. + */ + +const IMPORTED_BADGE = '[imported]'; +const IMPORTED_FLAG_KEY = 'imported_from_kimi_cli'; + +export interface SessionLabelInput { + readonly title: string; + readonly metadata?: Readonly> | undefined; +} + +export function isImportedSession( + metadata: Readonly> | undefined, +): boolean { + if (metadata === undefined) return false; + return metadata[IMPORTED_FLAG_KEY] === true; +} + +export function formatSessionLabel(input: SessionLabelInput): string { + const prefix = isImportedSession(input.metadata) ? `${IMPORTED_BADGE} ` : ''; + return `${prefix}${input.title}`; +} diff --git a/apps/kimi-code/src/migration/command.ts b/apps/kimi-code/src/migration/command.ts new file mode 100644 index 0000000000000000000000000000000000000000..528873cf5f8eed8467a90cce3522739871461efa --- /dev/null +++ b/apps/kimi-code/src/migration/command.ts @@ -0,0 +1,28 @@ +import type { Command } from 'commander'; + +export interface MigrateCommandOptions { + readonly run: boolean; + readonly configOnly: boolean; +} + +export function registerMigrateCommand( + parent: Command, + onMigrate: (options: MigrateCommandOptions) => void, +): void { + parent + .command('migrate') + .description('Migrate data from a legacy kimi-cli installation into kimi-code.') + .option( + '--run', + 'Run the migration non-interactively and print step-by-step logs. Migrates everything unless --config-only is also given.', + false, + ) + .option( + '--config-only', + 'With --run: migrate config, MCP servers, REPL history and skills, but skip chat sessions.', + false, + ) + .action((options: { run?: boolean; configOnly?: boolean }) => { + onMigrate({ run: options.run === true, configOnly: options.configOnly === true }); + }); +} diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ad58fc1747d2e968ac89abb4aff53e3f59abe0e --- /dev/null +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -0,0 +1,71 @@ +/** + * Pre-TUI detection: decide whether a first-launch migration screen should be + * shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to + * drive the screen, or null when there is nothing to offer. + */ +import { existsSync } from 'node:fs'; + +import { + detectMigration, + shouldSuppressMigration, + type MigrationPlan, +} from '@moonshot-ai/migration-legacy'; + +export interface DetectPendingInput { + readonly sourceHome: string; + readonly targetHome: string; + readonly skillsSourceHome?: string; + /** + * kimi-cli keeps plan files at `~/.kimi/plans` regardless of KIMI_SHARE_DIR, + * so detection reads them from the user's home by default. Injectable for + * isolated tests. + */ + readonly plansSourceHome?: string; + /** + * When true, skip the marker-based suppression (`.migrated-to-kimi-code` / + * `.skip-migration-from-kimi-cli`). The explicit `kimi migrate` command sets + * this so a deliberate invocation always runs regardless of prior runs. + */ + readonly ignoreMarker?: boolean; +} + +export async function detectPendingMigration( + input: DetectPendingInput, +): Promise { + const { sourceHome, targetHome } = input; + if (!existsSync(sourceHome)) return null; + if ( + input.ignoreMarker !== true && + shouldSuppressMigration({ sourceHome, targetHome }) + ) { + return null; + } + + let plan: MigrationPlan; + try { + plan = await detectMigration({ + sourcePath: sourceHome, + skillsSourcePath: input.skillsSourceHome, + plansSourcePath: input.plansSourceHome, + }); + } catch { + // Detection failure must never block startup; skip the screen. + return null; + } + + // OAuth credentials are deliberately not migrated, so an install whose + // only data is `credentials/*.json` has nothing to offer — kimi-code's own + // /login flow will pick up the auth conversation when the user first uses + // the app. Treat oauth-only as "nothing to migrate". + const nothingToMigrate = + plan.totalSessions === 0 && + !plan.hasConfig && + !plan.hasMcp && + !plan.hasUserHistory && + !plan.hasSkills && + !plan.hasPlans && + (plan.sessionScanFailures?.length ?? 0) === 0; + if (nothingToMigrate) return null; + + return plan; +} diff --git a/apps/kimi-code/src/migration/index.ts b/apps/kimi-code/src/migration/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d84b467ee1efb76bb05a38030aff96c8b5eefa6d --- /dev/null +++ b/apps/kimi-code/src/migration/index.ts @@ -0,0 +1,18 @@ +/** + * kimi-cli → kimi-code migration: host integration surface. + * + * Removable glue: the `kimi migrate` sub-command, the first-launch detection, + * the native pi-tui migration screen, and the session-picker `[imported]` + * badge helper. Migration logic itself lives in + * `@moonshot-ai/migration-legacy`. + */ +export { registerMigrateCommand, type MigrateCommandOptions } from './command'; +export { formatSessionLabel, isImportedSession, type SessionLabelInput } from './badge'; +export { detectPendingMigration } from './detect-pending'; +export { MIGRATE_HEADLESS_EXIT, runHeadlessMigrate } from './run-headless'; +export { + resolveLegacySourceHome, + sameLegacyPath, + type LegacySourceResolution, +} from './legacy-source'; +export { MigrationScreenComponent, type MigrationScreenResult } from './migration-screen'; diff --git a/apps/kimi-code/src/migration/legacy-source.ts b/apps/kimi-code/src/migration/legacy-source.ts new file mode 100644 index 0000000000000000000000000000000000000000..268d11e1ade641474d244a43ddcaac08cb4ea648 --- /dev/null +++ b/apps/kimi-code/src/migration/legacy-source.ts @@ -0,0 +1,29 @@ +import { isAbsolute, join, resolve, win32 } from 'node:path'; + +export interface LegacySourceResolution { + readonly sourceHome: string; + readonly origin: 'default' | 'share-dir'; + readonly skillsSourceHome?: string; +} + +export function resolveLegacySourceHome( + env: NodeJS.ProcessEnv, + home: string, + cwd: string, +): LegacySourceResolution { + const defaultHome = join(home, '.kimi'); + const shareDir = env['KIMI_SHARE_DIR']; + if (shareDir === undefined || shareDir.trim() === '') { + return { sourceHome: defaultHome, origin: 'default' }; + } + const sourceHome = isAbsolute(shareDir) ? resolve(shareDir) : resolve(cwd, shareDir); + const skillsSourceHome = sourceHome === defaultHome ? undefined : defaultHome; + return { sourceHome, origin: 'share-dir', skillsSourceHome }; +} + +export function sameLegacyPath(left: string, right: string): boolean { + if (process.platform === 'win32') { + return win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase(); + } + return resolve(left) === resolve(right); +} diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a26d85ab7466cb304d261c3fced0a5c387a61b7 --- /dev/null +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -0,0 +1,583 @@ +/** + * MigrationScreenComponent — native pi-tui first-launch migration experience. + * + * A single mounted Container & Focusable that runs a 3-phase state machine: + * ask (2-step choice wizard) -> progress -> result + * + * Pure decision mapping (choices -> MigrationScope) is delegated to the + * package's `resolveMigrationScope`. Rendering follows the `ChoicePicker` + * conventions in `apps/kimi-code/src/tui/components/dialogs/choice-picker.ts`. + * + * This file implements the ask, progress, and result phases. `beginMigration` + * drives the real runMigration flow (injectable for tests). + */ +import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { + resolveMigrationScope, + runMigration as realRunMigration, + type AnyChoice, + type MigrationPlan, + type MigrationPromptResult, + type MigrationReport, + type MigrationScope, + type Prompt1Choice, + type Prompt2Choice, + type RunMigrationInput, +} from '@moonshot-ai/migration-legacy'; + +type Phase = 'ask1' | 'ask2' | 'progress' | 'result'; + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const; + +/** Spinner frame cadence — one full braille cycle every ~800ms. */ +const SPINNER_INTERVAL_MS = 80; + +const STEP_LABELS: ReadonlyArray = [ + ['config', 'Config'], + ['mcp', 'MCP'], + ['user-history', 'REPL history'], + ['sessions', 'Sessions'], +]; + +export interface MigrationScreenOptions { + readonly plan: MigrationPlan; + readonly sourceHome: string; + readonly targetHome: string; + readonly colors?: ColorPalette; + /** Called once the screen is finished; the host then restores the editor. */ + readonly onComplete: (result: MigrationScreenResult) => void; + /** Triggers a re-render; the host wires this to `ui.requestRender()`. */ + readonly requestRender?: () => void; + /** Injectable for tests; defaults to the package's runMigration. */ + readonly runMigration?: (input: RunMigrationInput) => Promise; + /** + * When true, the screen starts at the scope question and skips the + * now/later/never gate — used by the explicit `kimi migrate` command, where + * invoking the command is itself the decision to migrate. + */ + readonly skipDecisionStep?: boolean; +} + +/** What the screen reports back to the host when finished. */ +export interface MigrationScreenResult { + readonly decision: 'now' | 'later' | 'never'; + /** Resolved migration scope; present only when decision === 'now'. */ + readonly scope?: MigrationScope; + // present only when decision === 'now' and migration ran + readonly migrated?: boolean; +} + +interface StepDef { + readonly title: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value: AnyChoice }>; +} + +export class MigrationScreenComponent extends Container implements Focusable { + focused = false; + private readonly opts: MigrationScreenOptions; + private phase: Phase = 'ask1'; + private selectedIndex = 0; + private readonly choices: AnyChoice[] = []; + private progressDone = 0; + private progressTotal = 0; + private readonly stepStatus = new Map([ + ['config', 'pending'], + ['mcp', 'pending'], + ['user-history', 'pending'], + ['sessions', 'pending'], + ]); + private spinnerFrame = 0; + private spinnerTimer: ReturnType | undefined; + private report: MigrationReport | undefined; + private migrationFailed = false; + private migrationFailureReason: string | undefined; + + constructor(opts: MigrationScreenOptions) { + super(); + this.opts = opts; + if (opts.skipDecisionStep === true) { + // Explicit `kimi migrate`: the now/later/never gate is meaningless, so + // start at the scope question with the decision already fixed to 'now'. + this.phase = 'ask2'; + this.choices.push('now'); + } + } + + /** Host calls this once runMigration resolves. */ + showResult(report: MigrationReport): void { + this.report = report; + this.phase = 'result'; + this.stopSpinner(); + } + + /** Host calls this if runMigration threw. */ + showFailure(error?: unknown): void { + this.migrationFailed = true; + this.migrationFailureReason = formatMigrationFailureReason(error); + this.phase = 'result'; + this.stopSpinner(); + } + + /** Host calls this when migration starts. */ + enterProgress(): void { + this.phase = 'progress'; + } + + /** Host wires this to runMigration's onProgress (step-level messages). */ + reportStep(msg: string): void { + // msg is like 'config done', 'mcp done', 'sessions done' + const key = msg.replace(/ done$/, ''); + if (this.stepStatus.has(key)) this.stepStatus.set(key, 'done'); + } + + /** Host wires this to runMigration's onSessionProgress. */ + reportSessionProgress(done: number, total: number): void { + this.progressDone = done; + this.progressTotal = total; + } + + // The braille spinner advances on its own timer so the progress screen stays + // visibly alive even while a single step (e.g. session translation) runs for + // a while without emitting progress events. Runs only for the progress + // phase: started on entering it, stopped the moment it ends. + private startSpinner(): void { + this.stopSpinner(); + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; + this.opts.requestRender?.(); + }, SPINNER_INTERVAL_MS); + // A decorative timer must never keep the process alive on its own. + this.spinnerTimer.unref(); + } + + private stopSpinner(): void { + if (this.spinnerTimer !== undefined) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; + } + } + + // test hooks (thin aliases so tests don't depend on host wiring) + _testEnterProgress(): void { + this.enterProgress(); + } + _testUpdateStep(msg: string): void { + this.reportStep(msg); + } + _testUpdateSessionProgress(done: number, total: number): void { + this.reportSessionProgress(done, total); + } + _testShowResult(report: MigrationReport): void { + this.showResult(report); + } + + handleInput(data: string): void { + if (this.phase === 'ask1' || this.phase === 'ask2') { + this.handleAskInput(data); + return; + } + if (this.phase === 'result') { + if (matchesKey(data, Key.enter)) { + this.opts.onComplete({ decision: 'now', migrated: !this.migrationFailed }); + } + return; + } + // progress phase: ignore input + } + + private currentStep(): StepDef { + return stepFor(this.phase, this.opts.plan); + } + + private handleAskInput(data: string): void { + const step = this.currentStep(); + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(step.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.escape)) { + // Esc anywhere in ask == "later" + this.opts.onComplete({ decision: 'later' }); + return; + } + if (matchesKey(data, Key.enter)) { + const chosen = step.options[this.selectedIndex]; + if (chosen === undefined) return; + this.advance(chosen.value); + return; + } + } + + /** Apply a chosen value and move the state machine forward. */ + private advance(value: AnyChoice): void { + this.choices.push(value); + this.selectedIndex = 0; + + const result: MigrationPromptResult = resolveMigrationScope(this.choices); + if (this.phase === 'ask1') { + if (value === 'now') { + this.phase = 'ask2'; + return; + } + // 'later' | 'never' + this.opts.onComplete({ decision: value as 'later' | 'never' }); + return; + } + // ask2 — either choice resolves the full scope; run migration immediately. + this.beginMigration(result); + } + + /** Enter the progress phase and run the migration to completion. */ + private beginMigration(result: MigrationPromptResult): void { + if (result.decision !== 'now' || result.scope === undefined) { + this.opts.onComplete({ decision: 'later' }); + return; + } + this.enterProgress(); + this.startSpinner(); + this.opts.requestRender?.(); + const run = this.opts.runMigration ?? realRunMigration; + void run({ + plan: this.opts.plan, + scope: result.scope, + source: this.opts.sourceHome, + target: this.opts.targetHome, + onProgress: (msg) => { + this.reportStep(msg); + this.opts.requestRender?.(); + }, + onSessionProgress: (done, total) => { + this.reportSessionProgress(done, total); + this.opts.requestRender?.(); + }, + }).then( + (report) => { + this.showResult(report); + this.opts.requestRender?.(); + }, + (error) => { + this.showFailure(error); + this.opts.requestRender?.(); + }, + ); + } + + override render(width: number): string[] { + if (this.phase === 'ask1' || this.phase === 'ask2') { + return this.renderAsk(width); + } + if (this.phase === 'progress') return this.renderProgress(width); + return this.renderResult(width); + } + + private renderResult(width: number): string[] { + const colors = this.opts.colors ?? currentTheme.palette; + const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + if (this.migrationFailed) { + lines.push(chalk.hex(colors.error).bold(' Migration failed')); + if (this.migrationFailureReason !== undefined) { + lines.push(''); + lines.push(chalk.hex(colors.text)(` Reason: ${this.migrationFailureReason}`)); + } + lines.push(''); + lines.push(chalk.hex(colors.text)(' You can retry later by running "kimi migrate".')); + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to kimi-code')); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + const r = this.report; + lines.push(chalk.hex(colors.primary).bold(' Migration complete')); + lines.push(''); + if (r !== undefined) { + const sum = r.summary; + if (sum.sessions.sessionsMigrated > 0) { + lines.push( + chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsMigrated} sessions migrated`), + ); + } + if (sum.plans.copied > 0) { + lines.push(chalk.hex(colors.success)(` ✓ ${sum.plans.copied} plan files copied`)); + } + // Only claim a data class was migrated when the summary says it was — + // a skipped/failed step (e.g. malformed config.toml) must not show ✓. + const migratedKinds: string[] = []; + if (sum.config.migrated) migratedKinds.push('config'); + if (sum.config.migratedHooks > 0) migratedKinds.push('hooks'); + if (sum.mcp.mergedServers.length > 0) migratedKinds.push('MCP'); + if (sum.userHistory.copied > 0) migratedKinds.push('REPL history'); + if (sum.skills.copied > 0) migratedKinds.push('skills'); + if (migratedKinds.length > 0) { + lines.push(chalk.hex(colors.success)(` ✓ ${migratedKinds.join(' · ')}`)); + } + if ( + sum.sessions.sessionsMigrated === 0 && + sum.plans.copied === 0 && + migratedKinds.length === 0 + ) { + lines.push(chalk.hex(colors.textMuted)(' Nothing needed migrating.')); + } + if (r.notices.detectedPlugins.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${r.notices.detectedPlugins.length} kimi-cli plugins — not yet supported for migration`, + ), + ); + } + if (r.notices.plansCopiedNotice !== null) { + lines.push(chalk.hex(colors.textMuted)(` ⓘ ${r.notices.plansCopiedNotice}`)); + } + // OAuth credentials are deliberately not migrated (refresh tokens cannot + // safely be held by two installs at once). kimi-code's normal auth flow + // will prompt for /login when the user first picks a model — surfacing a + // separate notice here reads as a migration limitation, which it is not. + if (sum.config.droppedHooks > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.config.droppedHooks} hooks dropped (incompatible)`, + ), + ); + } + // Conflicts and partial failures: the report records them, so surface + // them here too — otherwise "✓ config / MCP" hides that the data only + // landed in a *.migrated-from-kimi-cli.* sibling or that sessions failed. + if (sum.config.configConflicts.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.config.configConflicts.length} config conflicts kept yours: ${sum.config.configConflicts.join(' · ')}`, + ), + ); + } + if (sum.config.wroteSiblingDueToConflict) { + // Sibling mode: the live config.toml could not be parsed, so the + // migrated content went to `config.migrated-from-kimi-cli.toml` and + // the user must merge it by hand. Show the enumeration of contents + // on a SEPARATE line below — a single-line message with the contents + // appended would overflow 80 columns and be truncated, silently + // hiding the very info we want users to see. + lines.push( + chalk.hex(colors.warning)( + ' ⚠ config.toml could not be parsed — review config.migrated-from-kimi-cli.toml', + ), + ); + const sc = sum.config.siblingContents; + const items: string[] = []; + if (sc.providers.length > 0) { + items.push(`${sc.providers.length} provider${sc.providers.length === 1 ? '' : 's'}`); + } + if (sc.models.length > 0) { + items.push(`${sc.models.length} model${sc.models.length === 1 ? '' : 's'}`); + } + if (sc.hooks > 0) { + items.push(`${sc.hooks} hook${sc.hooks === 1 ? '' : 's'}`); + } + if (items.length > 0) { + lines.push(chalk.hex(colors.warning)(` contains: ${items.join(', ')}`)); + } + } + if (sum.config.wroteTuiSibling) { + lines.push( + chalk.hex(colors.warning)( + ' ⚠ tui.toml conflicted — review tui.migrated-from-kimi-cli.toml', + ), + ); + } + if (sum.mcp.wroteSiblingDueToConflict) { + lines.push( + chalk.hex(colors.warning)( + ' ⚠ mcp.json unreadable — review mcp.migrated-from-kimi-cli.json', + ), + ); + } + if (r.notices.mcpOauthServersRequiringReauth.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${r.notices.mcpOauthServersRequiringReauth.length} MCP servers need re-authentication`, + ), + ); + } + if (sum.sessions.sessionsFailed.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.sessions.sessionsFailed.length} sessions failed to migrate`, + ), + ); + } + if (sum.sessions.sessionsConflicts.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.sessions.sessionsConflicts.length} sessions skipped (target already occupied)`, + ), + ); + } + // Empty / user-cleared sessions carry no conversation — neutral info, + // not a failure, so it is shown muted rather than as a ⚠ warning. + if (sum.sessions.sessionsSkippedEmpty > 0) { + lines.push( + chalk.hex(colors.textMuted)( + ` ${sum.sessions.sessionsSkippedEmpty} empty sessions skipped`, + ), + ); + } + lines.push(''); + lines.push( + chalk.hex(colors.textMuted)(` Old data kept at ${this.opts.sourceHome} — kimi-cli still works.`), + ); + } + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to kimi-code')); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + + private renderProgress(width: number): string[] { + const colors = this.opts.colors ?? currentTheme.palette; + const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Migrating from kimi-cli'), + '', + ]; + if (this.progressTotal > 0) { + lines.push( + chalk.hex(colors.accent)(` ${spinner} `) + + chalk.hex(colors.text)( + `Translating sessions… ${this.progressDone} / ${this.progressTotal}`, + ), + ); + lines.push(''); + } + for (const [key, label] of STEP_LABELS) { + const status = this.stepStatus.get(key) ?? 'pending'; + const mark = + status === 'done' + ? chalk.hex(colors.success)('✓') + : chalk.hex(colors.textDim)('◐'); + lines.push(` ${mark} ${chalk.hex(colors.text)(label)}`); + } + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + + private renderAsk(width: number): string[] { + const colors = this.opts.colors ?? currentTheme.palette; + const step = this.currentStep(); + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Migrate from kimi-cli'), + '', + ]; + if (this.phase === 'ask1') { + lines.push(chalk.hex(colors.text)(' Found an existing kimi-cli installation:')); + lines.push(chalk.hex(colors.textMuted)(` ${summarizePlan(this.opts.plan)}`)); + lines.push(''); + } + lines.push(chalk.hex(colors.text)(` ${step.title}`)); + lines.push(''); + for (let i = 0; i < step.options.length; i++) { + const opt = step.options[i]!; + const isSel = i === this.selectedIndex; + const pointer = isSel ? '❯' : ' '; + const labelStyle = isSel ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + lines.push( + chalk.hex(isSel ? colors.primary : colors.textDim)(` ${pointer} `) + + labelStyle(opt.label), + ); + } + lines.push(''); + lines.push( + chalk.hex(colors.textMuted)( + ` ↑/↓ move · ⏎ select · esc ${this.opts.skipDecisionStep === true ? 'cancel' : 'later'}`, + ), + ); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } +} + +function formatMigrationFailureReason(error: unknown): string | undefined { + let reason: string | undefined; + if (error instanceof Error) { + reason = error.message !== '' ? error.message : error.name; + } else if (typeof error === 'string') { + reason = error; + } else if (typeof error === 'object' && error !== null) { + const maybeMessage = (error as { readonly message?: unknown }).message; + if (typeof maybeMessage === 'string' && maybeMessage !== '') { + reason = maybeMessage; + } + } + if (reason === undefined) { + switch (typeof error) { + case 'number': + case 'boolean': + case 'bigint': + reason = `${error}`; + break; + case 'symbol': + reason = + error.description !== undefined ? `Symbol(${error.description})` : 'Symbol rejection'; + break; + case 'function': + reason = error.name !== '' ? `Function ${error.name}` : 'Function rejection'; + break; + case 'object': + if (error !== null) reason = 'Object rejection'; + break; + case 'undefined': + break; + case 'string': + break; + } + } + const trimmed = reason?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +function summarizePlan(plan: MigrationPlan): string { + const parts: string[] = []; + if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`); + if (plan.hasConfig) parts.push('config'); + if (plan.hasMcp) parts.push('mcp.json'); + if (plan.hasUserHistory) parts.push('REPL history'); + if (plan.hasSkills) parts.push('skills'); + const scanFailures = plan.sessionScanFailures?.length ?? 0; + if (scanFailures > 0) parts.push(`${scanFailures} unreadable`); + return parts.join(' · '); +} + +function stepFor(phase: Phase, plan: MigrationPlan): StepDef { + if (phase === 'ask1') { + return { + title: 'Migrate this data to kimi-code?', + options: [ + { label: 'Migrate now', value: 'now' satisfies Prompt1Choice }, + { label: 'Ask me later', value: 'later' satisfies Prompt1Choice }, + { label: 'Never ask again', value: 'never' satisfies Prompt1Choice }, + ], + }; + } + // ask2 — the second option carries the actual session count so users can see + // the cost they are signing up for. Falls back to the singular "sessions" + // word only (no count) when no sessions were detected. + const sessionsLabel = + plan.totalSessions > 0 + ? `Config + ${plan.totalSessions} sessions` + : 'Config + all sessions'; + return { + title: 'Migrate chat sessions too? (they are bulky and slower)', + options: [ + { label: 'Config only', value: 'config-only' satisfies Prompt2Choice }, + { label: sessionsLabel, value: 'all-sessions' satisfies Prompt2Choice }, + ], + }; +} diff --git a/apps/kimi-code/src/migration/run-headless.ts b/apps/kimi-code/src/migration/run-headless.ts new file mode 100644 index 0000000000000000000000000000000000000000..491d560fd214264397cfb7351f64cba52f897501 --- /dev/null +++ b/apps/kimi-code/src/migration/run-headless.ts @@ -0,0 +1,211 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk'; +import { + runMigration, + type MigrationPlan, + type MigrationReport, + type MigrationScope, +} from '@moonshot-ai/migration-legacy'; + +import { detectPendingMigration } from './detect-pending'; +import { resolveLegacySourceHome, sameLegacyPath } from './legacy-source'; + +export const MIGRATE_HEADLESS_EXIT = { + success: 0, + incomplete: 1, + error: 2, +} as const; + +export interface HeadlessMigrateDeps { + readonly env: NodeJS.ProcessEnv; + readonly userHome: string; + readonly cwd: string; + readonly targetHome: string; + readonly write: (line: string) => void; +} + +export interface HeadlessMigrateInput { + readonly configOnly: boolean; +} + +function defaultWrite(line: string): void { + process.stdout.write(`${line}\n`); +} + +function timestamp(): string { + return new Date().toISOString().slice(11, 19); +} + +export async function runHeadlessMigrate( + input: HeadlessMigrateInput, + deps?: Partial, +): Promise { + const resolved: HeadlessMigrateDeps = { + env: deps?.env ?? process.env, + userHome: deps?.userHome ?? homedir(), + cwd: deps?.cwd ?? process.cwd(), + targetHome: deps?.targetHome ?? resolveKimiHome(), + write: deps?.write ?? defaultWrite, + }; + const log = (msg: string): void => { + resolved.write(`[kimi-migrate ${timestamp()}] ${msg}`); + }; + + const source = resolveLegacySourceHome(resolved.env, resolved.userHome, resolved.cwd); + log(`source: ${source.sourceHome} (${source.origin === 'share-dir' ? 'KIMI_SHARE_DIR' : 'default ~/.kimi'})`); + if (source.skillsSourceHome !== undefined) { + log(`skills source: ${source.skillsSourceHome} (kimi-cli skills are not relocated by KIMI_SHARE_DIR)`); + } + log(`target: ${resolved.targetHome}`); + + if (sameLegacyPath(source.sourceHome, resolved.targetHome)) { + log('error: source and target are the same directory; refusing to migrate'); + return MIGRATE_HEADLESS_EXIT.error; + } + + const scope: MigrationScope = { + config: true, + mcp: true, + userHistory: true, + skills: true, + sessions: !input.configOnly, + }; + log(`scope: ${input.configOnly ? 'config-only (config, mcp, user-history, skills)' : 'full (config, mcp, user-history, skills, sessions)'}`); + + log('detecting legacy data…'); + const plansSourceHome = join(resolved.userHome, '.kimi', 'plans'); + const plan = await detectPendingMigration({ + sourceHome: source.sourceHome, + skillsSourceHome: source.skillsSourceHome, + targetHome: resolved.targetHome, + plansSourceHome, + ignoreMarker: true, + }); + if (plan === null) { + log(`nothing to migrate from ${source.sourceHome}`); + return MIGRATE_HEADLESS_EXIT.success; + } + logPlan(plan, log); + + let report: MigrationReport; + try { + report = await runMigration({ + plan, + scope, + source: source.sourceHome, + target: resolved.targetHome, + plansSourceDir: plansSourceHome, + onProgress: (msg) => log(`step: ${msg}`), + onSessionProgress: (done, total) => log(`sessions: translating ${done}/${total}`), + }); + } catch (error) { + log(`error: migration crashed: ${error instanceof Error ? error.message : String(error)}`); + return MIGRATE_HEADLESS_EXIT.error; + } + + const complete = logReport(report, scope, log); + log(`report written to ${resolved.targetHome}/migration-report.json`); + log(`run log appended to ${resolved.targetHome}/migration-errors.log`); + if (complete) { + log('result: complete — completion marker written; future launches will not re-prompt'); + return MIGRATE_HEADLESS_EXIT.success; + } + log('result: incomplete — no completion marker; the next launch will offer migration again'); + return MIGRATE_HEADLESS_EXIT.incomplete; +} + +function logPlan(plan: MigrationPlan, log: (msg: string) => void): void { + const scanFailures = plan.sessionScanFailures?.length ?? 0; + log( + `detected: ${plan.totalSessions} sessions across ${plan.workdirs.length} workdirs` + + ` · config=${plan.hasConfig} · mcp=${plan.hasMcp} · user-history=${plan.hasUserHistory} · skills=${plan.hasSkills}` + + (scanFailures > 0 ? ` · ${scanFailures} unreadable session stores` : ''), + ); + for (const failure of plan.sessionScanFailures ?? []) { + log(` unreadable: ${failure.sourcePath} — ${failure.reason}`); + } + if (plan.oauthCredentials.length > 0) { + log(`oauth logins requiring re-login after migration: ${plan.oauthCredentials.join(', ')}`); + } + if (plan.detectedMcpOauthServers.length > 0) { + log(`MCP servers requiring re-authentication: ${plan.detectedMcpOauthServers.join(', ')}`); + } + if (plan.detectedPlugins.length > 0) { + log(`kimi-cli plugins (not migrated): ${plan.detectedPlugins.join(', ')}`); + } +} + +function logReport( + report: MigrationReport, + scope: MigrationScope, + log: (msg: string) => void, +): boolean { + const sum = report.summary; + const c = sum.config; + log( + `config: migrated=${c.migrated} tui-extracted=${c.tuiExtracted}` + + ` hooks-migrated=${c.migratedHooks} hooks-dropped=${c.droppedHooks}` + + (c.droppedProviders.length > 0 ? ` dropped-providers=[${c.droppedProviders.join(', ')}]` : '') + + (c.droppedModels.length > 0 ? ` dropped-models=[${c.droppedModels.join(', ')}]` : '') + + (c.droppedKeys.length > 0 ? ` dropped-keys=[${c.droppedKeys.join(', ')}]` : '') + + (c.configConflicts.length > 0 ? ` conflicts-kept-yours=[${c.configConflicts.join(', ')}]` : '') + + (c.sourceUnreadable ? ' SOURCE-UNREADABLE' : ''), + ); + if (c.wroteSiblingDueToConflict) { + log(`config: live config.toml unparseable — migrated copy at config.migrated-from-kimi-cli.toml (${c.siblingContents.providers.length} providers, ${c.siblingContents.models.length} models, ${c.siblingContents.hooks} hooks)`); + } + if (c.wroteTuiSibling) { + log('config: tui.toml conflicted — migrated copy at tui.migrated-from-kimi-cli.toml'); + } + const m = sum.mcp; + log( + `mcp: merged=[${m.mergedServers.join(', ')}]` + + (m.keptNewForConflicts.length > 0 ? ` kept-existing=[${m.keptNewForConflicts.join(', ')}]` : '') + + (m.droppedServers.length > 0 ? ` dropped=[${m.droppedServers.join(', ')}]` : '') + + (m.wroteSiblingDueToConflict ? ' wrote mcp.migrated-from-kimi-cli.json' : '') + + (m.sourceUnreadable ? ' SOURCE-UNREADABLE' : ''), + ); + log(`user-history: copied=${sum.userHistory.copied} skipped-existing=${sum.userHistory.skippedExisting}`); + log(`skills: copied=${sum.skills.copied} skipped-existing=${sum.skills.skippedExisting}`); + log(`plans: copied=${sum.plans.copied} skipped-existing=${sum.plans.skippedExisting}`); + const s = sum.sessions; + if (scope.sessions) { + log( + `sessions: scanned=${s.bucketsScanned} attempted=${s.sessionsAttempted} migrated=${s.sessionsMigrated}` + + ` already-migrated=${s.sessionsAlreadyMigrated} skipped-empty=${s.sessionsSkippedEmpty}` + + ` skipped-malformed=${s.sessionsSkippedMalformed} skipped-placeholder=${s.sessionsSkippedPlaceholder}` + + ` failed=${s.sessionsFailed.length} conflicts=${s.sessionsConflicts.length}` + + (s.bucketsSkippedNonlocalKaos > 0 ? ` buckets-skipped-nonlocal-kaos=${s.bucketsSkippedNonlocalKaos}` : '') + + (s.bucketsSkippedNoWorkdirFound > 0 ? ` buckets-skipped-no-workdir=${s.bucketsSkippedNoWorkdirFound}` : ''), + ); + for (const failure of s.sessionsFailed) { + log(` failed: ${failure.sourcePath} — ${failure.reason}`); + } + for (const conflict of s.sessionsConflicts) { + log(` conflict: ${conflict.sourcePath} — target occupied: ${conflict.targetPath}`); + } + } + if (report.notices.oauthLoginsRequiringRelogin.length > 0) { + log(`notice: run /login for: ${report.notices.oauthLoginsRequiringRelogin.join(', ')}`); + } + if (report.notices.mcpOauthServersRequiringReauth.length > 0) { + log(`notice: re-authenticate MCP servers: ${report.notices.mcpOauthServersRequiringReauth.join(', ')}`); + } + if (report.notices.configConflictNotice !== null) { + log(`notice: ${report.notices.configConflictNotice}`); + } + if (report.notices.tuiConflictNotice !== null) { + log(`notice: ${report.notices.tuiConflictNotice}`); + } + if (report.notices.plansCopiedNotice !== null) { + log(`notice: ${report.notices.plansCopiedNotice}`); + } + return ( + s.sessionsFailed.length === 0 && + s.sessionsConflicts.length === 0 && + !(scope.config && c.sourceUnreadable) && + !(scope.mcp && m.sourceUnreadable) + ); +} diff --git a/apps/kimi-code/src/native/minidb-worker.ts b/apps/kimi-code/src/native/minidb-worker.ts new file mode 100644 index 0000000000000000000000000000000000000000..babad491e5d809fb8811964516792061ccbd4c2e --- /dev/null +++ b/apps/kimi-code/src/native/minidb-worker.ts @@ -0,0 +1,69 @@ +import { basename } from 'node:path'; + +import { + configureTextBuildWorkerRuntime, + getTextBuildWorkerRuntimeState, +} from '@moonshot-ai/minidb/worker-runtime'; + +import { MINIDB_TEXT_BUILD_WORKER_ASSET } from '../../scripts/native/manifest.mjs'; +import { + getEmbeddedNativeAssetManifest, + getMinidbTextBuildWorkerFile, + getSeaAssetSource, + type NativeAssetOptions, +} from './native-assets'; + +export type MinidbTextBuildWorkerInstallStatus = + | { readonly status: 'not-sea' } + | { readonly status: 'asset-missing' } + | { + readonly status: 'installed'; + readonly assetSha256: string; + readonly basename: string; + } + | { + readonly status: 'failed'; + readonly errorCode: string; + readonly assetSha256?: string; + }; + +function errorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (typeof code === 'string' && code.length > 0) return code; + return error instanceof Error ? error.name : 'UNKNOWN'; +} + +/** Install the SEA-bundled worker without making optional extraction fatal. */ +export function installMinidbTextBuildWorker( + options: NativeAssetOptions = {}, +): MinidbTextBuildWorkerInstallStatus { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return { status: 'not-sea' }; + + let assetSha256: string | undefined; + try { + const manifest = options.manifest ?? getEmbeddedNativeAssetManifest(source); + const file = manifest?.runtimeFiles.find( + (entry) => entry.key === MINIDB_TEXT_BUILD_WORKER_ASSET.key, + ); + if (manifest === null || file === undefined) return { status: 'asset-missing' }; + assetSha256 = file.sha256; + + const workerPath = getMinidbTextBuildWorkerFile({ ...options, source, manifest }); + if (workerPath === null) return { status: 'asset-missing' }; + configureTextBuildWorkerRuntime(workerPath); + const runtime = getTextBuildWorkerRuntimeState(); + if (!runtime.configured) throw new Error('MiniDb worker runtime was not configured'); + return { + status: 'installed', + assetSha256, + basename: basename(workerPath), + }; + } catch (error) { + return { + status: 'failed', + errorCode: errorCode(error), + assetSha256, + }; + } +} diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts new file mode 100644 index 0000000000000000000000000000000000000000..4271609ea988c25d4d4a704009a210ef3e1a0ced --- /dev/null +++ b/apps/kimi-code/src/native/module-hook.ts @@ -0,0 +1,56 @@ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { getNativePackageRoot } from './native-assets'; + +type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; + +interface ModuleWithLoad { + _load?: ModuleLoad; +} + +const nodeRequire = createRequire(import.meta.url); +let installed = false; + +// pi-tui loads its platform-specific native helpers via an absolute-path +// require() computed from import.meta.url / process.execPath +// (see pi-tui dist/native-platform.js and dist/native-module-path.js). In a +// SEA binary those .node files live in the native-asset cache, so redirect +// any absolute require of a pi-tui native helper to the cached copy. +// +// Path shape: native//prebuilds//.node — note +// the two path segments after "prebuilds", so ".+" (not "[^/]+") is required. +const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin|linux)[\\/]prebuilds[\\/].+\.node$/; + +export function installNativeModuleHook(): void { + if (installed) return; + installed = true; + + const moduleBuiltin = nodeRequire('node:module') as ModuleWithLoad; + const originalLoad = moduleBuiltin._load; + if (originalLoad === undefined) return; + + moduleBuiltin._load = function loadWithNativeAssets( + this: unknown, + request: string, + parent: unknown, + isMain: boolean, + ): unknown { + if ( + typeof request === 'string' && + PI_TUI_NATIVE_PATTERN.test(request) && + !existsSync(request) + ) { + const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui'); + if (pkgRoot !== null) { + const match = request.match(PI_TUI_NATIVE_PATTERN); + if (match !== null) { + const redirected = join(pkgRoot, match[0]); + return originalLoad.call(this, redirected, parent, isMain); + } + } + } + return originalLoad.call(this, request, parent, isMain); + }; +} diff --git a/apps/kimi-code/src/native/native-assets.ts b/apps/kimi-code/src/native/native-assets.ts new file mode 100644 index 0000000000000000000000000000000000000000..51db1af9fc00e3b177cfbebd310d58a0c0504232 --- /dev/null +++ b/apps/kimi-code/src/native/native-assets.ts @@ -0,0 +1,554 @@ +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, win32 as pathWin32 } from 'node:path'; +import { join as joinPosix } from 'pathe'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { + KAP_SEARCH_WORKER_ASSET, + MINIDB_TEXT_BUILD_WORKER_ASSET, + NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildManifestKey, +} from '../../scripts/native/manifest.mjs'; + +export const NATIVE_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; + +export interface NativeAssetFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; + readonly mode?: number; +} + +export interface NativeAssetPackage { + readonly name: string; + readonly root: string; + readonly files: readonly NativeAssetFile[]; +} + +export interface NativeRuntimeAssetFile extends NativeAssetFile { + readonly key: string; +} + +export interface NativeAssetManifest { + readonly version: typeof NATIVE_ASSET_MANIFEST_VERSION; + readonly target: string; + readonly packages: readonly NativeAssetPackage[]; + readonly runtimeFiles: readonly NativeRuntimeAssetFile[]; +} + +export interface NativeAssetSource { + getAssetKeys(): readonly string[]; + getRawAsset(assetKey: string): ArrayBuffer | ArrayBufferView | Buffer | string; +} + +export interface NativeAssetOptions { + readonly source?: NativeAssetSource | null; + readonly manifest?: NativeAssetManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +interface NodeSeaModule { + isSea(): boolean; + getAssetKeys(): string[]; + getRawAsset(assetKey: string): ArrayBuffer; +} + +const nodeRequire = createRequire(import.meta.url); +let seaModule: NodeSeaModule | null | undefined; + +function loadSeaModule(): NodeSeaModule | null { + if (seaModule !== undefined) return seaModule; + try { + seaModule = nodeRequire('node:sea') as NodeSeaModule; + } catch { + seaModule = null; + } + return seaModule; +} + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +export function nativeAssetManifestKey(target: string = currentTarget()): string { + return buildManifestKey(target); +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function manifestObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid native asset manifest: ${label} must be an object`); + } + return value as Record; +} + +function manifestString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid native asset manifest: ${label} must be a non-empty string`); + } + return value; +} + +function validateRelativePath(value: unknown, label: string): string { + const path = manifestString(value, label); + const segments = path.split(/[\\/]/); + if ( + isAbsolute(path) || + /^[a-zA-Z]:/.test(path) || + path.startsWith('\\\\') || + segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid native asset manifest: ${label} must be a safe relative path`); + } + return path; +} + +function validateAssetFile( + value: unknown, + label: string, + assetKeys: Set, + relativePaths: Set, +): NativeAssetFile { + const file = manifestObject(value, label); + const assetKey = manifestString(file['assetKey'], `${label}.assetKey`); + if (assetKeys.has(assetKey)) { + throw new Error(`Invalid native asset manifest: duplicate assetKey ${assetKey}`); + } + assetKeys.add(assetKey); + const relativePath = validateRelativePath(file['relativePath'], `${label}.relativePath`); + const portableRelativePath = relativePath.replaceAll('\\', '/'); + if (relativePaths.has(portableRelativePath)) { + throw new Error(`Invalid native asset manifest: duplicate relativePath ${relativePath}`); + } + relativePaths.add(portableRelativePath); + const fileSha256 = file['sha256']; + if (typeof fileSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(fileSha256)) { + throw new Error(`Invalid native asset manifest: ${label}.sha256 must be 64 lowercase hex characters`); + } + const mode = file['mode']; + if ( + mode !== undefined && + (!Number.isInteger(mode) || (mode as number) < 0 || (mode as number) > 0o777) + ) { + throw new Error(`Invalid native asset manifest: ${label}.mode must be an integer between 0 and 0777`); + } + return { + assetKey, + relativePath, + sha256: fileSha256, + mode: mode as number | undefined, + }; +} + +export function validateNativeAssetManifest( + value: unknown, + expectedTarget?: string, +): NativeAssetManifest { + const manifest = manifestObject(value, 'root'); + if (manifest['version'] !== NATIVE_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported native asset manifest version: ${String(manifest['version'])}`); + } + const target = manifestString(manifest['target'], 'target'); + if (expectedTarget !== undefined && target !== expectedTarget) { + throw new Error(`Native asset manifest target mismatch: ${target} !== ${expectedTarget}`); + } + const manifestPackages = manifest['packages']; + if (!Array.isArray(manifestPackages)) { + throw new TypeError('Invalid native asset manifest: packages must be an array'); + } + const manifestRuntimeFiles = manifest['runtimeFiles']; + if (!Array.isArray(manifestRuntimeFiles)) { + throw new TypeError('Invalid native asset manifest: runtimeFiles must be an array'); + } + + const assetKeys = new Set(); + const relativePaths = new Set(); + const packageNames = new Set(); + const packages = manifestPackages.map((value, packageIndex): NativeAssetPackage => { + const label = `packages[${packageIndex}]`; + const pkg = manifestObject(value, label); + const name = manifestString(pkg['name'], `${label}.name`); + if (packageNames.has(name)) { + throw new Error(`Invalid native asset manifest: duplicate package name ${name}`); + } + packageNames.add(name); + const root = validateRelativePath(pkg['root'], `${label}.root`); + const packageFiles = pkg['files']; + if (!Array.isArray(packageFiles)) { + throw new TypeError(`Invalid native asset manifest: ${label}.files must be an array`); + } + return { + name, + root, + files: packageFiles.map((file, fileIndex) => + validateAssetFile(file, `${label}.files[${fileIndex}]`, assetKeys, relativePaths), + ), + }; + }); + + const runtimeKeys = new Set(); + const runtimeFiles = manifestRuntimeFiles.map((value, index): NativeRuntimeAssetFile => { + const label = `runtimeFiles[${index}]`; + const raw = manifestObject(value, label); + const key = manifestString(raw['key'], `${label}.key`); + if (runtimeKeys.has(key)) { + throw new Error(`Invalid native asset manifest: duplicate runtime key ${key}`); + } + runtimeKeys.add(key); + return { + ...validateAssetFile(raw, label, assetKeys, relativePaths), + key, + }; + }); + + return { + version: NATIVE_ASSET_MANIFEST_VERSION, + target, + packages, + runtimeFiles, + }; +} + +function resolveAssetPath(cacheRoot: string, relativePath: string): string { + const path = resolve(cacheRoot, ...relativePath.split(/[\\/]/)); + const fromRoot = relative(cacheRoot, path); + if (fromRoot === '..' || fromRoot.startsWith('../') || fromRoot.startsWith('..\\') || isAbsolute(fromRoot)) { + throw new Error(`Native asset path escapes cache root: ${relativePath}`); + } + return path; +} + +function optionalEnvValue(env: NodeJS.ProcessEnv, key: string): string | null { + const value = env[key]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +export function getSeaAssetSource(): NativeAssetSource | null { + const sea = loadSeaModule(); + if (sea === null || !sea.isSea()) return null; + return { + getAssetKeys: () => sea.getAssetKeys(), + getRawAsset: (assetKey) => sea.getRawAsset(assetKey), + }; +} + +export function getEmbeddedNativeAssetManifest( + source = getSeaAssetSource(), + target = currentTarget(), +): NativeAssetManifest | null { + if (source === null) return null; + const key = nativeAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const parsed: unknown = JSON.parse(toBuffer(raw).toString('utf-8')); + validateNativeAssetManifest(parsed, target); + return parsed as NativeAssetManifest; +} + +export function getNativeCacheBase(options: NativeAssetOptions = {}): string { + if (options.cacheBase !== undefined) return options.cacheBase; + + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const home = options.homeDir ?? homedir(); + + const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR'); + if (cacheDirEnv !== null) return cacheDirEnv; + + if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code'); + if (platform === 'win32') { + const localAppData = optionalEnvValue(env, 'LOCALAPPDATA'); + return localAppData !== null + ? pathWin32.join(localAppData, 'kimi-code') + : pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache'); + } + + return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code'); +} + +export function getNativeAssetCacheRoot( + manifest: NativeAssetManifest, + options: NativeAssetOptions = {}, +): string { + const validated = validateNativeAssetManifest(manifest); + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase(options), + 'native', + version, + sanitizeSegment(validated.target), + manifestHash, + ); +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string, mode?: number): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: mode ?? 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function ensureEntryFile(cacheRoot: string): void { + const entryPath = join(cacheRoot, 'node_modules', '.kimi-native-entry.cjs'); + ensureFile( + entryPath, + Buffer.from('module.exports = require;\n'), + sha256('module.exports = require;\n'), + 0o644, + ); +} + +export function ensureNativeAssetTree(options: NativeAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const rawManifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const cacheRoot = getNativeAssetCacheRoot(rawManifest, options); + const sourceKeys = new Set(source.getAssetKeys()); + const files = [ + ...manifest.packages.flatMap((pkg) => pkg.files), + ...manifest.runtimeFiles, + ]; + for (const file of files) { + if (!sourceKeys.has(file.assetKey)) { + throw new Error(`Native asset is missing: ${file.assetKey}`); + } + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(resolveAssetPath(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); + } + ensureEntryFile(cacheRoot); + return cacheRoot; +} + +export function getNativeRuntimeFile( + key: string, + options: NativeAssetOptions = {}, +): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const rawManifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const file = manifest.runtimeFiles.find((entry) => entry.key === key); + if (file === undefined) return null; + + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, file.relativePath); +} + +export function getMinidbTextBuildWorkerFile( + options: NativeAssetOptions = {}, +): string | null { + return getNativeRuntimeFile(MINIDB_TEXT_BUILD_WORKER_ASSET.key, options); +} + +export function getKapSearchWorkerFile(options: NativeAssetOptions = {}): string | null { + return getNativeRuntimeFile(KAP_SEARCH_WORKER_ASSET.key, options); +} + +export function getNativePackageRoot( + packageName: string, + options: NativeAssetOptions = {}, +): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const rawManifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const pkg = manifest.packages.find((entry) => entry.name === packageName); + if (pkg === undefined) return null; + + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, pkg.root); +} + +export function hasNativePackage(packageName: string, manifest: NativeAssetManifest): boolean { + return manifest.packages.some((pkg) => pkg.name === packageName); +} + +export function nativeAssetCacheExists( + packageName: string, + options: NativeAssetOptions = {}, +): boolean { + const root = getNativePackageRoot(packageName, options); + return root !== null && existsSync(root); +} + +export interface CleanupOptions { + readonly cacheBase: string; + readonly version: string; + readonly target: string; + readonly currentRoot: string; +} + +export interface CleanupResult { + readonly kept: string[]; + readonly removed: string[]; + readonly errors: Array<{ path: string; error: unknown }>; +} + +/** + * Remove stale native asset cache directories for the current (version, target). + * + * Keeps: + * - the currentRoot (passed in by caller) + * - the most recently modified sibling (defensive: in case currentRoot calc changed) + * + * Deletes all other sibling directories. Other versions and + * other targets are never touched. Errors per-entry are collected and returned + * (never throw — this is fire-and-forget background work). + */ +export function cleanupStaleNativeCache(options: CleanupOptions): CleanupResult { + const { cacheBase, version, target, currentRoot } = options; + const targetDir = join(cacheBase, 'native', version, target); + const result: CleanupResult = { kept: [], removed: [], errors: [] }; + + let entries: string[]; + try { + entries = readdirSync(targetDir); + } catch { + return result; + } + + const siblings: Array<{ path: string; mtimeMs: number }> = []; + for (const name of entries) { + const path = join(targetDir, name); + try { + const st = statSync(path); + if (!st.isDirectory()) continue; + siblings.push({ path, mtimeMs: st.mtimeMs }); + } catch (error) { + (result.errors as Array<{ path: string; error: unknown }>).push({ path, error }); + } + } + + if (siblings.length === 0) return result; + + // sort newest first + siblings.sort((a, b) => b.mtimeMs - a.mtimeMs); + // Defensive: keep the most recently modified sibling that is not currentRoot + // so a previously-written cache survives in case currentRoot calc changed. + const mostRecentOther = siblings.find((entry) => entry.path !== currentRoot)?.path; + const keepSet = new Set( + mostRecentOther === undefined ? [currentRoot] : [currentRoot, mostRecentOther], + ); + + for (const { path } of siblings) { + if (keepSet.has(path)) { + result.kept.push(path); + continue; + } + try { + rmSync(path, { recursive: true, force: true }); + result.removed.push(path); + } catch (error) { + (result.errors as Array<{ path: string; error: unknown }>).push({ path, error }); + } + } + + return result; +} + +/** + * Convenience: discover currentRoot from embedded manifest + run cleanup. + * Safe to call without args from main.ts startup. Returns null if not in SEA mode. + */ +export function cleanupStaleNativeCacheForCurrent( + options: NativeAssetOptions = {}, +): CleanupResult | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheBase = getNativeCacheBase(options); + const version = KIMI_BUILD_INFO.version ?? 'dev'; + const currentRoot = getNativeAssetCacheRoot(manifest, options); + + return cleanupStaleNativeCache({ + cacheBase, + version, + target: manifest.target, + currentRoot, + }); +} diff --git a/apps/kimi-code/src/native/native-require.ts b/apps/kimi-code/src/native/native-require.ts new file mode 100644 index 0000000000000000000000000000000000000000..6840d610b54af7525b7a2d55c8ae186c288672c1 --- /dev/null +++ b/apps/kimi-code/src/native/native-require.ts @@ -0,0 +1,30 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { + ensureNativeAssetTree, + getNativePackageRoot, + type NativeAssetOptions, +} from './native-assets'; + +export function createNativePackageRequire( + packageName: string, + options: NativeAssetOptions = {}, +): ReturnType | null { + const packageRoot = getNativePackageRoot(packageName, options); + if (packageRoot === null) return null; + + const cacheRoot = ensureNativeAssetTree(options); + if (cacheRoot === null) return null; + + return createRequire(join(cacheRoot, 'node_modules', '.kimi-native-entry.cjs')); +} + +export function loadNativePackage( + packageName: string, + options: NativeAssetOptions = {}, +): T | null { + const nativeRequire = createNativePackageRequire(packageName, options); + if (nativeRequire === null) return null; + return nativeRequire(packageName) as T; +} diff --git a/apps/kimi-code/src/native/search-worker.ts b/apps/kimi-code/src/native/search-worker.ts new file mode 100644 index 0000000000000000000000000000000000000000..709aec84fcd85e29809656da98533d465b2c25a8 --- /dev/null +++ b/apps/kimi-code/src/native/search-worker.ts @@ -0,0 +1,72 @@ +import { basename } from 'node:path'; + +import { + configureSearchWorkerRuntime, + getSearchWorkerRuntimeState, +} from '@moonshot-ai/kap-server/search-worker-runtime'; + +import { KAP_SEARCH_WORKER_ASSET } from '../../scripts/native/manifest.mjs'; +import { + getEmbeddedNativeAssetManifest, + getKapSearchWorkerFile, + getSeaAssetSource, + type NativeAssetOptions, +} from './native-assets'; + +export type KapSearchWorkerInstallStatus = + | { readonly status: 'not-sea' } + | { readonly status: 'asset-missing' } + | { + readonly status: 'installed'; + readonly assetSha256: string; + readonly basename: string; + } + | { + readonly status: 'failed'; + readonly errorCode: string; + readonly assetSha256?: string; + }; + +function errorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (typeof code === 'string' && code.length > 0) return code; + return error instanceof Error ? error.name : 'UNKNOWN'; +} + +/** + * Install the SEA-bundled global-search worker without making optional + * extraction fatal. Without it the search service resolves no worker entry + * inside the single-file binary and reports the index as degraded; + * `[database] search = false` restores the in-process host. + */ +export function installKapSearchWorker( + options: NativeAssetOptions = {}, +): KapSearchWorkerInstallStatus { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return { status: 'not-sea' }; + + let assetSha256: string | undefined; + try { + const manifest = options.manifest ?? getEmbeddedNativeAssetManifest(source); + const file = manifest?.runtimeFiles.find((entry) => entry.key === KAP_SEARCH_WORKER_ASSET.key); + if (manifest === null || file === undefined) return { status: 'asset-missing' }; + assetSha256 = file.sha256; + + const workerPath = getKapSearchWorkerFile({ ...options, source, manifest }); + if (workerPath === null) return { status: 'asset-missing' }; + configureSearchWorkerRuntime(workerPath); + const runtime = getSearchWorkerRuntimeState(); + if (!runtime.configured) throw new Error('search worker runtime was not configured'); + return { + status: 'installed', + assetSha256, + basename: basename(workerPath), + }; + } catch (error) { + return { + status: 'failed', + errorCode: errorCode(error), + assetSha256, + }; + } +} diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts new file mode 100644 index 0000000000000000000000000000000000000000..e21ed83f0b9abd48c65834af0538ef658fb9071b --- /dev/null +++ b/apps/kimi-code/src/native/smoke.ts @@ -0,0 +1,134 @@ +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { once } from 'node:events'; +import { dirname, join } from 'node:path'; +import { Worker } from 'node:worker_threads'; + +import { MiniDb } from '@moonshot-ai/minidb'; +import { getSearchWorkerRuntimeState } from '@moonshot-ai/kap-server/search-worker-runtime'; + +import { + getEmbeddedNativeAssetManifest, + getNativeCacheBase, + getNativePackageRoot, +} from './native-assets'; + +const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui']; + +function smokePiTuiNativeLoad(): void { + const platform = process.platform; + const arch = process.arch; + let rel: string | undefined; + if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'darwin', 'prebuilds', `darwin-${arch}`, 'darwin-platform.node'); + } else if (platform === 'linux' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'linux', 'prebuilds', `linux-${arch}`, 'linux-platform-x11.node'); + } else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-platform.node'); + } + if (rel === undefined) return; + + const req = createRequire(import.meta.url); + const helper = req(join(dirname(process.execPath), rel)) as { + getText?: unknown; + getImage?: unknown; + }; + if (typeof helper.getText !== 'function' || typeof helper.getImage !== 'function') { + throw new TypeError(`pi-tui native helper exports are unexpected: ${rel}`); + } +} + +async function smokeMinidbWorker(): Promise { + const cacheBase = getNativeCacheBase(); + mkdirSync(cacheBase, { recursive: true }); + const dir = mkdtempSync(join(cacheBase, 'sea-minidb-smoke-')); + let db: MiniDb> | null = null; + try { + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + const total = 4_200; + for (let base = 0; base < total; base += 500) { + await db.batch( + Array.from({ length: Math.min(500, total - base) }, (_, offset) => { + const id = base + offset; + return { + op: 'set' as const, + key: `doc-${id}`, + value: { text: `sea worker searchable document ${id}` }, + }; + }), + ); + } + await db.createTextIndex('smoke', { fields: ['text'] }); + if (db.stats.textWorkerBuilds < 1) { + throw new Error(`MiniDb worker did not run: ${JSON.stringify(db.stats)}`); + } + if (db.stats.textWorkerFallbacks !== 0) { + throw new Error( + `MiniDb worker unexpectedly fell back: ${db.stats.lastTextWorkerFallback ?? 'unknown'}`, + ); + } + if (!db.search('smoke', 'searchable').some((hit) => hit.key === 'doc-0')) { + throw new Error('MiniDb worker-built text index returned an incorrect search result'); + } + } finally { + await db?.close().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } +} + +async function smokeSearchWorker(): Promise { + // The SEA-extracted global-search worker entry must boot from disk and + // complete the versioned ready handshake. + const runtime = getSearchWorkerRuntimeState(); + if (!runtime.configured) { + throw new Error('search worker runtime was not configured'); + } + const cacheBase = getNativeCacheBase(); + mkdirSync(cacheBase, { recursive: true }); + const dir = mkdtempSync(join(cacheBase, 'sea-search-worker-')); + const worker = new Worker(runtime.path, { + workerData: { dir, bootSalt: 'sea-smoke' }, + }); + try { + const ready = once(worker, 'message', { + signal: AbortSignal.timeout(15_000), + }) as Promise; + const [event] = await ready; + const v = (event as { type?: string; v?: number }).v; + if ((event as { type?: string }).type !== 'ready' || typeof v !== 'number') { + throw new Error(`search worker handshake is unexpected: ${JSON.stringify(event)}`); + } + } finally { + await worker.terminate().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } +} + +async function runSmoke(): Promise { + const manifest = getEmbeddedNativeAssetManifest(); + if (manifest === null) throw new Error('Native asset manifest is not available.'); + for (const packageName of smokePackages) { + if (getNativePackageRoot(packageName, { manifest }) === null) { + throw new Error(`Native package is not available: ${packageName}`); + } + } + smokePiTuiNativeLoad(); + await smokeMinidbWorker(); + await smokeSearchWorker(); + process.stdout.write( + `Native asset smoke passed: ${manifest.target}; MiniDb worker build passed; search worker ready\n`, + ); +} + +export function runNativeAssetSmokeIfRequested(): boolean { + if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; + void runSmoke().then( + () => process.exit(0), + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Native asset smoke failed: ${message}\n`); + process.exit(1); + }, + ); + return true; +} diff --git a/apps/kimi-code/src/native/web-assets.ts b/apps/kimi-code/src/native/web-assets.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fb0845e524003720c977f4bfd2d2aca82e91902 --- /dev/null +++ b/apps/kimi-code/src/native/web-assets.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { + getNativeCacheBase, + getSeaAssetSource, + type NativeAssetSource, +} from './native-assets'; +import { + WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildWebManifestKey, +} from '../../scripts/native/manifest.mjs'; + +export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; + +export interface WebAssetFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; +} + +export interface WebAssetManifest { + readonly version: typeof WEB_ASSET_MANIFEST_VERSION; + readonly target: string; + readonly root: 'dist-web'; + readonly files: readonly WebAssetFile[]; +} + +export type WebAssetSource = NativeAssetSource; + +export interface WebAssetOptions { + readonly source?: WebAssetSource | null; + readonly manifest?: WebAssetManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +type RawWebAssetManifest = Omit & { + readonly version: number; + readonly root: string; +}; + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + relativePath.startsWith('/') || + relativePath.includes('\\') || + relativePath.split('/').includes('..') || + /^[A-Za-z]:/.test(relativePath) + ) { + throw new Error(`Invalid web asset relative path: ${relativePath}`); + } +} + +export function webAssetManifestKey(target: string = currentTarget()): string { + return buildWebManifestKey(target); +} + +export function getEmbeddedWebAssetManifest( + source: WebAssetSource | null = getSeaAssetSource(), + target = currentTarget(), +): WebAssetManifest | null { + if (source === null) return null; + const key = webAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest; + if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported web asset manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`); + } + if (manifest.root !== 'dist-web') { + throw new Error(`Unsupported web asset root: ${manifest.root}`); + } + return manifest as WebAssetManifest; +} + +export function getWebAssetCacheRoot( + manifest: WebAssetManifest, + options: WebAssetOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase({ + cacheBase: options.cacheBase, + env: options.env, + platform: options.platform, + homeDir: options.homeDir, + }), + 'web', + version, + sanitizeSegment(manifest.target), + manifestHash, + manifest.root, + ); +} + +export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getWebAssetCacheRoot(manifest, options); + for (const file of manifest.files) { + assertSafeRelativePath(file.relativePath); + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256); + } + return cacheRoot; +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..e09a8fe28e6c5dcb5f288344f80775cdb75a3e35 --- /dev/null +++ b/apps/kimi-code/src/tui/config.ts @@ -0,0 +1,280 @@ +/** + * Client-owned preferences. + * + * Agent/runtime settings live in core's `config.toml`; this file owns + * kimi-code client preferences such as terminal UI and update behavior. + */ + +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { parse as parseToml } from 'smol-toml'; +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; + +export const INVALID_TUI_CONFIG_MESSAGE = + 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.'; + +export const TuiThemeSchema = z.string(); + +export const NotificationConditionSchema = z.enum(['unfocused', 'always']); + +export const NotificationsConfigSchema = z.object({ + enabled: z.boolean(), + condition: NotificationConditionSchema, +}); + +export const UpgradePreferencesSchema = z.object({ + autoInstall: z.boolean(), +}); + +export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const; +export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number]; + +export const StatusLineFileConfigSchema = z.object({ + items: z.array(z.string()).optional(), + command: z.string().optional(), +}); + +export const StatusLineConfigSchema = z.object({ + /** Ordered built-in slots for footer line 1; null means the default layout. */ + items: z.array(z.enum(STATUS_LINE_ITEMS)).nullable(), + /** User command whose first stdout line replaces footer line 1; null disables. */ + command: z.string().nullable(), +}); +export type StatusLineConfig = z.infer; + +export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { + items: null, + command: null, +}; + +export const TuiConfigFileSchema = z.object({ + theme: TuiThemeSchema.optional(), + render_latex: z.boolean().optional(), + disable_paste_burst: z.boolean().optional(), + cache_expiry_hint: z.boolean().optional(), + disable_feedback_survey: z.boolean().optional(), + editor: z + .object({ + command: z.string().optional(), + }) + .optional(), + notifications: z + .object({ + enabled: z.boolean().optional(), + notification_condition: NotificationConditionSchema.optional(), + }) + .optional(), + upgrade: z + .object({ + auto_install: z.boolean().optional(), + }) + .optional(), + status_line: StatusLineFileConfigSchema.optional(), +}); + +export const TuiConfigSchema = z.object({ + theme: TuiThemeSchema, + /** LaTeX math rendering in Markdown; optional only so older hand-built test + * fixtures still typecheck. */ + renderLatex: z.boolean().optional(), + disablePasteBurst: z.boolean(), + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + cacheExpiryHint: z.boolean().optional(), + disableFeedbackSurvey: z.boolean().optional(), + editorCommand: z.string().nullable(), + notifications: NotificationsConfigSchema, + upgrade: UpgradePreferencesSchema, + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + statusLine: StatusLineConfigSchema.optional(), +}); + +export type TuiConfigFileShape = z.infer; +export type TuiConfig = z.infer; +export type NotificationsConfig = z.infer; +export type UpgradePreferences = z.infer; + +export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = { + enabled: true, + condition: 'unfocused', +}; + +export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { + autoInstall: true, +}; + +export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ + theme: 'auto', + renderLatex: true, + disablePasteBurst: false, + cacheExpiryHint: true, + disableFeedbackSurvey: false, + editorCommand: null, + notifications: DEFAULT_NOTIFICATIONS_CONFIG, + upgrade: DEFAULT_UPGRADE_PREFERENCES, + statusLine: DEFAULT_STATUS_LINE_CONFIG, +}); + +/** + * Thrown by `loadTuiConfig` when the on-disk TOML cannot be parsed. + * Carries `fallback` so the caller can recover without re-running the + * I/O, and use `message` (== `INVALID_TUI_CONFIG_MESSAGE`) as a + * user-facing notice. + */ +export class TuiConfigParseError extends Error { + override readonly name = 'TuiConfigParseError'; + readonly fallback: TuiConfig; + constructor(fallback: TuiConfig) { + super(INVALID_TUI_CONFIG_MESSAGE); + this.fallback = fallback; + } +} + +export function getTuiConfigPath(): string { + return join(getDataDir(), 'tui.toml'); +} + +export async function loadTuiConfig( + filePath: string = getTuiConfigPath(), + warn?: (message: string) => void, +): Promise { + if (!existsSync(filePath)) { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + return DEFAULT_TUI_CONFIG; + } + + try { + const text = await readFile(filePath, 'utf-8'); + return parseTuiConfig(text, warn); + } catch { + throw new TuiConfigParseError(DEFAULT_TUI_CONFIG); + } +} + +export function parseTuiConfig( + tomlText: string, + warn?: (message: string) => void, +): TuiConfig { + if (tomlText.trim().length === 0) { + return DEFAULT_TUI_CONFIG; + } + const raw = parseToml(tomlText) as Record; + const parsed = TuiConfigFileSchema.parse(raw); + return normalizeTuiConfig(parsed, warn); +} + +export async function saveTuiConfig( + config: TuiConfig, + filePath: string = getTuiConfigPath(), +): Promise { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, renderTuiConfig(config), 'utf-8'); +} + +export function normalizeTuiConfig( + config: TuiConfigFileShape, + warn: (message: string) => void = (message) => { + // oxlint-disable-next-line no-console + console.warn(message); + }, +): TuiConfig { + const command = config.editor?.command?.trim(); + const statusLineCommand = config.status_line?.command?.trim(); + const knownItems = new Set(STATUS_LINE_ITEMS); + const statusLineItems = + config.status_line?.items + ?.filter((item) => { + const known = knownItems.has(item); + if (!known) { + warn(`[tui.toml] ignoring unknown status_line item: ${item}`); + } + return known; + }) + .map((item) => item as StatusLineItem) ?? null; + return TuiConfigSchema.parse({ + theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, + disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + disableFeedbackSurvey: + config.disable_feedback_survey ?? DEFAULT_TUI_CONFIG.disableFeedbackSurvey, + editorCommand: command === undefined || command.length === 0 ? null : command, + notifications: { + enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, + condition: + config.notifications?.notification_condition ?? DEFAULT_NOTIFICATIONS_CONFIG.condition, + }, + upgrade: { + autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, + }, + statusLine: { + items: statusLineItems, + command: + statusLineCommand === undefined || statusLineCommand.length === 0 + ? null + : statusLineCommand, + }, + }); +} + +export function renderTuiConfig(config: TuiConfig): string { + // An active status_line must round-trip: any preference save rewrites the + // whole file, so the section is emitted live when set and left as a + // commented-out guide when unset. + const statusItems = config.statusLine?.items; + const statusCommand = config.statusLine?.command; + const statusLines: string[] = []; + if (statusItems !== null && statusItems !== undefined) { + statusLines.push(`items = ${JSON.stringify(statusItems)}`); + } + if (statusCommand) { + statusLines.push(`command = "${escapeTomlBasicString(statusCommand)}"`); + } + const statusSection = + statusLines.length > 0 + ? `[status_line]\n${statusLines.join('\n')}\n` + : `# [status_line] +# Pick and order the built-in footer slots: ${STATUS_LINE_ITEMS.join(', ')} +# items = ${JSON.stringify([...STATUS_LINE_ITEMS])} +# Or render your own: a command whose first stdout line replaces footer line 1. +# It receives a JSON snapshot (model, cwd, git, usage, mode) on stdin. +# command = "~/.kimi-code/statusline.sh" +`; + return `# ~/.kimi-code/tui.toml +# Client preferences for kimi-code. +# Agent/runtime settings stay in ~/.kimi-code/config.toml. + +theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source +disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback +cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit +disable_feedback_survey = ${String(config.disableFeedbackSurvey === true)} # true hides the occasional session rating prompt + +[editor] +command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR + +[notifications] +enabled = ${String(config.notifications.enabled)} # true | false +notification_condition = "${config.notifications.condition}" # "unfocused" | "always" + +[upgrade] +auto_install = ${String(config.upgrade.autoInstall)} # true | false + +${statusSection}`; +} + +function escapeTomlBasicString(value: string): string { + return value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\b', '\\b') + .replaceAll('\t', '\\t') + .replaceAll('\n', '\\n') + .replaceAll('\f', '\\f') + .replaceAll('\r', '\\r'); +} diff --git a/apps/kimi-code/src/tui/goal-queue-store.ts b/apps/kimi-code/src/tui/goal-queue-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3b0ccb47bb7428f346cdd983c8d49770f5e6329 --- /dev/null +++ b/apps/kimi-code/src/tui/goal-queue-store.ts @@ -0,0 +1,269 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + ErrorCodes, + KimiError, +} from '@moonshot-ai/kimi-code-sdk'; + +const GOAL_QUEUE_FILE = 'upcoming-goals.json'; +const GOAL_QUEUE_VERSION = 1; +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; + +export interface UpcomingGoal { + readonly id: string; + readonly objective: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface GoalQueueSnapshot { + readonly goals: readonly UpcomingGoal[]; +} + +export type GoalQueueMoveDirection = 'up' | 'down'; + +interface GoalQueueFile { + readonly version: typeof GOAL_QUEUE_VERSION; + readonly goals: readonly UpcomingGoal[]; +} + +interface GoalQueueSession { + readonly id: string; + readonly summary?: { + readonly sessionDir?: string; + }; +} + +const queueMutationLocks = new Map>(); + +export async function readGoalQueue(session: GoalQueueSession): Promise { + const state = await readQueueFile(session); + return toSnapshot(state); +} + +export async function appendGoalQueueItem( + session: GoalQueueSession, + input: { readonly objective: string }, +): Promise { + const objective = normalizeObjective(input.objective); + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const now = new Date().toISOString(); + const goal: UpcomingGoal = { + id: randomUUID(), + objective, + createdAt: now, + updatedAt: now, + }; + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals: [...state.goals, goal] }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function updateGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string; readonly objective: string }, +): Promise { + const objective = normalizeObjective(input.objective); + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const current = state.goals[index]!; + const updatedAt = timestampAfter(current.updatedAt); + const goals = state.goals.map((goal, goalIndex) => + goalIndex === index ? { ...goal, objective, updatedAt } : goal, + ); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function removeGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string }, +): Promise { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const goals = state.goals.filter((_, goalIndex) => goalIndex !== index); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function restoreGoalQueueItem( + session: GoalQueueSession, + goal: UpcomingGoal, +): Promise { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + if (state.goals.some((item) => item.id === goal.id)) { + return toSnapshot(state); + } + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals: [goal, ...state.goals] }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function moveGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string; readonly direction: GoalQueueMoveDirection }, +): Promise { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const targetIndex = input.direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= state.goals.length) { + return toSnapshot(state); + } + const goals = [...state.goals]; + const [goal] = goals.splice(index, 1); + goals.splice(targetIndex, 0, goal!); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +function goalQueuePath(session: GoalQueueSession): string { + const sessionDir = session.summary?.sessionDir; + if (sessionDir === undefined || sessionDir.trim().length === 0) { + throw new Error(`Session ${session.id} does not expose a session directory`); + } + return join(sessionDir, GOAL_QUEUE_FILE); +} + +async function readQueueFile(session: GoalQueueSession): Promise { + const filePath = goalQueuePath(session); + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) return emptyQueueFile(); + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Invalid JSON in goal queue: ${describeError(error)}`, + ); + } + + if (!isGoalQueueFile(parsed)) { + const empty = emptyQueueFile(); + await writeQueueFile(session, empty); + return empty; + } + + return parsed; +} + +async function writeQueueFile(session: GoalQueueSession, file: GoalQueueFile): Promise { + const filePath = goalQueuePath(session); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8'); +} + +async function withQueueMutationLock( + session: GoalQueueSession, + work: () => Promise, +): Promise { + const filePath = goalQueuePath(session); + const previous = queueMutationLocks.get(filePath) ?? Promise.resolve(); + const run = previous.catch(() => undefined).then(work); + const lock = run.then( + () => undefined, + () => undefined, + ); + queueMutationLocks.set(filePath, lock); + try { + return await run; + } finally { + if (queueMutationLocks.get(filePath) === lock) { + queueMutationLocks.delete(filePath); + } + } +} + +function emptyQueueFile(): GoalQueueFile { + return { version: GOAL_QUEUE_VERSION, goals: [] }; +} + +function toSnapshot(file: GoalQueueFile): GoalQueueSnapshot { + return { goals: file.goals }; +} + +function normalizeObjective(value: string): string { + const objective = value.trim(); + if (objective.length === 0) { + throw new KimiError(ErrorCodes.GOAL_OBJECTIVE_EMPTY, 'Goal objective cannot be empty'); + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + throw new KimiError( + ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`, + ); + } + return objective; +} + +function findGoalIndex(file: GoalQueueFile, goalId: string): number { + const index = file.goals.findIndex((goal) => goal.id === goalId); + if (index === -1) { + throw new KimiError(ErrorCodes.GOAL_NOT_FOUND, 'No queued goal found'); + } + return index; +} + +function isGoalQueueFile(value: unknown): value is GoalQueueFile { + if (!isRecord(value)) return false; + return ( + value['version'] === GOAL_QUEUE_VERSION && + Array.isArray(value['goals']) && + value['goals'].every(isUpcomingGoal) + ); +} + +function isUpcomingGoal(value: unknown): value is UpcomingGoal { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value['id']) && + isNonEmptyString(value['objective']) && + isNonEmptyString(value['createdAt']) && + isNonEmptyString(value['updatedAt']) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function timestampAfter(previous: string): string { + const now = new Date(); + const previousMs = Date.parse(previous); + if (Number.isFinite(previousMs) && now.getTime() <= previousMs) { + return new Date(previousMs + 1).toISOString(); + } + return now.toISOString(); +} + +function isErrno(error: unknown, code: string): boolean { + return isRecord(error) && error['code'] === code; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/tui/index.ts b/apps/kimi-code/src/tui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..06af194ecaaa8e15ffaa0c841ae9f1b8dea2cb13 --- /dev/null +++ b/apps/kimi-code/src/tui/index.ts @@ -0,0 +1,3 @@ +export { KimiTUI } from './kimi-tui'; +export type { KimiTUIStartupInput } from './kimi-tui'; +export type { KimiTUIOptions } from './types'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts new file mode 100644 index 0000000000000000000000000000000000000000..92e8e12ab274a477111cce5ae5c247ef56b673c0 --- /dev/null +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -0,0 +1,4190 @@ +import { randomUUID } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; +import { effectiveModelAlias, log } from '@moonshot-ai/kimi-code-sdk'; +import type { + ApprovalRequest, + ApprovalResponse, + BackgroundTaskInfo, + CreateSessionOptions, + KimiHarness, + PermissionMode, + PluginCommandDef, + PromptPart, + Session, + SkillSummary, + TokenUsage, + TurnEndedEvent, + TurnStartedEvent, + WorkspaceTrustInfo, +} from '@moonshot-ai/kimi-code-sdk'; +import { isTelemetryDisabledByEnv } from '@moonshot-ai/kimi-telemetry'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + deleteAllKittyImages, + type Component, + type Focusable, + getCapabilities, + Spacer, + TuiAltScreen, + TuiMainScreen, +} from '@moonshot-ai/pi-tui'; +import { resolve } from 'pathe'; + +import type { CLIOptions } from '#/cli/options'; +import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; +import { openUrl } from '#/utils/open-url'; +import { getInputHistoryFile } from '#/utils/paths'; +import { applyRecommendedEffort } from '#/utils/recommended-effort'; +import { getRecommendedEffortConfig } from '#/utils/recommended-effort-config'; +import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; +import { quoteShellArg } from '#/utils/shell-quote'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; + +import { BannerProvider } from './banner/banner-provider'; +import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; +import { + BUILTIN_SLASH_COMMANDS, + buildPluginSlashCommands, + buildSkillSlashCommands, + goalObjectiveLengthWarning, + isExperimentalFlagEnabled, + setExperimentalFeatures, + sortSlashCommands, + type KimiSlashCommand, + type SkillListSession, +} from './commands'; +import * as slashCommands from './commands/dispatch'; +import { CacheHintController } from './controllers/cache-hint-controller'; +import { BannerComponent } from './components/chrome/banner'; +import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; +import { GutterContainer } from './components/chrome/gutter-container'; +import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; +import { WelcomeComponent } from './components/chrome/welcome'; +import { pickRandomWorkingTip } from './components/chrome/working-tips'; +import { + ApprovalPanelComponent, + type ApprovalPanelResponse, +} from './components/dialogs/approval-panel'; +import { + ApprovalPreviewViewer, + type ApprovalPreviewBlock, +} from './components/dialogs/approval-preview'; +import { CompactionComponent } from './components/dialogs/compaction'; +import { HelpPanelComponent } from './components/dialogs/help-panel'; +import { defaultThinkingEffortFor } from './components/dialogs/model-selector'; +import { QuestionDialogComponent } from './components/dialogs/question-dialog'; +import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; +import { + FileMentionProvider, + type SlashAutocompleteCommand, +} from './components/editor/file-mention-provider'; +import { AssistantMessageComponent } from './components/messages/assistant-message'; +import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; +import { CronMessageComponent } from './components/messages/cron-message'; +import { buildGoalMarker } from './components/messages/goal-markers'; +import { + GoalCompletionMessageComponent, + GoalSetMessageComponent, +} from './components/messages/goal-panel'; +import { PluginCommandComponent } from './components/messages/plugin-command'; +import { ShellRunComponent } from './components/messages/shell-run'; +import { SkillActivationComponent } from './components/messages/skill-activation'; +import { + NoticeMessageComponent, + StatusMessageComponent, +} from './components/messages/status-message'; +import { StepSummaryComponent } from './components/messages/step-summary'; +import { ThinkingComponent } from './components/messages/thinking'; +import { ToolCallComponent } from './components/messages/tool-call'; +import { + ReplayTurnBoundaryComponent, + UserMessageComponent, +} from './components/messages/user-message'; +import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; +import { QueuePaneComponent } from './components/panes/queue-pane'; +import type { TuiConfig } from './config'; +import { + LLM_NOT_SET_MESSAGE, + MAIN_AGENT_ID, + NO_ACTIVE_SESSION_MESSAGE, + PRODUCT_NAME, + SESSION_LIST_PAGE_SIZE, + SESSIONLESS_STARTUP_NOTICE, +} from './constant/kimi-tui'; +import { MEDIA_INGESTION_SUBMIT_WAIT_MS } from './constant/media'; +import { CHROME_GUTTER } from './constant/rendering'; +import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; +import { AuthFlowController } from './controllers/auth-flow'; +import { BtwPanelController } from './controllers/btw-panel'; +import { ClipboardImageHintController } from './controllers/clipboard-image-hint'; +import { EditorKeyboardController } from './controllers/editor-keyboard'; +import { SessionEventHandler } from './controllers/session-event-handler'; +import { SessionReplayRenderer } from './controllers/session-replay'; +import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases'; +import { StreamingUIController } from './controllers/streaming-ui'; +import { SurveyController } from './controllers/survey-controller'; +import { TasksBrowserController } from './controllers/tasks-browser'; +import { installRainbowDance } from './easter-eggs/dance'; +import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; +import { ApprovalController } from './reverse-rpc/approval/controller'; +import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; +import { registerReverseRPCHandlers } from './reverse-rpc/index'; +import { QuestionController } from './reverse-rpc/question/controller'; +import { createQuestionAskHandler } from './reverse-rpc/question/handler'; +import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; +import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; +import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; +import { createTUIState, type TUIState } from './tui-state'; +import { + INITIAL_LIVE_PANE, + sumTokenUsage, + type AppState, + type InlineSkillActivation, + type KimiTUIOptions, + type LivePaneState, + type LoginProgressSpinnerHandle, + type QueuedMessage, + type SteerInputItem, + type StepRetryState, + type TranscriptEntry, + type TUIStartupOptions, + type TUIStartupState, +} from './types'; +import { + hasDispose, + hasHiddenContent, + isExpandable, + isExpandedComponent, +} from './utils/component-capabilities'; +import { isDeadTerminalError } from './utils/dead-terminal'; +import { formatErrorMessage } from './utils/event-payload'; +import { pickForegroundTasks } from './utils/foreground-task'; +import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; +import { + extractMediaAttachments, + originalsDirForSession, + pendingMediaIngestions, + refreshExpiringImageFileRefs, + resolveOriginalCaptions, + rewriteMediaPlaceholders, +} from './utils/image-placeholder'; +import type { ExtractionResult } from './utils/image-placeholder'; +import { installInputLatencyProbe } from './utils/input-latency'; +import { combineSteerInput } from './utils/steer-input'; +import { startupTrace } from '#/utils/startup-trace'; +import { REPLAY_FETCH_TURN_LIMIT } from './utils/message-replay'; +import { hasPatchChanges } from './utils/object-patch'; +import { beginScreenTakeover, endScreenTakeover, type ScreenTakeover } from './utils/screen-takeover'; +import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { formatStepRetryDetail, formatStepRetryLabel } from './utils/step-retry'; +import { formatBashOutputForDisplay } from './utils/shell-output'; +import { thinkingEffortFromConfig } from './utils/thinking-config'; +import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; +import { installTerminalFocusTracking } from './utils/terminal-focus'; +import { notifyTerminalOnce } from './utils/terminal-notification'; +import { installTerminalThemeTracking } from './utils/terminal-theme'; +import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; +import { + getTranscriptComponentEntry, + markTranscriptComponent, +} from './utils/transcript-component-metadata'; +import { nextTranscriptId } from './utils/transcript-id'; +import { + expandCutoffIndex, + TRANSCRIPT_EXPAND_TURNS, + TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_MAX_TURNS, + TRANSCRIPT_WINDOW_ENABLED, + groupTurns, + turnsToTrim, +} from './utils/transcript-window'; + +export type { TUIState } from './tui-state'; +export { createTUIState } from './tui-state'; +export type { + KimiTUIOptions, + LoginProgressSpinnerHandle, + TUIStartupOptions, + TUIStartupState, +} from './types'; + +export interface KimiTUIStartupInput { + readonly cliOptions: CLIOptions; + /** Profile name resolved from cliOptions --agent/--agent-file (see resolveAgentProfileSelection). */ + readonly agentProfile?: string; + readonly additionalDirs?: readonly string[]; + readonly tuiConfig: TuiConfig; + readonly version: string; + readonly workDir: string; + readonly startupNotice?: string; + readonly migrationPlan?: MigrationPlan | null; + /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ + readonly migrateOnly?: boolean; + readonly telemetryDisabled?: boolean; +} + +type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +type LoadingTipKind = 'moon' | 'composing'; + +function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undefined { + if (mode === 'waiting' || mode === 'tool') return 'moon'; + if (mode === 'composing') return 'composing'; + return undefined; +} + +function waitingSpinnerLabel(retry: StepRetryState | null): string { + return retry === null ? '' : formatStepRetryLabel(retry); +} + +function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + +function createInitialAppState(input: KimiTUIStartupInput): AppState { + const startupPermission: PermissionMode = input.cliOptions.auto + ? 'auto' + : input.cliOptions.yolo + ? 'yolo' + : 'manual'; + return { + model: '', + workDir: input.workDir, + additionalDirs: [...(input.additionalDirs ?? [])], + sessionId: '', + permissionMode: startupPermission, + planMode: input.cliOptions.plan, + inputMode: 'prompt', + swarmMode: false, + towerMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + cumulativeTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + stepRetry: null, + theme: input.tuiConfig.theme, + version: input.version, + editorCommand: input.tuiConfig.editorCommand, + disablePasteBurst: input.tuiConfig.disablePasteBurst, + renderLatex: input.tuiConfig.renderLatex, + cacheExpiryHint: input.tuiConfig.cacheExpiryHint, + disableFeedbackSurvey: input.tuiConfig.disableFeedbackSurvey, + notifications: input.tuiConfig.notifications, + upgrade: input.tuiConfig.upgrade, + statusLine: input.tuiConfig.statusLine, + availableModels: {}, + availableProviders: {}, + sessionTitle: null, + goal: null, + mcpServersSummary: null, + banner: undefined, + }; +} + +interface SendMessageOptions { + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; + readonly videoAttachmentIds?: readonly number[]; + readonly hasMedia?: boolean; + /** + * Lease pre-created at extraction time by `sendNormalUserInput`. Dispatch + * reuses it (carrying its exact-binding submission id); enqueueing defers + * it — the queue item owns the raw ids and re-leases at dequeue. + */ + readonly lease?: StagingLease; +} + +/** How long the one-shot "moved to background" footer hint stays visible. */ +const DETACH_HINT_DISPLAY_MS = 4_000; + +function isUserSubmittedTurnOrigin(origin: TurnStartedEvent['origin'] | undefined): boolean { + if (origin === undefined) return false; + switch (origin.kind) { + case 'user': + return true; + case 'skill_activation': + case 'plugin_command': + return origin.trigger === 'user-slash'; + case 'shell_command': + return origin.phase === 'input'; + default: + return false; + } +} + +export class KimiTUI { + readonly harness: KimiHarness; + readonly options: KimiTUIOptions; + session: Session | undefined; + state: TUIState; + /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */ + private ensureSessionPromise: Promise | null = null; + private readonly cacheHint = new CacheHintController(this); + /** Staged prompt media lifecycle (daemon uploads + cache copies) — see StagingLeaseTracker. */ + private readonly staging: StagingLeaseTracker; + private readonly approvalController = new ApprovalController(); + private readonly questionController = new QuestionController(); + private readonly reverseRpcDisposers: Array<() => void> = []; + private skillCommands: readonly KimiSlashCommand[] = []; + readonly skillCommandMap = new Map(); + private pluginCommands: readonly KimiSlashCommand[] = []; + readonly pluginCommandMap = new Map(); + private readonly imageStore = new ImageAttachmentStore(); + // Detected lazily in startBackgroundFdAutocomplete() — detection spawns + // `fd --version`, which must not happen before the workspace trust gate: + // on Windows a bare command name resolves into the (untrusted) cwd first. + private fdPath: string | null = null; + private fdDownloadStarted = false; + sessionEventUnsubscribe: (() => void) | undefined; + cancelInFlight: (() => void) | undefined; + deferUserMessages = false; + aborted = false; + private terminalFocusTrackingDispose: (() => void) | undefined; + private terminalThemeTrackingDispose: (() => void) | undefined; + private clipboardImageHintController: ClipboardImageHintController | undefined; + private uninstallRainbowDance: () => void; + private signalCleanupHandlers: Array<() => void> = []; + private isShuttingDown = false; + private backgroundRefreshPromise: Promise | undefined; + private readonly migrationPlan: MigrationPlan | null; + private readonly migrateOnly: boolean; + private readonly telemetryDisabled: boolean; + private startupNotice: string | undefined; + private lastActivityMode: string | undefined; + private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = + undefined; + private lastHistoryContent: string | undefined; + // Live `!` shell output entries, keyed by commandId so concurrent commands + // each update their own card and stale events are dropped. Mutated in place + // as `shell.output` events arrive; removed when the command completes. + // `taskId` (from `shell.started`) lets ctrl+b detach the exact task. + private readonly shellOutputStreams = new Map< + string, + { entry: TranscriptEntry; component: ShellRunComponent; taskId?: string } + >(); + readonly streamingUI: StreamingUIController; + readonly authFlow: AuthFlowController; + readonly btwPanelController: BtwPanelController; + readonly sessionEventHandler: SessionEventHandler; + readonly sessionReplay: SessionReplayRenderer; + readonly tasksBrowserController: TasksBrowserController; + readonly surveyController: SurveyController; + readonly editorKeyboard: EditorKeyboardController; + + /** Timer that auto-clears the one-shot "moved to background" footer hint. */ + private detachHintClearTimer: ReturnType | undefined; + + // The currently-mounted approval panel, if any. Kept so the full-screen + // preview viewer can restore focus to the exact same instance (and its + // selection / feedback state) when it closes. + private activeApprovalPanel: ApprovalPanelComponent | undefined; + // Active full-screen approval preview. While set, the previous screen is + // stashed in `takeover` (root children in regular mode, the layout root in + // fullscreen); closing restores it. + private approvalPreview: + | { + component: ApprovalPreviewViewer; + takeover: ScreenTakeover; + panel: ApprovalPanelComponent; + } + | undefined; + + public onExit?: (exitCode?: number) => Promise; + + /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ + public exitOpenUrl: string | undefined; + + /** + * Task that takes over the process after the TUI shuts down, instead of + * exiting (`/web` starting a new server: the server keeps this terminal + * attached until Ctrl+C). Set via {@link setExitForegroundTask}. + */ + public exitForegroundTask: ((exitCode: number) => Promise) | undefined; + + track(event: string, properties?: Parameters[1]): void { + this.harness.track(event, properties); + } + + constructor(harness: KimiHarness, startupInput: KimiTUIStartupInput) { + this.harness = harness; + this.staging = new StagingLeaseTracker({ + takeFileIds: (ids) => this.imageStore.takeFileIds(ids), + releaseRetains: (ids) => { + this.imageStore.releaseRetains(ids); + }, + deleteFiles: async (fileIds, paths) => { + await Promise.all([ + ...fileIds.map((fileId) => this.harness.deleteFile(fileId).catch(() => undefined)), + ...paths.map((path) => unlink(path).catch(() => undefined)), + ]); + }, + warn: (message) => { + this.track('staging_lease_invariant', { message }); + }, + }); + const tuiOptions: KimiTUIOptions = { + initialAppState: createInitialAppState(startupInput), + startup: { + sessionFlag: startupInput.cliOptions.session, + continueLast: startupInput.cliOptions.continue, + yolo: startupInput.cliOptions.yolo, + auto: startupInput.cliOptions.auto, + plan: startupInput.cliOptions.plan, + model: startupInput.cliOptions.model, + agentProfile: startupInput.agentProfile, + agentFiles: startupInput.cliOptions.agentFiles, + startupNotice: startupInput.startupNotice, + }, + }; + this.options = tuiOptions; + this.migrationPlan = startupInput.migrationPlan ?? null; + this.migrateOnly = startupInput.migrateOnly ?? false; + this.telemetryDisabled = startupInput.telemetryDisabled ?? false; + this.startupNotice = startupInput.startupNotice; + this.state = createTUIState(tuiOptions); + this.state.footer.setExpandHintProvider(() => this.toolOutputExpandHint()); + this.uninstallRainbowDance = installRainbowDance(() => { + this.state.ui.requestRender(); + }); + + this.reverseRpcDisposers.push( + ...registerReverseRPCHandlers(this.approvalController, this.questionController, { + showApprovalPanel: (payload) => { + this.showApprovalPanel(payload); + }, + hideApprovalPanel: () => { + this.hideApprovalPanel(); + }, + showQuestionDialog: (payload) => { + this.showQuestionDialog(payload); + }, + hideQuestionDialog: () => { + this.hideQuestionDialog(); + }, + }), + ); + this.streamingUI = new StreamingUIController(this); + this.authFlow = new AuthFlowController(this); + this.btwPanelController = new BtwPanelController(this); + this.sessionEventHandler = new SessionEventHandler(this); + this.sessionReplay = new SessionReplayRenderer(this); + this.tasksBrowserController = new TasksBrowserController(this); + this.surveyController = new SurveyController(this, { + accessToken: () => this.harness.auth.getCachedAccessToken(), + telemetryDisabled: () => isTelemetryDisabledByEnv() || this.telemetryDisabled, + }); + this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); + this.editorKeyboard.install(); + this.buildLayout(); + } + + // ========================================================================= + // Autocomplete & Skill Commands + // ========================================================================= + + private getSlashCommands(): readonly KimiSlashCommand[] { + const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => + isExperimentalFlagEnabled(command.experimentalFlag), + ); + return [...builtins, ...this.skillCommands, ...this.pluginCommands]; + } + + private setupAutocomplete(): void { + const slashCommands: SlashAutocompleteCommand[] = this.getSlashCommands().map((cmd) => { + const completer = cmd.completeArgs; + return { + name: cmd.name, + aliases: cmd.aliases, + description: cmd.description, + ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), + ...(completer !== undefined + ? { getArgumentCompletions: (prefix: string) => completer(prefix) } + : {}), + }; + }); + const skillCommandNames = new Set(this.skillCommandMap.keys()); + const provider = new FileMentionProvider( + slashCommands, + this.state.appState.workDir, + this.fdPath, + this.state.appState.additionalDirs, + () => this.state.appState.inputMode, + skillCommandNames, + ); + this.state.editor.setAutocompleteProvider(provider); + + const argumentHints = new Map(); + for (const cmd of slashCommands) { + if (cmd.argumentHint === undefined) continue; + argumentHints.set(cmd.name, cmd.argumentHint); + for (const alias of cmd.aliases ?? []) { + argumentHints.set(alias, cmd.argumentHint); + } + } + this.state.editor.setArgumentHints(argumentHints); + this.state.editor.setSkillCommandNames(skillCommandNames); + } + + refreshSlashCommandAutocomplete(): void { + this.sessionEventHandler.notifications.setEnabled(isExperimentalFlagEnabled('notify_user')); + this.setupAutocomplete(); + } + + async refreshSkillCommands(session?: SkillListSession): Promise { + if (session === undefined) { + // v2 engine: skills live on the workspace handler, not the session, so + // they are available before the first (lazy) session is created — the + // workspace catalog is the same merged view a session would serve. + try { + const skills = await this.harness.listWorkspaceSkills(this.state.appState.workDir); + this.applySkillCommands(skills); + } catch {} + return; + } + + let skills; + try { + skills = await session.listSkills(); + } catch { + return; + } + this.applySkillCommands(skills); + } + + private applySkillCommands(skills: readonly SkillSummary[]): void { + const skillCommands = buildSkillSlashCommands(skills); + this.skillCommands = skillCommands.commands; + this.skillCommandMap.clear(); + for (const [commandName, skillName] of skillCommands.commandMap) { + this.skillCommandMap.set(commandName, skillName); + } + this.setupAutocomplete(); + } + + async refreshPluginCommands(session?: Session): Promise { + if (session === undefined) { + // v2 engine: the enabled plugin commands are an app-global live view, + // available before the first (lazy) session is created. + try { + const defs = await this.harness.listPluginCommands(); + this.applyPluginCommands(defs); + } catch {} + return; + } + + let defs; + try { + defs = await session.listPluginCommands(); + } catch { + return; + } + this.applyPluginCommands(defs); + } + + private applyPluginCommands(defs: readonly PluginCommandDef[]): void { + const pluginSlashCommands = buildPluginSlashCommands(defs); + this.pluginCommands = pluginSlashCommands.commands; + this.pluginCommandMap.clear(); + for (const [commandName, body] of pluginSlashCommands.commandMap) { + this.pluginCommandMap.set(commandName, body); + } + this.setupAutocomplete(); + } + + // ========================================================================= + // Lifecycle + // ========================================================================= + + async start(): Promise { + startupTrace('tui:start'); + // Signal handlers must be installed before raw mode to avoid EIO loops. + this.registerSignalHandlers(); + // Outer try rolls back signal listeners on startup failure. + try { + // The workspace trust gate must run before anything else in startup — + // including the migration branch: a workspace that needs migration is + // not implicitly trusted, and later startup steps spawn child processes. + startupTrace('trustPrompt:begin'); + const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); + startupTrace('trustPrompt:end'); + + if (this.migrationPlan !== null) { + // Migration needs the event loop running first (pi-tui component). + // When the trust prompt already started it, starting it again would + // re-run pi-tui's terminal.start() — stacking a second Kitty + // keyboard-protocol push and duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); + try { + const migrationResult = await this.runMigrationScreen(this.migrationPlan); + if (this.migrateOnly) { + const failed = migrationResult.decision === 'now' && migrationResult.migrated === false; + this.disposeTerminalTracking(); + this.state.ui.stop(); + await this.onExit?.(failed ? 1 : 0); + return; + } + const shouldReplayHistory = await this.initMainTui(); + this.startBackgroundFdAutocomplete(); + await this.finishStartup(shouldReplayHistory); + } catch (error) { + this.disposeTerminalTracking(); + this.state.ui.stop(); + throw error; + } + return; + } + + startupTrace('initMainTui:begin'); + const shouldReplayHistory = await this.initMainTui(); + startupTrace('initMainTui:end'); + // Debug-only input→render latency overlay (KIMI_TUI_INPUT_LATENCY=1). + if (process.env['KIMI_TUI_INPUT_LATENCY']) installInputLatencyProbe(this.state.ui); + // When the trust prompt already started the event loop, starting it + // again would re-run pi-tui's terminal.start() — stacking a second + // Kitty keyboard-protocol push (leaking CSI-u mode past exit) and + // duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); + startupTrace('eventLoop:started'); + try { + this.startBackgroundFdAutocomplete(); + startupTrace('finishStartup:begin'); + await this.finishStartup(shouldReplayHistory); + startupTrace('finishStartup:end'); + } catch (error) { + this.disposeTerminalTracking(); + this.state.ui.stop(); + throw error; + } + } catch (error) { + this.unregisterSignalHandlers(); + throw error; + } + } + + private async loadBanner(): Promise { + const provider = new BannerProvider(this.state.appState.version); + const displayState = await readBannerDisplayState(); + const now = new Date(); + const banner = await provider.load({ + state: displayState, + now, + }); + this.state.appState.banner = banner; + if (banner === null) return; + + this.renderBanner(); + this.state.ui.requestRender(); + + if (banner.display === 'always') return; + try { + await writeBannerDisplayState({ + version: 1, + shown: { + ...displayState.shown, + [banner.key]: { lastShownAt: now.toISOString() }, + }, + }); + } catch { + // Best-effort: banner display state should never block startup. + } + } + + private renderBanner(): void { + if (this.state.appState.banner === null || this.state.appState.banner === undefined) { + return; + } + if (this.state.transcriptContainer.children.some((child) => child instanceof BannerComponent)) { + return; + } + const welcomeIndex = this.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const banner = new BannerComponent(this.state.appState.banner); + if (welcomeIndex >= 0) { + this.state.transcriptContainer.children.splice(welcomeIndex + 1, 0, banner); + } else { + this.state.transcriptContainer.children.unshift(banner); + } + this.state.transcriptContainer.invalidate(); + } + + private async initMainTui(): Promise { + const shouldReplayHistory = await this.init(); + + // Mount only after init() succeeds; see mountFooter(). + this.mountFooter(); + this.renderWelcome(); + void this.loadBanner(); + this.setupAutocomplete(); + void this.loadPersistedInputHistory(); + this.state.editorContainer.clear(); + this.state.editorContainer.addChild(this.state.editor); + this.state.ui.setFocus(this.state.editor); + this.applyRecommendedEffortInBackground(); + return shouldReplayHistory; + } + + private startEventLoop(): void { + // Dispose any previous focus/clipboard/theme tracking so re-entering the + // event loop (e.g. a future TUI reconnect) can't stack duplicate listeners. + this.disposeTerminalTracking(); + this.state.ui.start(); + this.startClipboardImageHintController(); + this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); + this.refreshTerminalThemeTracking(); + } + + private startClipboardImageHintController(): void { + this.clipboardImageHintController = new ClipboardImageHintController({ + ui: this.state.ui, + footer: this.state.footer, + getModelSupportsImage: () => this.supportsCurrentModelCapability('image_in'), + requestRender: () => { + this.state.ui.requestRender(); + }, + }); + this.clipboardImageHintController.start(); + } + + private startBackgroundFdAutocomplete(): void { + if (this.fdDownloadStarted) return; + this.fdDownloadStarted = true; + + this.fdPath = detectFdPath(); + if (this.fdPath !== null) { + this.setupAutocomplete(); + return; + } + + void ensureFdPath() + .then((fdPath) => { + if (fdPath === null) return; + this.fdPath = fdPath; + this.setupAutocomplete(); + }) + .catch(() => { + // Best-effort background bootstrap: autocomplete keeps using the filesystem fallback. + }); + } + + private applyRecommendedEffortInBackground(): void { + void this.backgroundRefreshPromise?.then(async () => { + await applyRecommendedEffort({ + fetchConfig: async () => + getRecommendedEffortConfig({ + accessToken: await this.harness.auth.getCachedAccessToken(), + }), + getConfig: () => this.harness.getConfig(), + setConfig: (patch) => this.harness.setConfig(patch), + track: (event, properties) => { + this.track(event, properties); + }, + }); + }); + } + + private async refreshProviderModelsInBackground(): Promise { + try { + const result = await this.authFlow.refreshProviderModels(); + for (const c of result.changed) { + if (c.added <= 0) continue; + this.showStatus(`${c.providerName} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`); + } + for (const f of result.failed) { + this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); + } + } catch { + // Best-effort: startup must not crash on background refresh failures. + } + } + + private async finishStartup(shouldReplayHistory: boolean): Promise { + if (this.startupNotice !== undefined) { + this.showStatus(this.startupNotice); + this.startupNotice = undefined; + } + void this.showTmuxKeyboardWarningIfNeeded(); + // Config diagnostics (deprecated keys/env vars, invalid sections) in + // warning yellow at boot; `run-prompt`/`run-v2-print` print them to + // stderr for non-interactive runs. + void this.showConfigWarningsIfAny(); + if (this.state.startupState === 'picker') { + void this.bootstrapFromPicker(); + return; + } + if (shouldReplayHistory) { + await this.sessionReplay.hydrateFromReplay(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); + } + const resumeState = this.session?.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); + } + if (this.session !== undefined) { + this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(this.session); + } + if (shouldReplayHistory) { + void this.cacheHint.maybeShowOnResume(); + } + void this.fetchSessions(); + if (this.session !== undefined) { + this.updateTerminalTitle(); + } + void this.refreshSkillCommands(this.session); + void this.refreshPluginCommands(this.session); + } + + private async showSessionWarnings(session: Session): Promise { + try { + const warnings = await session.getSessionWarnings(); + if (this.session !== session) return; + for (const warning of warnings) { + const severity = warning.severity === 'error' ? 'error' : 'warning'; + this.showStatus(`Warning: ${warning.message}`, severity); + } + } catch { + // Best-effort: startup must not block on warning retrieval. + } + } + + private async showTmuxKeyboardWarningIfNeeded(): Promise { + const warning = await detectTmuxKeyboardWarning(); + if (warning === undefined || this.aborted) return; + this.showStatus(warning, 'warning'); + } + + private async init(): Promise { + setExperimentalFeatures(await this.harness.getExperimentalFeatures()); + this.sessionEventHandler.notifications.setEnabled(isExperimentalFlagEnabled('notify_user')); + await this.authFlow.refreshAvailableModels(); + this.backgroundRefreshPromise = this.refreshProviderModelsInBackground(); + + const { startup } = this.options; + const { workDir } = this.state.appState; + let session: Session | undefined; + let shouldReplayHistory = false; + const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast; + const createSessionOptions: MutableCreateSessionOptions = { + workDir, + model: startup.model, + permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, + planMode: startup.plan ? true : undefined, + // --agent/--agent-file bind the startup session only; sessions created + // later in this process fall back to the default profile. + agentProfile: startup.agentProfile, + agentFiles: startup.agentFiles?.length ? [...startup.agentFiles] : undefined, + }; + if (this.state.appState.additionalDirs.length > 0) { + createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; + } + + try { + if (isResumeStartup) { + if (startup.sessionFlag === '') { + this.state.startupState = 'picker'; + return false; + } + + if (startup.sessionFlag !== undefined) { + const sessions = await this.harness.listSessions({ + sessionId: startup.sessionFlag, + workDir, + }); + const target = sessions[0]; + if (target === undefined) { + throw new Error(`Session "${startup.sessionFlag}" not found.`); + } + if (resolve(target.workDir) !== resolve(workDir)) { + this.state.ui.stop(); + process.stderr.write( + `${currentTheme.fg( + 'warning', + `Session "${startup.sessionFlag}" was created under a different directory.\n` + + ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, + )}\n\n`, + ); + throw new Error( + `Session "${startup.sessionFlag}" was created under a different directory.`, + ); + } + session = await this.harness.resumeSession({ + id: startup.sessionFlag, + additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + shouldReplayHistory = true; + } else { + // Only the most recent session matters here — fetch a one-item page + // instead of materializing the whole listing. + const page = await this.harness.listSessionsPage({ workDir, limit: 1 }); + const target = page.items[0]; + if (target !== undefined) { + session = await this.harness.resumeSession({ + id: target.id, + additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + shouldReplayHistory = true; + } else { + session = await this.harness.createSession(createSessionOptions); + this.startupNotice = combineStartupNotice( + this.startupNotice, + `No sessions to continue under "${workDir}"; starting a fresh session.`, + ); + } + } + } else { + // Lazy session creation (v2 engine): start session-less and create the + // session on the first message. Startup flags are carried in appState + // and applied when that session is created; until then the footer + // shows the config defaults the engine would apply at createSession + // time (model, permission, plan mode, thinking effort, context cap). + await this.hydrateLazyConfigDefaults(); + this.appendStartupNotice(SESSIONLESS_STARTUP_NOTICE); + } + if (session !== undefined && shouldReplayHistory) { + await this.applyStartupModesToResumedSession(session); + if (startup.model !== undefined) { + await session.setModel(startup.model); + } + } + } catch (error) { + if (!isOAuthLoginRequiredError(error)) throw error; + this.authFlow.enterLoginRequiredStartupState(); + return false; + } + + if (session !== undefined) { + await this.setSession(session); + await this.syncRuntimeState(session); + } + this.applyStartupPermissionAndPlanToAppState(); + this.state.startupState = 'ready'; + return shouldReplayHistory; + } + + async stop(exitCode?: number): Promise { + if (this.isShuttingDown) return; + this.isShuttingDown = true; + this.unregisterSignalHandlers(); + this.aborted = true; + // Give the startup provider-model refresh a brief chance to finish before + // the harness closes (and the process exits): its config writes are each + // atomic, so draining can only ever leave a complete file behind. Bounded + // so a slow network never delays the exit. + if (this.backgroundRefreshPromise !== undefined) { + await Promise.race([ + this.backgroundRefreshPromise, + new Promise((resolve) => setTimeout(resolve, 1500)), + ]); + } + this.streamingUI.discardPending(); + // Stop background polling, streaming intervals, and per-component timers + // before tearing the UI down, so they can't keep firing requestRender after + // stop() returns (or leak when stop() runs without process.exit). + this.tasksBrowserController.close(); + this.btwPanelController.clear(); + this.stopActivitySpinner(); + this.streamingUI.disposeActiveCompactionBlock(); + this.streamingUI.resetToolUi(); + this.disposeTranscriptChildren(); + this.editorKeyboard.dispose(); + this.surveyController.dispose(); + this.state.footer.dispose(); + for (const dispose of this.reverseRpcDisposers) { + dispose(); + } + this.reverseRpcDisposers.length = 0; + this.disposeTerminalTracking(); + // Restore the terminal even if closing the session / harness throws — a + // SIGTERM during a network or MCP shutdown must not leave the user stuck in + // raw mode with a hidden cursor. + try { + await this.closeSession('shutting down'); + this.clearQueuedMessages(); + this.staging.releaseAll(); + this.staging.deleteStaged(this.imageStore.clear()); + await this.staging.drain(); + await this.harness.close(); + } finally { + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.sessionEventHandler.clearStepRetryAttemptTimer(); + this.uninstallRainbowDance(); + try { + await this.state.terminal.drainInput(); + } catch { + // best effort — the terminal may already be dead (SIGHUP / EIO). + } + try { + this.stopUiForExit(); + } catch { + // best effort terminal restore. + } + } + if (this.onExit) { + await this.onExit(exitCode); + } + } + + // SIGHUP / dead-terminal EIO → emergencyTerminalExit (no cleanup, avoids + // EIO write-loop that can pin a CPU core). SIGTERM → normal stop(). + private registerSignalHandlers(): void { + this.unregisterSignalHandlers(); + + const signals: NodeJS.Signals[] = ['SIGTERM']; + if (process.platform !== 'win32') { + signals.push('SIGHUP'); + } + + for (const signal of signals) { + const handler = (): void => { + if (signal === 'SIGHUP') { + this.emergencyTerminalExit(); + return; + } + // Registering a SIGTERM listener disables Node's default exit(143), + // so we must reinstate it after stop() or on failure. + this.stop(143).then( + () => { + process.exit(143); + }, + () => { + this.emergencyTerminalExit(143); + }, + ); + }; + process.prependListener(signal, handler); + this.signalCleanupHandlers.push(() => { + process.off(signal, handler); + }); + } + + const terminalErrorHandler = (error: Error): void => { + if (isDeadTerminalError(error)) { + this.emergencyTerminalExit(); + } + }; + process.stdout.on('error', terminalErrorHandler); + process.stderr.on('error', terminalErrorHandler); + this.signalCleanupHandlers.push(() => { + process.stdout.off('error', terminalErrorHandler); + }); + this.signalCleanupHandlers.push(() => { + process.stderr.off('error', terminalErrorHandler); + }); + } + + private unregisterSignalHandlers(): void { + const handlers = this.signalCleanupHandlers; + this.signalCleanupHandlers = []; + for (const cleanup of handlers) cleanup(); + } + + // Exit codes follow POSIX 128+signum: 129 = SIGHUP, 143 = SIGTERM. + private emergencyTerminalExit(exitCode = 129): never { + this.isShuttingDown = true; + this.unregisterSignalHandlers(); + // Best-effort terminal restore: stop() may not have run (SIGHUP) or may + // have thrown (SIGTERM cleanup failure), so recover raw mode / cursor / + // bracketed paste before exiting instead of leaving the user's shell broken. + restoreTerminalModes(); + process.exit(exitCode); + } + + private disposeTerminalTracking(): void { + this.stopTerminalThemeTracking(); + this.clipboardImageHintController?.stop(); + this.clipboardImageHintController = undefined; + this.terminalFocusTrackingDispose?.(); + this.terminalFocusTrackingDispose = undefined; + } + + private buildLayout(): void { + const { ui } = this.state; + // Fullscreen mounts its layout root (transcript ScrollView + bottom dock) + // in createTUIState; the root children list stays empty there. + if (ui instanceof TuiAltScreen) return; + ui.clear(); + ui.addChild(this.state.transcriptContainer); + ui.addChild(this.state.activityContainer); + ui.addChild(this.state.todoPanelContainer); + ui.addChild(this.state.notifyPanelContainer); + ui.addChild(this.state.queueContainer); + ui.addChild(this.state.btwPanelContainer); + ui.addChild(this.state.surveyContainer); + ui.addChild(this.state.editorContainer); + // Footer is mounted later (mountFooter), not here. + } + + // Footer is the only chrome with content before a session is ready, so + // mounting it at construction lets a stray pre-start render leak it to the + // terminal — e.g. above the error when resuming a missing session. Mount it + // only once init() succeeds. FooterComponent isn't a Container, so wrap it to + // pick up the same outer gutter as the panels above. + private mountFooter(): void { + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(this.state.footer); + const dock = this.state.dockContainer; + if (dock !== undefined) { + // Dock sizing contract: the footer may shrink to 1 row under extreme + // height pressure, but never disappears (see createTUIState). + dock.addChild(footerWrap, { shrink: 1, minSize: 1 }); + return; + } + this.state.ui.addChild(footerWrap); + } + + // Fullscreen exit: leave the alternate screen with the frame preserved, + // then replay the transcript through a main-screen renderer so native + // scrollback ends up with the same inline layout a regular session would + // have produced (pi's "transcript" exit form). + private stopUiForExit(): void { + const ui = this.state.ui; + if (!(ui instanceof TuiAltScreen)) { + ui.stop(); + return; + } + ui.stop({ preserveScreen: true }); + const main = new TuiMainScreen(ui.terminal); + main.addChild(this.state.transcriptContainer); + main.addChild(this.state.activityContainer); + main.addChild(this.state.todoPanelContainer); + main.addChild(this.state.notifyPanelContainer); + main.addChild(this.state.queueContainer); + main.addChild(this.state.btwPanelContainer); + main.addChild(this.state.surveyContainer); + main.addChild(this.state.editorContainer); + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(this.state.footer); + main.addChild(footerWrap); + // First paint of a main-screen renderer writes every line sequentially, + // landing the whole transcript in native scrollback. + main.renderNow(); + main.stop(); + } + + // ========================================================================= + // Input Dispatch + // ========================================================================= + + handlePlanToggle(next: boolean): void { + void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); + } + + handleInputModeChange(mode: 'prompt' | 'bash'): void { + this.setAppState({ inputMode: mode }); + this.surveyController.notifyInputModeChanged(mode); + this.updateEditorBorderHighlight(); + } + + handleUserInput(text: string): void { + const wasBashMode = this.state.appState.inputMode === 'bash'; + if (wasBashMode) { + // A submit always exits bash mode (the `!` is consumed by this command). + this.state.editor.inputMode = 'prompt'; + this.handleInputModeChange('prompt'); + } + if (text.trim().length === 0) return; + if (this.state.appState.isReplaying) { + this.showError('Cannot send input while session history is replaying.'); + return; + } + // Shell commands are stored with a leading `!` so ↑ recall can tell them + // apart from prompts and restore bash mode (see CustomEditor's mode-aware + // history navigation). The `!` is stripped again when the entry is recalled. + const historyText = wasBashMode ? `!${text}` : text; + void this.persistInputHistory(historyText); + if (wasBashMode) { + // Only one foreground action at a time: queue the shell command while + // another shell command is running or an agent turn is in progress. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(text, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + void this.runShellCommandFromInput(text); + return; + } + slashCommands.dispatchInput(this, text); + } + + private async runShellCommandFromInput(command: string): Promise { + let session = this.session; + if (session === undefined) { + session = await this.ensureSession(); + if (session === undefined) return; + // A concurrent first message may have started a prompt while this lazy + // creation was in flight (both inputs share the same creation promise); + // honor the busy gate here, like handleUserInput does before the await, + // instead of running the shell command concurrently with an agent turn. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(command, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + } + // Echo the command locally (bash-input) with a `$` prompt. The agent also + // records it for resume; this is the live view. + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: currentTheme.fg('shellMode', `$ ${command}`), + bullet: '', + }); + // Create the live output entry up front. ShellRunComponent owns its own + // rendering (running card → final view) and is mutated in place as output + // streams in and on completion. + const commandId = nextTranscriptId(); + const outputEntry: TranscriptEntry = { + id: commandId, + kind: 'status', + turnId: undefined, + renderMode: 'plain', + content: '', + }; + const outputComponent = new ShellRunComponent(() => this.state.ui.requestRender()); + // Inherit the current ctrl+o state, same as freshly mounted tool calls — + // the global toggle only reaches components that exist when it fires. + if (this.state.toolOutputExpanded) outputComponent.setExpanded(true); + this.shellOutputStreams.set(commandId, { entry: outputEntry, component: outputComponent }); + this.state.transcriptEntries.push(outputEntry); + markTranscriptComponent(outputComponent, outputEntry); + this.state.transcriptContainer.addChild(outputComponent); + // Treat command execution as a streaming phase so input queues, the activity + // pane shows the moon spinner, and ctrl+b is enabled while it runs. + this.setAppState({ streamingPhase: 'shell' }); + this.state.ui.requestRender(); + + this.track('shell_command'); + + void session.runShellCommand(command, { commandId }).then( + ({ stdout, stderr, isError, backgrounded }) => { + this.finishShellOutput(commandId, stdout, stderr, isError, backgrounded); + }, + (error: unknown) => { + const message = formatErrorMessage(error); + this.finishShellOutput(commandId, '', message, true); + this.showError(`Shell command failed: ${message}`); + }, + ); + } + + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + const text = event.update.text ?? ''; + if (text.length === 0) return; + stream.component.append(text); + } + + handleShellStarted(event: { commandId: string; taskId: string }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + stream.taskId = event.taskId; + } + + cancelRunningShellCommand(): void { + const session = this.session; + if (session === undefined) return; + for (const commandId of this.shellOutputStreams.keys()) { + void session.cancelShellCommand(commandId).catch((error: unknown) => { + this.showError(`Failed to cancel shell command: ${formatErrorMessage(error)}`); + }); + } + } + + private finishShellOutput( + commandId: string, + stdout: string, + stderr: string, + isError?: boolean, + backgrounded?: boolean, + ): void { + const stream = this.shellOutputStreams.get(commandId); + if (stream === undefined) return; + if (backgrounded === true) { + // The command was moved to the background; detachRunningShellCommand owns + // the UI and the model notification, so there is nothing to render here. + return; + } + stream.component.finish(stdout, stderr, isError); + // Keep the transcript entry's metadata in sync for anything that reads it + // (export / copy). The component renders itself. + stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError); + this.shellOutputStreams.delete(commandId); + // When the last shell command finishes, leave the shell streaming phase, + // release one queued message (if any), and refresh the activity pane. + if (this.shellOutputStreams.size === 0) { + this.setAppState({ streamingPhase: 'idle' }); + this.drainOneQueuedMessage(); + } + } + + private drainOneQueuedMessage(): void { + const session = this.session; + if (session === undefined) return; + const item = this.shiftQueuedMessage(); + if (item === undefined) return; + if (item.mode === 'bash') { + this.staging.releaseQueued([item]); + void this.runShellCommandFromInput(item.text); + } else { + this.sendQueuedMessage(session, item); + } + this.updateQueueDisplay(); + } + + async sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise { + if (this.btwPanelController.sendUserInput(text)) return; + if (this.state.appState.model.trim().length === 0) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + let extraction: ReturnType; + if (preExtracted === undefined) { + // A just-pasted image/video may still be finishing its background + // ingestion (compression/daemon upload): give it a bounded moment so + // the submit can use the daemon-ref form — a slower image ingestion + // extracts to the inline fallback instead, a slower video upload + // refuses the submission below. Undefined when nothing is pending, + // keeping the media-free send path synchronous. + const ingestionWait = pendingMediaIngestions( + text, + this.imageStore, + MEDIA_INGESTION_SUBMIT_WAIT_MS, + ); + if (ingestionWait !== undefined) await ingestionWait; + } + try { + // A cache-hint-swallowed resend passes its pre-dialog extraction back + // in: the image store may already be cleared (e.g. after "Start a new + // session"), so re-extracting from the text would lose the media. + extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore); + if (preExtracted !== undefined) { + const parts = refreshExpiringImageFileRefs( + extraction.parts, + extraction.imageAttachmentIds, + this.imageStore, + ); + if (parts !== extraction.parts) extraction = { ...extraction, parts }; + } + } catch (error) { + // A pasted video's daemon upload was unusable (still in flight, + // failed, expired); nothing was dispatched. + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + // Create the staging lease right after extraction, so every exit below + // releases through the tracker instead of open-coding ids — a + // forgotten exit degrades to an unclaimed lease (swept by `releaseAll`) + // instead of a permanently retained upload. The lease carries the + // exact-binding submission id: the consuming turn's `turn.started` echoes + // it as `promptId`. A goal-active submission is steered and binds its + // lease explicitly in sendMessageInternal, so it gets no id. + const stagingLease = this.staging.create( + // One retain per unique id per extraction: dedupe repeated placeholder + // occurrences so the lease's id multiplicity matches the retain count. + [...new Set([...extraction.imageAttachmentIds, ...extraction.videoAttachmentIds])], + [], + 'user', + extraction.hasMedia && this.state.appState.goal?.status !== 'active' + ? randomUUID() + : undefined, + ); + if (!this.validateMediaCapabilities(extraction)) { + this.staging.release(stagingLease); + return; + } + // Idle cache-hint interception sits before session creation; it is + // synchronous unless a hint actually fires. Aside from the bounded + // ingestion wait above, the send path stays await-free up to sendMessage. + if (this.cacheHint.maybeInterceptOnSubmit(text, extraction)) { + // The stash owns the extraction from here: its resend re-leases inside + // the re-entered send path, its restore goes through releaseRecalled + // (see CacheHintController). Detach so the stash is not double-owned. + this.staging.defer(stagingLease); + return; + } + let session = this.session; + if (session === undefined) { + session = await this.ensureSession(); + if (session === undefined) { + this.staging.release(stagingLease); + return; + } + } + if (extraction.hasMedia) { + this.sendMessage(session, text, { + hasMedia: true, + parts: extraction.parts, + imageAttachmentIds: extraction.imageAttachmentIds, + videoAttachmentIds: extraction.videoAttachmentIds, + lease: stagingLease, + }); + } else { + this.sendMessage(session, text); + } + this.updateQueueDisplay(); + this.state.ui.requestRender(); + } + + async sendInlineSkillUserInput( + text: string, + activations: readonly InlineSkillActivation[], + preExtracted?: ExtractionResult, + ): Promise { + if (this.btwPanelController.sendUserInput(text, activations)) return; + if (this.state.appState.model.trim().length === 0) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + let extraction: ReturnType; + try { + extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore); + } catch (error) { + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(extraction)) return; + if (this.cacheHint.maybeInterceptOnSubmit(text, extraction, activations)) return; + let session = this.session; + if (session === undefined) { + // Dispatch only routes here on the v2 engine, so the session is created + // lazily on first use exactly like a normal prompt. + session = await this.ensureSession(); + if (session === undefined) return; + } + if ( + this.deferUserMessages || + this.state.appState.goal?.status === 'active' || + this.state.appState.streamingPhase !== 'idle' || + this.state.appState.isCompacting + ) { + this.enqueueMessage( + text, + extraction.hasMedia + ? { + hasMedia: true, + parts: extraction.parts, + imageAttachmentIds: extraction.imageAttachmentIds, + videoAttachmentIds: extraction.videoAttachmentIds, + inlineSkillActivations: activations, + } + : { inlineSkillActivations: activations }, + ); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + this.beginSessionRequest(); + void this.runInlineSkillActivations(session, text, activations, extraction).catch( + (error: unknown) => { + this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`); + }, + ); + } + + private async runInlineSkillActivations( + session: Session, + text: string, + activations: readonly InlineSkillActivation[], + extraction: ReturnType, + ): Promise { + const knownEntryIds = new Set(this.state.transcriptEntries.map((entry) => entry.id)); + await session.promptWithSkills( + extraction.hasMedia + ? resolveOriginalCaptions( + extraction.parts, + extraction.imageAttachmentIds, + this.imageStore, + originalsDirForSession(session), + ) + : text, + activations.map((activation) => ({ name: activation.skillName, args: activation.args })), + ); + // The engine bundles the activations into the prompt's own message, and + // the `skill.activated` events land synchronously during the call — so + // the cards appended for this submission are the skill_activation entries + // with fresh ids (the window trim may replace the entries array mid-call, + // so membership is decided by id, not by index into a captured array). + // Appending the user entry afterwards keeps the live transcript in the + // same order as a resumed replay (skill cards first, prompt last). + // Marking only happens once the submission was accepted: a rejected + // bundle leaves no cards and must not leave a local undo anchor the + // engine never recorded. + for (const entry of this.state.transcriptEntries) { + if (entry.kind === 'skill_activation' && !knownEntryIds.has(entry.id)) { + entry.bundledWithPrompt = true; + } + } + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: text, + imageAttachmentIds: + extraction.imageAttachmentIds.length > 0 ? extraction.imageAttachmentIds : undefined, + }); + } + + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + imageSnapshots?: readonly unknown[]; + }): boolean { + if (!extraction.hasMedia) return true; + if ( + (extraction.imageAttachmentIds.length > 0 || (extraction.imageSnapshots?.length ?? 0) > 0) && + !this.supportsCurrentModelCapability('image_in') + ) { + this.showError('Current model does not support image input.'); + return false; + } + if ( + extraction.videoAttachmentIds.length > 0 && + !this.supportsCurrentModelCapability('video_in') + ) { + this.showError('Current model does not support video input.'); + return false; + } + return true; + } + + private supportsCurrentModelCapability(capability: string): boolean { + const capabilities = + this.state.appState.availableModels[this.state.appState.model]?.capabilities; + if (capabilities === undefined) return true; + return capabilities.includes(capability); + } + + private async loadPersistedInputHistory(): Promise { + try { + const file = getInputHistoryFile(this.state.appState.workDir); + const entries = await loadInputHistory(file); + for (const entry of entries) { + this.state.editor.addToHistory(entry.content); + } + this.lastHistoryContent = entries.at(-1)?.content; + } catch { + // best-effort + } + } + + private async persistInputHistory(text: string): Promise { + const trimmed = text.trim(); + if (trimmed.length === 0) return; + if (trimmed === this.lastHistoryContent) return; + this.state.editor.addToHistory(trimmed); + try { + const file = getInputHistoryFile(this.state.appState.workDir); + const written = await appendInputHistory(file, trimmed, this.lastHistoryContent); + if (written) this.lastHistoryContent = trimmed; + } catch { + this.lastHistoryContent = trimmed; + } + } + + recallLastQueued(): QueuedMessage | undefined { + if (this.state.queuedMessages.length === 0) return undefined; + const last = this.state.queuedMessages.at(-1)!; + this.state.queuedMessages = this.state.queuedMessages.slice(0, -1); + // A recall restores the draft into the editor — it is not a discard: + // consumes the retains only, keeping the staged daemon uploads alive + // (see `releaseRecalled`) so the restored draft resubmits them. + this.staging.releaseRecalled([ + ...(last.imageAttachmentIds ?? []), + ...(last.videoAttachmentIds ?? []), + ]); + return last; + } + + /** + * Cache-hint restore: a dismissed/hand-back interception returns its draft + * to the editor — same semantics as a queue recall (consume the stash + * extraction's retains; the staged daemon uploads stay alive for the + * restored draft). + */ + recallStashedMedia(extraction: ExtractionResult | undefined): void { + if (extraction === undefined) return; + this.staging.releaseRecalled([ + ...extraction.imageAttachmentIds, + ...extraction.videoAttachmentIds, + ]); + } + + // ========================================================================= + // Session Requests / Queues + // ========================================================================= + + private enqueueMessage( + text: string, + options?: SendMessageOptions & { + readonly inlineSkillActivations?: readonly InlineSkillActivation[]; + }, + mode?: 'prompt' | 'bash', + ): void { + this.state.queuedMessages.push({ + text, + agentId: this.harness.interactiveAgentId, + parts: options?.parts, + imageAttachmentIds: + options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 + ? options.imageAttachmentIds + : undefined, + videoAttachmentIds: + options?.videoAttachmentIds !== undefined && options.videoAttachmentIds.length > 0 + ? options.videoAttachmentIds + : undefined, + mode, + inlineSkillActivations: options?.inlineSkillActivations, + }); + this.track('input_queue'); + } + + beginSessionRequest(): void { + this.cacheHint.onTurnBegin(); + this.streamingUI.setTurnId(undefined); + this.streamingUI.resetLiveText(); + this.streamingUI.resetToolUi(); + this.streamingUI.resetToolCallState(); + + this.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.setAppState({ + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + failSessionRequest(message: string): void { + this.setAppState({ streamingPhase: 'idle' }); + this.resetLivePane(); + this.showError(message); + } + + sendQueuedMessage(session: Session, item: QueuedMessage): void { + if (item.mode === 'bash') { + this.staging.releaseQueued([item]); + void this.runShellCommandFromInput(item.text); + return; + } + if (item.mode === 'skill' && item.skillName !== undefined) { + // sendSkillActivation re-checks the busy state, so a premature drain + // re-queues at the tail instead of racing the running turn. + this.sendSkillActivation(session, item.skillName, item.skillArgs ?? ''); + return; + } + if (item.inlineSkillActivations !== undefined && item.inlineSkillActivations.length > 0) { + // Media was extracted and validated at enqueue time; reuse the queued + // parts rather than re-extracting from a possibly-cleared image store. + // Expiring daemon refs refresh at dispatch, same as the plain tail below. + const refreshed = + item.parts === undefined + ? [] + : [ + ...refreshExpiringImageFileRefs( + item.parts, + item.imageAttachmentIds ?? [], + this.imageStore, + ), + ]; + this.beginSessionRequest(); + void this.runInlineSkillActivations( + session, + item.text, + item.inlineSkillActivations, + { + parts: refreshed, + hasMedia: refreshed.length > 0, + imageAttachmentIds: item.imageAttachmentIds !== undefined ? [...item.imageAttachmentIds] : [], + videoAttachmentIds: item.videoAttachmentIds !== undefined ? [...item.videoAttachmentIds] : [], + imageSnapshots: [], + }, + ).catch((error: unknown) => { + this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`); + }); + return; + } + const parts = + item.parts === undefined + ? undefined + : refreshExpiringImageFileRefs( + item.parts, + item.imageAttachmentIds ?? [], + this.imageStore, + ); + this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { + this.sendMessageInternal(session, item.text, { + parts, + imageAttachmentIds: item.imageAttachmentIds, + videoAttachmentIds: item.videoAttachmentIds, + }); + }); + } + + handleTurnStarted(event: TurnStartedEvent): void { + this.staging.handleTurnStarted(event); + this.surveyController.notifyTurnStarted(isUserSubmittedTurnOrigin(event.origin)); + } + + handleTurnEnded(event: TurnEndedEvent): void { + this.staging.handleTurnEnded(event); + this.surveyController.notifyTurnEnded(); + } + + releaseStagingMedia(mediaAttachmentIds: readonly number[]): void { + this.staging.releaseMedia(mediaAttachmentIds, []); + } + + requestQueuedGoalPromotion(): void { + this.sessionEventHandler.requestQueuedGoalPromotion(); + } + + private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void { + const imageAttachmentIds = + options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 + ? options.imageAttachmentIds + : undefined; + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: input, + imageAttachmentIds, + }); + // A goal-active steer is buffered into the running goal turn — no new + // turn.started will fire for handleTurnStarted to claim the lease — so + // bind it to that turn here. The turn context must be read BEFORE + // beginSessionRequest resets it, and only while a turn is actually live + // (finalizeTurn clears the id at turn end; a queued dispatch can land + // while the goal driver's next continuation turn is already streaming). + const runningTurnId = + this.state.appState.streamingPhase === 'idle' || this.state.appState.streamingPhase === 'shell' + ? undefined + : this.streamingUI.getTurnContext().turnId; + this.beginSessionRequest(); + + // Compression captions for pasted images are authored here — not at + // extraction — because only now is the session (and its media-originals + // dir) known: extraction runs before a first session exists. + const sdkInput = + options?.parts !== undefined + ? resolveOriginalCaptions( + options.parts, + options.imageAttachmentIds ?? [], + this.imageStore, + originalsDirForSession(session), + ) + : input; + const goalActive = this.state.appState.goal?.status === 'active'; + // The lease normally arrives pre-created by sendNormalUserInput (carrying + // its exact-binding submission id). Queued dispatches and steer batches + // arrive with raw ids instead: a prompt submission carrying staged + // media gets a client-chosen prompt id minted here — the engine echoes it + // on the consuming turn's `turn.started` (`promptId`), so the lease binds + // exactly instead of through the origin heuristic. The goal-steer path + // binds its lease explicitly below, so it gets no id. + const stagingIds = [ + ...(options?.imageAttachmentIds ?? []), + ...(options?.videoAttachmentIds ?? []), + ]; + const stagingLease = + options?.lease ?? + this.staging.create( + // One retain per unique id per extraction: dedupe repeated placeholder + // occurrences so the lease's id multiplicity matches the retain count. + [...new Set(stagingIds)], + [], + 'user', + !goalActive && stagingIds.length > 0 ? randomUUID() : undefined, + ); + const submissionId = stagingLease?.submissionId; + // While a goal is being pursued the engine holds its active turn across the + // whole continuation loop, so a fresh prompt races the goal driver at every + // continuation boundary and is rejected with `turn.agent_busy`, dropping + // the message. Steer instead: the engine buffers it into the running goal + // turn, or launches a turn of its own if the loop just ended. + if (goalActive) { + if (runningTurnId !== undefined) this.staging.bindToTurn(stagingLease, runningTurnId); + this.staging.trackDispatch(stagingLease, session.steer(sdkInput), (error) => { + // Same reset as the prompt path: beginSessionRequest already moved the + // TUI to the waiting phase, and no turn events may follow a failed + // steer (e.g. the session is gone), which would leave the UI stuck + // queueing input behind a request that never completes. + this.failSessionRequest(`Failed to steer: ${formatErrorMessage(error)}`); + }); + return; + } + this.staging.trackDispatch(stagingLease, session.prompt(sdkInput, { promptId: submissionId }), (error) => { + this.failSessionRequest(`Failed to send: ${formatErrorMessage(error)}`); + }); + } + + sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { + // Args are a plain-text channel, so pasted media can't ride along as + // inline parts. Skill args are XML-escaped on render (renderSkillAttributes + // + expandSkillParameters), so rewrite placeholders into escape-proof + // plain-text file references the model can open with ReadMediaFile. + let rewrite: ReturnType; + try { + rewrite = rewriteMediaPlaceholders(skillArgs, this.imageStore, 'plain'); + } catch (error) { + // Cache copy failed (unwritable cache dir, vanished video source…); + // nothing has been dispatched yet, so just report and keep the input. + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(rewrite)) { + this.staging.releaseMedia(rewrite.imageAttachmentIds, rewrite.stagingPaths); + return; + } + // Compacting (or deferred input): queue behind it — visible and recallable. + // Slash-skill items steer like any queued input on Ctrl-S (the activation + // fires into the running turn instead of the literal text) — see + // editor-keyboard.ts. + // A running turn queues the activation too: every skill behaves like + // plain input — queued by default, steered on demand — because the engine + // steers activations into a running turn exactly like a steered user + // message (v2 `prompt.inject`, v1 `SkillManager.recordActivation`). + // The rewritten args reference the staging cache copies by plain path, + // never the daemon uploads, so queueing takes recall semantics: the + // retains are consumed and the copies retire to session lifetime — they + // must stay readable until the item drains. + const turnRunning = this.state.appState.streamingPhase !== 'idle'; + if (this.deferUserMessages || this.state.appState.isCompacting || turnRunning) { + const args = rewrite.text.trim(); + this.state.queuedMessages.push({ + text: `/${skillName}${args.length > 0 ? ` ${args}` : ''}`, + agentId: this.harness.interactiveAgentId, + mode: 'skill', + skillName, + skillArgs: rewrite.text, + }); + this.staging.releaseRecalled([...rewrite.imageAttachmentIds], rewrite.stagingPaths); + this.track('input_queue'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + const stagingLease = this.staging.create( + [...new Set(rewrite.imageAttachmentIds)], + rewrite.stagingPaths, + 'skill_activation', + ); + this.beginSessionRequest(); + this.staging.trackDispatch( + stagingLease, + session.activateSkill(skillName, rewrite.text), + (error) => { + this.failSessionRequest(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`); + }, + ); + } + + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void { + // Plugin command args are expanded verbatim (no XML escaping), so the + // standard tag convention works — see + // sendSkillActivation for the escaped-channel variant. + let rewrite: ReturnType; + try { + rewrite = rewriteMediaPlaceholders(args, this.imageStore, 'tag'); + } catch (error) { + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + const stagingLease = this.staging.create( + [...new Set(rewrite.imageAttachmentIds)], + rewrite.stagingPaths, + 'plugin_command', + ); + if (!this.validateMediaCapabilities(rewrite)) { + this.staging.release(stagingLease); + return; + } + this.beginSessionRequest(); + this.staging.trackDispatch( + stagingLease, + session.activatePluginCommand(pluginId, commandName, rewrite.text), + (error) => { + this.failSessionRequest( + `Command "${pluginId}:${commandName}" failed: ${formatErrorMessage(error)}`, + ); + }, + ); + } + + private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { + const phase = this.state.appState.streamingPhase; + // Tower mode keeps the main agent as a long-lived coordinator: while its + // turn is live, new input steers into that turn instead of queueing + // behind it, so consecutive /tower objectives are accepted immediately + // rather than serialized one turn at a time. A foreground shell command + // ('shell') has no turn to steer into and keeps queue semantics, as do + // input deferral and compaction. + const steerIntoCoordinator = + this.state.appState.towerMode && + phase !== 'idle' && + phase !== 'shell' && + !this.deferUserMessages && + !this.state.appState.isCompacting; + // Submission order must survive a mid-turn compaction: objectives queued + // while compacting stay queued when the turn outlives the compaction, so + // steering this input ahead of them would reorder the conversation. + // Prompt-only backlog rides along in the same steer batch, ahead of the + // new input; a non-steerable backlog (bash, slash-skill, inline-skill + // bundle) cannot, and then this input queues behind it instead. + const backlog = this.state.queuedMessages; + const backlogSteerable = backlog.every( + (m) => m.inlineSkillActivations === undefined && m.mode !== 'bash' && m.mode !== 'skill', + ); + if (steerIntoCoordinator && backlogSteerable) { + // Same lease hand-off as the queue path below: the pre-dispatch lease + // defers to the raw ids on the steer item, which re-leases inside + // steerMessage and binds to the running turn. + this.staging.defer(options?.lease); + const items: SteerInputItem[] = [ + ...backlog.map((m) => ({ + text: m.text, + parts: m.parts, + imageAttachmentIds: m.imageAttachmentIds, + videoAttachmentIds: m.videoAttachmentIds, + })), + { + text: input, + parts: options?.parts, + imageAttachmentIds: options?.imageAttachmentIds, + videoAttachmentIds: options?.videoAttachmentIds, + }, + ]; + if (backlog.length > 0) { + this.state.queuedMessages = []; + this.updateQueueDisplay(); + } + this.steerMessage(session, items); + return; + } + if ( + this.deferUserMessages || + this.state.appState.streamingPhase !== 'idle' || + this.state.appState.isCompacting + ) { + // A queued message re-leases its staged media at dequeue dispatch; the + // pre-dispatch lease defers to the queue item's raw ids. + this.staging.defer(options?.lease); + this.enqueueMessage(input, options); + return; + } + this.sendMessageInternal(session, input, options); + } + + steerMessage(session: Session, input: readonly SteerInputItem[]): void { + if (this.deferUserMessages || this.state.appState.isCompacting) { + for (const item of input) { + this.enqueueMessage(item.text, item); + } + return; + } + if (this.state.appState.streamingPhase === 'idle') { + for (const item of input) { + this.sendMessageInternal(session, item.text, item); + } + return; + } + + for (const item of input) { + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: this.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: item.text, + imageAttachmentIds: + item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 + ? item.imageAttachmentIds + : undefined, + }); + } + + // Dedupe per item, not across the batch: each queued message retained a + // shared medium once, so the batch's id multiplicity is the retain count. + const mediaAttachmentIds = input.flatMap((item) => [ + ...new Set([...(item.imageAttachmentIds ?? []), ...(item.videoAttachmentIds ?? [])]), + ]); + const stagingLease = this.staging.create(mediaAttachmentIds, [], 'user'); + const currentTurnId = this.streamingUI.getTurnContext().turnId; + if (currentTurnId !== undefined) this.staging.bindToTurn(stagingLease, currentTurnId); + // Same dispatch-time caption resolution as sendMessageInternal — the + // running turn's session owns the persisted originals. + const resolvedInput = input.map((item) => + item.parts === undefined + ? item + : { + ...item, + parts: resolveOriginalCaptions( + item.parts, + item.imageAttachmentIds ?? [], + this.imageStore, + originalsDirForSession(session), + ), + }, + ); + this.staging.trackDispatch(stagingLease, session.steer(combineSteerInput(resolvedInput)), (error) => { + this.showError(`Failed to steer: ${formatErrorMessage(error)}`); + }); + } + + steerSkillActivation(session: Session, skillName: string, skillArgs: string): void { + // Ctrl-S on a queued slash-skill item: the activation fires into the + // running turn (the engine steers it there, never the literal text). No + // beginSessionRequest — the live pane belongs to the running turn. + void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + this.showError(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`); + }); + } + + // ========================================================================= + // State & Accessors + // ========================================================================= + + setStartupReady(): void { + this.state.startupState = 'ready'; + } + + clearQueuedMessages(): void { + const queued = this.state.queuedMessages; + this.state.queuedMessages = []; + this.staging.releaseQueued(queued); + } + + shiftQueuedMessage(): QueuedMessage | undefined { + if (this.state.queuedMessages.length === 0) return undefined; + const [first, ...rest] = this.state.queuedMessages; + this.state.queuedMessages = rest; + return first; + } + + pushTranscriptEntry(entry: TranscriptEntry): void { + this.state.transcriptEntries.push(entry); + } + + setExternalEditorRunning(running: boolean): void { + this.state.externalEditorRunning = running; + } + + setTasksBrowser(value: TUIState['tasksBrowser']): void { + this.state.tasksBrowser = value; + } + + appendStartupNotice(extra: string): void { + this.startupNotice = combineStartupNotice(this.startupNotice, extra); + } + + get backgroundTasks(): ReadonlyMap { + return this.sessionEventHandler.backgroundTasks; + } + + getCurrentSessionId(): string { + return this.state.appState.sessionId; + } + + hasSessionContent(): boolean { + return this.state.transcriptEntries.length > 0; + } + + setExitOpenUrl(url: string): void { + this.exitOpenUrl = url; + } + + setExitForegroundTask(task: (exitCode: number) => Promise): void { + this.exitForegroundTask = task; + } + + async getStartupMcpMs(): Promise { + const session = this.session; + if (session === undefined) return 0; + try { + const metrics = await session.getMcpStartupMetrics(); + return metrics.durationMs; + } catch { + return 0; + } + } + + setAppState(patch: Partial): void { + if (!hasPatchChanges(this.state.appState, patch)) return; + const additionalDirsChanged = + 'additionalDirs' in patch && + !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []); + const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; + Object.assign(this.state.appState, patch); + if ('planMode' in patch) this.updateEditorBorderHighlight(); + this.state.footer.setState(this.state.appState); + this.updateActivityPane(); + if (busyChanged) { + this.updateQueueDisplay(); + this.sessionEventHandler.retryQueuedGoalPromotion(); + } + if (additionalDirsChanged) this.setupAutocomplete(); + this.state.ui.requestRender(); + } + + patchLivePane(patch: Partial): void { + if (!hasPatchChanges(this.state.livePane, patch)) return; + Object.assign(this.state.livePane, patch); + this.updateActivityPane(); + this.state.ui.requestRender(); + } + + resetLivePane(): void { + this.state.livePane = { ...INITIAL_LIVE_PANE }; + this.updateActivityPane(); + this.state.ui.requestRender(); + } + + private syncAdditionalDirs(session: Session): void { + const additionalDirs = session.summary?.additionalDirs ?? []; + if (sameStringArrays(this.state.appState.additionalDirs, additionalDirs)) return; + this.setAppState({ additionalDirs: [...additionalDirs] }); + } + + // ========================================================================= + // Session Runtime + // ========================================================================= + + requireSession(): Session { + if (this.session === undefined) { + throw new Error(NO_ACTIVE_SESSION_MESSAGE); + } + return this.session; + } + + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap), so the footer and the lazy create path reflect them while + * no session exists. Runs at session-less startup and again on /reload + * while still session-less, so externally edited defaults take effect + * before the first lazy-created session. + */ + async hydrateLazyConfigDefaults(): Promise { + const { startup } = this.options; + const config = await this.harness.getConfig({ reload: true }); + const patch: Partial = {}; + const startupModel = startup.model ?? config.defaultModel; + if (startupModel !== undefined) { + patch.model = startupModel; + const selected = config.models?.[startupModel]; + if (selected?.maxContextSize !== undefined) { + patch.maxContextTokens = selected.maxContextSize; + } + } else { + // The default disappeared from config (edited externally): clear the + // previously hydrated value instead of passing a stale explicit model + // to the first lazy-created session. + patch.model = ''; + patch.maxContextTokens = 0; + } + // CLI --auto/--yolo/--plan win over config defaults; the flags are + // re-applied by applyStartupPermissionAndPlanToAppState at startup. + if (!startup.auto && !startup.yolo) { + // Reset to manual when the default was removed from config — a stale + // elevated mode must not be passed to the first lazy-created session. + patch.permissionMode = config.defaultPermissionMode ?? 'manual'; + } + // Track the config default itself (vs an explicit CLI --plan) so the lazy + // create path can tell which one would activate plan mode; a removed + // default also clears the hydrated footer value. + patch.configDefaultPlanMode = config.defaultPlanMode === true; + if (!startup.plan) { + patch.planMode = config.defaultPlanMode === true; + } + const effort = thinkingEffortFromConfig(config.thinking); + if (effort !== undefined) { + patch.thinkingEffort = effort; + } else if (startupModel !== undefined) { + // No concrete effort configured: mirror the engine, which resolves the + // model's default effort at createSession time. + const raw = config.models?.[startupModel]; + if (raw !== undefined) { + const providerType = config.providers?.[raw.provider]?.type; + patch.thinkingEffort = defaultThinkingEffortFor( + effectiveModelAlias(raw, providerType ?? raw.protocol), + ); + } + } + if (startup.agentProfile !== undefined || startup.agentFiles !== undefined) { + patch.agentProfile = startup.agentProfile; + patch.agentFiles = startup.agentFiles?.length ? [...startup.agentFiles] : undefined; + } + this.setAppState(patch); + } + + private async createSessionFromCurrentState(bindStartupAgent = false): Promise { + // Background warm-up of the cache-hint config on every new session. + this.cacheHint.refreshConfigInBackground(); + const model = this.state.appState.model.trim(); + if (model.length === 0) { + throw new Error(LLM_NOT_SET_MESSAGE); + } + // With an active session, carry the live plan state. Session-less (lazy + // creation / `/new` before the first session) on v2, pass only the + // explicit CLI --plan intent — and only when the engine is not already + // applying `defaultPlanMode` at create time (sessionLifecycleService), + // since re-entering an active plan mode throws. + const explicitPlanMode = + this.session !== undefined + ? this.state.appState.planMode + : this.options.startup.plan && this.state.appState.configDefaultPlanMode !== true; + const options: MutableCreateSessionOptions = { + workDir: this.state.appState.workDir, + model, + // With an active session, carry the live effort. Session-less (lazy + // creation / `/new` before the first session), carry the session-only + // thinking override chosen via Alt+S if any — never the initial 'off' + // default, which would force thinking off where the engine's config or + // model default would apply. + thinking: + this.session === undefined + ? this.state.appState.lazySessionThinking + : this.state.appState.thinkingEffort, + permission: this.state.appState.permissionMode, + planMode: explicitPlanMode ? true : undefined, + }; + if (this.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...this.state.appState.additionalDirs]; + } + if (bindStartupAgent) { + // The --agent/--agent-file startup binding is consumed by the first + // lazy-created session; `/new` sessions fall back to the default profile. + if (this.state.appState.agentProfile !== undefined) { + options.agentProfile = this.state.appState.agentProfile; + } + if (this.state.appState.agentFiles !== undefined) { + options.agentFiles = [...this.state.appState.agentFiles]; + } + } + return this.harness.createSession(options); + } + + /** + * Lazy-create the session on first use (v2 engine, session-less startup). + * Returns the existing session, or creates one from the current state and + * runs the same assembly `createNewSession` performs. Returns undefined and + * shows the error when creation fails; callers must still guard on + * `appState.model`. + * + * Concurrent first-use triggers (a double Enter, or a slash command right + * after a prompt) both observe `session === undefined`, so the first caller + * owns the creation and the rest share the in-flight promise — otherwise + * two sessions would be created and the later `setSession` would close the + * first one mid-dispatch. + */ + async ensureSession(): Promise { + // Even when a session is already assigned, a previous lazy creation may + // still be finishing its assembly (runtime sync, command refresh, + // subscription). Wait for it so callers never dispatch against a + // partially initialized session. + if (this.ensureSessionPromise !== null) return this.ensureSessionPromise; + if (this.session !== undefined) return this.session; + this.ensureSessionPromise = this.lazyCreateSession().finally(() => { + this.ensureSessionPromise = null; + }); + return this.ensureSessionPromise; + } + + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + async waitForLazyCreation(): Promise { + await this.ensureSessionPromise; + } + + private async lazyCreateSession(): Promise { + let session: Session; + try { + session = await this.createSessionFromCurrentState(true); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to start a session: ${msg}`); + return undefined; + } + this.resetSessionRuntime(); + await this.setSession(session); + this.setAppState({ sessionId: session.id }); + try { + await this.activateRuntime(); + await this.syncRuntimeState(session); + } catch (error) { + this.sessionEventHandler.startSubscription(); + const msg = formatErrorMessage(error); + this.showError(`Post-create setup failed: ${msg}`); + return undefined; + } + try { + await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); + } catch { + /* keep the new session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(session); + // The session-only thinking override was consumed by this session; the + // runtime status now owns the displayed effort. + if (this.state.appState.lazySessionThinking !== undefined) { + this.setAppState({ lazySessionThinking: undefined }); + } + return session; + } + + async setSession(session: Session): Promise { + const previous = this.unloadCurrentSession('switching session'); + await previous?.close(); + // A session switch abandons the previous session's in-flight staging + // leases and retires its history-owned cache copies. Do this at the + // boundary so retired paths cannot accumulate until process shutdown. + // Only when actually replacing a live session, though: on lazy first + // creation the outstanding lease belongs to the new session's first + // prompt, whose dispatch continues right after this — releasing it here + // would delete the staged media (e.g. a pasted image's daemon upload) + // before the engine's intake can read it. + if (previous !== undefined) this.staging.releaseAll(); + this.session = session; + this.harness.setTelemetryContext({ sessionId: session.id }); + this.registerSessionHandlers(session); + this.syncAdditionalDirs(session); + } + + async syncRuntimeState(session: Session = this.requireSession()): Promise { + const [status, goalResult] = await Promise.all([session.getStatus(), session.getGoal()]); + this.setAppState({ + sessionId: session.id, + model: status.model ?? '', + thinkingEffort: status.thinkingEffort, + permissionMode: status.permission, + planMode: status.planMode, + swarmMode: status.swarmMode ?? false, + towerMode: status.towerMode ?? false, + contextTokens: status.contextTokens, + maxContextTokens: status.maxContextTokens, + contextUsage: status.contextUsage, + cumulativeTokens: + status.usage?.total === undefined ? 0 : sumTokenUsage(status.usage.total), + sessionTitle: session.summary?.title ?? null, + goal: goalResult.goal, + }); + this.syncAdditionalDirs(session); + } + + // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed + // session may already be in plan mode from its persisted records, and + // re-entering plan mode throws, so only enable it when it is not active yet. + // setPermission is idempotent and needs no such guard. + private async applyStartupModesToResumedSession(session: Session): Promise { + const { startup } = this.options; + if (startup.auto) { + await session.setPermission('auto'); + } else if (startup.yolo) { + await session.setPermission('yolo'); + } + if (startup.plan) { + const status = await session.getStatus(); + if (!status.planMode) { + await session.setPlanMode(true); + } + } + } + + // Re-apply startup flags that the user explicitly passed on the command line. + // syncRuntimeState and session-replay hydration can both read stale persisted + // values, so this guarantees the footer reflects the CLI intent. + private applyStartupPermissionAndPlanToAppState(): void { + const { startup } = this.options; + if (startup.auto) { + this.setAppState({ permissionMode: 'auto' }); + } else if (startup.yolo) { + this.setAppState({ permissionMode: 'yolo' }); + } + if (startup.plan) { + this.setAppState({ planMode: true }); + } + } + + // Plan mode is set by createSession — do not re-enter it here. + private async activateRuntime(): Promise { + const session = this.requireSession(); + await session.setPermission(this.state.appState.permissionMode); + await this.syncRuntimeState(session); + } + + async closeSession(reason: string): Promise { + const previous = this.unloadCurrentSession(reason); + await previous?.close(); + this.staging.releaseAll(); + } + + private unloadCurrentSession(reason: string): Session | undefined { + const previous = this.session; + this.sessionEventUnsubscribe?.(); + this.sessionEventUnsubscribe = undefined; + this.clearReverseRpcPanels(); + previous?.setApprovalHandler(undefined); + previous?.setQuestionHandler(undefined); + this.approvalController.cancelAll(reason); + this.questionController.cancelAll(reason); + this.session = undefined; + this.state.swarmModeEntry = undefined; + this.harness.setTelemetryContext({ sessionId: null }); + this.setAppState({ goal: null }); + return previous; + } + + private clearReverseRpcPanels(): void { + for (const dispose of this.reverseRpcDisposers) { + dispose(); + } + this.reverseRpcDisposers.length = 0; + } + + private registerSessionHandlers(session: Session): void { + session.setApprovalHandler( + createApprovalRequestHandler(this.approvalController, (request, response) => { + this.appendApprovalTranscriptEntry(request, response); + }), + ); + session.setQuestionHandler(createQuestionAskHandler(this.questionController)); + } + + async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise { + this.state.loadingSessions = true; + this.state.sessionsScope = scope; + this.state.sessionsNextCursor = undefined; + this.state.sessionsLoadingMore = false; + try { + const page = await this.harness.listSessionsPage({ + workDir: scope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + }); + this.state.sessionsNextCursor = page.nextCursor; + this.state.sessions = sessionRowsForPicker( + page.items, + this.state.appState.sessionId, + this.hasSessionContent(), + ); + } catch (error) { + // The picker must keep working (it renders the empty state), but a + // swallowed failure surfaces as a misleading "No sessions found." — + // keep a log trail so the real error stays discoverable. + log.warn('failed to fetch sessions for picker', { error: String(error) }); + } finally { + this.state.loadingSessions = false; + } + } + + /** + * Pulls the next keyset page into the session picker (scroll-bottom paging). + * A scope switch or picker close bumps `sessionPickerScopeRequestToken`, + * which makes an in-flight append discard its result. Returns whether a page + * was appended — callers draining pages stop on the first `false`. + * Scroll triggers pass no argument and are dropped while a fetch is running; + * the search drain passes `waitForInFlight` to join the running fetch and + * continue with the next page, so a query typed mid-fetch still ends up + * covering every session. + */ + private async fetchMoreSessions(waitForInFlight = false): Promise { + while (this.sessionsPageFetchInFlight !== undefined) { + if (!waitForInFlight) return false; + await this.sessionsPageFetchInFlight; + } + const cursor = this.state.sessionsNextCursor; + if (cursor === undefined) return false; + const requestToken = this.sessionPickerScopeRequestToken; + this.state.sessionsLoadingMore = true; + this.sessionPickerComponent?.setPaging(true, true); + this.state.ui.requestRender(); + const run = this.appendNextSessionPage(cursor, requestToken); + this.sessionsPageFetchInFlight = run; + try { + return await run; + } finally { + if (this.sessionsPageFetchInFlight === run) this.sessionsPageFetchInFlight = undefined; + } + } + + private async appendNextSessionPage(cursor: string, requestToken: number): Promise { + try { + const page = await this.harness.listSessionsPage({ + workDir: this.state.sessionsScope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + before: cursor, + }); + if (requestToken !== this.sessionPickerScopeRequestToken) return false; + this.state.sessionsNextCursor = page.nextCursor; + const rows = sessionRowsForPicker( + page.items, + this.state.appState.sessionId, + this.hasSessionContent(), + ); + this.state.sessions = [...this.state.sessions, ...rows]; + this.sessionPickerComponent?.appendSessions(rows); + this.sessionPickerComponent?.setPaging(page.nextCursor !== undefined, false); + return true; + } catch (error) { + log.warn('failed to fetch more sessions for picker', { error: String(error) }); + return false; + } finally { + if (requestToken === this.sessionPickerScopeRequestToken) { + this.state.sessionsLoadingMore = false; + this.sessionPickerComponent?.setPaging(this.state.sessionsNextCursor !== undefined, false); + this.state.ui.requestRender(); + } + } + } + + /** + * Search covers every session: while a query is active the picker asks for + * all remaining pages, drained one at a time in the background. A failed or + * superseded fetch stops the drain (the next fresh query re-triggers it). + */ + private async drainSessionsForSearch(): Promise { + const requestToken = this.sessionPickerScopeRequestToken; + while ( + this.state.sessionsNextCursor !== undefined && + requestToken === this.sessionPickerScopeRequestToken + ) { + if (!(await this.fetchMoreSessions(true))) return; + } + } + + updateTerminalTitle(): void { + const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; + const label = trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; + this.state.terminal.setTitle(label); + } + + resetSessionRuntime(): void { + this.aborted = false; + this.cacheHint.resetRuntime(); + this.surveyController.reset(); + this.streamingUI.discardPending(); + this.clearQueuedMessages(); + this.state.swarmModeEntry = undefined; + this.streamingUI.resetToolCallState(); + this.streamingUI.resetToolUi(); + this.sessionEventHandler.resetRuntimeState(); + this.tasksBrowserController.close(); + this.btwPanelController.clear(); + this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); + this.streamingUI.setTodoList([]); + this.sessionEventHandler.notifications.clear(); + this.streamingUI.setTurnId(undefined); + this.setAppState({ mcpServersSummary: null }); + this.streamingUI.setStep(0); + this.streamingUI.resetLiveText(); + this.updateQueueDisplay(); + } + + private async showResumeOtherWorkDirHint(session: SessionRow): Promise { + this.hideSessionPicker(); + const command = `cd ${quoteShellArg(session.work_dir)} && kimi --resume ${quoteShellArg(session.id)}`; + const message = `Current session is in a different working directory.\n To resume, run: ${command}`; + try { + await copyTextToClipboard(command); + this.showStatus(`${message}\n Command copied to clipboard`, 'warning'); + } catch { + this.showStatus(`${message}\n Failed to copy command to clipboard`, 'warning'); + } + } + + private async resumeSession(targetSessionId: string): Promise { + // A first-use lazy creation may still be in flight: wait it out so the + // checks below see settled state — the pending prompt would otherwise + // replace the resumed session when creation completes. + await this.waitForLazyCreation(); + if (targetSessionId === this.state.appState.sessionId) { + this.showStatus('Already on this session.'); + return true; + } + if (this.state.appState.streamingPhase !== 'idle') { + this.showError('Cannot switch sessions while streaming — press Esc or Ctrl-C first.'); + return false; + } + if (this.state.appState.isReplaying) { + this.showError('Cannot switch sessions while history is replaying.'); + return false; + } + + let session: Session; + try { + session = await this.harness.resumeSession({ + id: targetSessionId, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to resume session ${targetSessionId}: ${msg}`); + return false; + } + + await this.switchToSession(session, `Resumed session (${session.id}).`); + return true; + } + + async switchToSession(session: Session, statusMessage: string): Promise { + this.resetSessionRuntime(); + await this.setSession(session); + await this.syncRuntimeState(session); + this.updateTerminalTitle(); + try { + await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); + } catch { + /* keep the switched session usable even if dynamic skills fail */ + } + this.clearTranscriptAndRedraw(); + try { + await this.sessionReplay.hydrateFromReplay(session); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to replay session history: ${msg}`); + } finally { + this.sessionEventHandler.startSubscription(); + } + const resumeState = session.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); + } + this.showStatus(statusMessage); + void this.showSessionWarnings(session); + void this.cacheHint.maybeShowOnResume(); + } + + async reloadCurrentSessionView(session: Session, statusMessage: string): Promise { + this.sessionEventUnsubscribe?.(); + this.sessionEventUnsubscribe = undefined; + this.clearReverseRpcPanels(); + session.setApprovalHandler(undefined); + session.setQuestionHandler(undefined); + this.approvalController.cancelAll('reloading session'); + this.questionController.cancelAll('reloading session'); + + this.resetSessionRuntime(); + this.session = session; + this.harness.setTelemetryContext({ sessionId: session.id }); + this.registerSessionHandlers(session); + await this.syncRuntimeState(session); + this.updateTerminalTitle(); + try { + await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); + } catch { + /* keep the reloaded session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + const resumeState = session.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); + } + this.showStatus(statusMessage); + void this.showSessionWarnings(session); + } + + async createNewSession(): Promise { + if (this.state.appState.isReplaying) { + this.showError('Cannot start a new session while history is replaying.'); + return; + } + + let session: Session; + try { + session = await this.createSessionFromCurrentState(); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to start a new session: ${msg}`); + return; + } + + this.resetSessionRuntime(); + await this.setSession(session); + this.setAppState({ sessionId: session.id }); + try { + await this.activateRuntime(); + await this.syncRuntimeState(session); + } catch (error) { + this.sessionEventHandler.startSubscription(); + const msg = formatErrorMessage(error); + this.showError(`Post-create setup failed: ${msg}`); + return; + } + try { + await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); + } catch { + /* keep the new session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + this.clearTranscriptAndRedraw(); + this.showStatus(`Started a new session (${session.id}).`); + void this.showSessionWarnings(session); + void this.showConfigWarningsIfAny(); + } + + /** Surface config.toml load warnings (degraded or kept-previous config) in the status bar. */ + private async showConfigWarningsIfAny(): Promise { + try { + const { warnings } = await this.harness.getConfigDiagnostics(); + for (const warning of warnings) { + this.showStatus(warning, 'warning'); + } + } catch { + /* diagnostics are best-effort */ + } + } + + // ========================================================================= + // Transcript Rendering + // ========================================================================= + + private createTranscriptComponent(entry: TranscriptEntry): Component | null { + if (entry.compactionData !== undefined) { + const data = entry.compactionData; + const block = new CompactionComponent(this.state.ui, data.instruction); + if (data.result === 'cancelled') { + block.markCanceled(); + } else { + block.markDone(data.tokensBefore, data.tokensAfter, data.summary); + if (this.state.toolOutputExpanded) { + block.setExpanded(true); + } + } + return block; + } + + switch (entry.kind) { + case 'user': { + const images = entry.imageAttachmentIds + ?.map((id) => this.imageStore.get(id)) + .filter((a): a is ImageAttachment => a?.kind === 'image'); + return new UserMessageComponent(entry.content, images, entry.bullet); + } + case 'skill_activation': + return new SkillActivationComponent( + entry.skillName ?? entry.content, + entry.skillArgs, + entry.skillTrigger, + ); + case 'plugin_command': { + const data = entry.pluginCommandData; + if (data === undefined) return null; + return new PluginCommandComponent(data.pluginId, data.commandName, data.args); + } + case 'cron': + return new CronMessageComponent(entry.content, entry.cronData ?? {}); + case 'goal': + if (entry.goalData?.kind === 'created') { + return new GoalSetMessageComponent(); + } + if (entry.goalData?.kind === 'lifecycle') { + return buildGoalMarker(entry.goalData.change, this.state.toolOutputExpanded); + } + return null; + case 'assistant': { + if (entry.content.trimStart().startsWith('✓ Goal complete')) { + return new GoalCompletionMessageComponent(entry.content); + } + const component = new AssistantMessageComponent(); + component.updateContent(entry.content); + return component; + } + case 'thinking': { + const thinking = new ThinkingComponent(entry.content, true); + if (this.state.toolOutputExpanded) thinking.setExpanded(true); + return thinking; + } + case 'tool_call': + if (entry.toolCallData) { + const tc = new ToolCallComponent( + entry.toolCallData, + entry.toolCallData.result, + this.state.ui, + this.state.appState.workDir, + ); + if (this.state.toolOutputExpanded) tc.setExpanded(true); + return tc; + } + if (entry.backgroundAgentStatus !== undefined) { + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); + } + return entry.renderMode === 'notice' + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); + case 'status': + if (entry.backgroundAgentStatus !== undefined) { + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); + } + return entry.renderMode === 'notice' + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); + case 'welcome': + return null; + default: + return null; + } + } + + appendTranscriptEntry(entry: TranscriptEntry): void { + this.state.transcriptEntries.push(entry); + const component = this.createTranscriptComponent(entry); + if (component) { + markTranscriptComponent(component, entry); + this.state.transcriptContainer.addChild(component); + } + const trimmed = this.trimTranscriptWindow(); + const merged = this.mergeCurrentTurnSteps(); + if (component || trimmed || merged) { + this.state.ui.requestRender(); + } + } + + private appendApprovalTranscriptEntry( + request: ApprovalRequest, + response: ApprovalResponse, + ): void { + if ( + request.toolName === 'ExitPlanMode' || + request.display.kind === 'plan_review' || + request.display.kind === 'goal_start' + ) + return; + const parts: string[] = []; + switch (response.decision) { + case 'approved': + parts.push(response.scope === 'session' ? 'Approved for session' : 'Approved'); + break; + case 'rejected': + parts.push('Rejected'); + break; + case 'cancelled': + parts.push('Cancelled'); + break; + } + parts.push(`: ${request.action}`); + if (response.feedback !== undefined && response.feedback.length > 0) { + parts.push(` — "${response.feedback}"`); + } + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: request.turnId === undefined ? undefined : String(request.turnId), + renderMode: 'notice', + content: parts.join(''), + }); + } + + private renderWelcome(): void { + if ( + this.state.transcriptContainer.children.some((child) => child instanceof WelcomeComponent) + ) { + return; + } + const welcome = new WelcomeComponent(this.state.appState); + this.state.transcriptContainer.addChild(welcome); + } + + private clearTerminalInlineImages(): void { + if (getCapabilities().images !== 'kitty') return; + this.state.terminal.write(deleteAllKittyImages()); + } + + private disposeTranscriptChildren(): void { + // Dispose disposable children (e.g. ShellRunComponent's 1s timer, + // ThinkingComponent's spinner) before dropping them, so a /clear, session + // switch, or shutdown can't leak intervals that keep firing requestRender + // on a removed component. + for (const child of this.state.transcriptContainer.children) { + if (hasDispose(child)) child.dispose(); + } + } + + private clearTranscriptAndRedraw(): void { + this.streamingUI.discardPending(); + this.state.transcriptEntries = []; + this.streamingUI.disposeActiveCompactionBlock(); + this.streamingUI.resetLiveText(); + this.streamingUI.resetToolUi(); + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.disposeTranscriptChildren(); + this.state.transcriptContainer.clear(); + this.btwPanelController.clear(); + this.clearTerminalInlineImages(); + this.state.todoPanel.clear(); + this.state.todoPanelContainer.clear(); + this.sessionEventHandler.notifications.clear(); + const stagingFileIds = this.imageStore.clear(); + this.staging.deleteStaged(stagingFileIds); + this.renderWelcome(); + // No forced full render on session reset: let the differential renderer + // converge on its own (a mass change above the viewport still makes the + // engine repaint everything, but nothing is forced destructively here). + this.state.ui.requestRender(); + } + + private isTurnBoundaryComponent(child: Component): boolean { + if ( + !(child instanceof UserMessageComponent) && + !(child instanceof SkillActivationComponent) && + !(child instanceof PluginCommandComponent) && + !(child instanceof ReplayTurnBoundaryComponent) + ) { + return false; + } + const entry = getTranscriptComponentEntry(child); + if (entry === undefined) return false; + // Live user messages / slash activations have an undefined turnId; replayed + // ones get a `replay:N` turnId. Both start a new turn. Steer messages carry + // a defined non-replay turnId and are not boundaries. + return entry.turnId === undefined || entry.turnId.startsWith('replay:'); + } + + /** + * Fold-segment boundary: everything {@link isTurnBoundaryComponent} counts, + * plus the cron card. A cron-fired turn mounts no user message, so without + * the card as a boundary its output would share the previous user turn's + * fold segment — and the completed-turn assistant cap would fold that turn's + * final answer into the step summary. + */ + private isFoldSegmentBoundaryComponent(child: Component): boolean { + return this.isTurnBoundaryComponent(child) || child instanceof CronMessageComponent; + } + + private trimTranscriptWindow(): boolean { + if (!TRANSCRIPT_WINDOW_ENABLED || TRANSCRIPT_MAX_TURNS <= 0) return false; + // Session replay already caps history to its own turn limit; trimming during + // replay would shrink it further and fight that limit. + if (this.state.appState.isReplaying) return false; + + const children = this.state.transcriptContainer.children; + + // Trim whole turns by *position* in the child list rather than by entry + // lookup — otherwise only the (registered) user message would be removed and + // the rest of the turn would be left behind. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + + const turns = groupTurns(this.state.transcriptEntries); + + const toRemove = turnsToTrim(turns, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_HYSTERESIS); + if (toRemove.size === 0) return false; + + // Reclaim image bytes referenced by trimmed user messages. The transcript + // renders historical thumbnails via imageStore.get(id), so an attachment can + // only be dropped once its owning user message leaves the transcript. + for (const entry of toRemove) { + if (entry.kind === 'user' && entry.imageAttachmentIds !== undefined) { + const stagingFileIds = this.imageStore.removeMany(entry.imageAttachmentIds); + this.staging.deleteStaged(stagingFileIds); + } + } + + let boundariesToRemove = 0; + for (const entry of toRemove) { + if ( + (entry.kind === 'user' || + entry.kind === 'skill_activation' || + entry.kind === 'plugin_command') && + entry.turnId === undefined + ) { + boundariesToRemove++; + } + } + if (boundariesToRemove === 0) { + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + let boundariesSeen = 0; + let cutoff = 0; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) { + if (boundariesSeen === boundariesToRemove) { + cutoff = i; + break; + } + boundariesSeen++; + } + } + + const componentsToRemove: Component[] = []; + for (let i = 0; i < cutoff; i++) { + const child = children[i]!; + if (child instanceof WelcomeComponent) continue; + componentsToRemove.push(child); + } + for (const child of componentsToRemove) { + // pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist. + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + this.state.transcriptContainer.removeChild(child); + if (hasDispose(child)) child.dispose(); + } + + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + mergeCurrentTurnSteps(): boolean { + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + ); + } + + /** + * Fold the just-finished turn's assistant messages down to the completed-turn + * cap: while a turn is live it may keep TRANSCRIPT_KEEP_RECENT_ASSISTANT + * messages mounted, but once it ends only the conclusion-bearing tail stays. + * Called when a turn finishes; the finished turn is still the current one at + * that point (no newer boundary exists yet). + */ + mergeCompletedTurnAssistants(): boolean { + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + ); + } + + private foldCurrentTurnContent(keepSteps: number, keepAssistants: number): boolean { + if (keepSteps <= 0 && keepAssistants <= 0) return false; + const children = this.state.transcriptContainer.children; + + // Find the start of the current fold segment. + let turnStart = -1; + for (let i = children.length - 1; i >= 0; i--) { + if (this.isFoldSegmentBoundaryComponent(children[i]!)) { + turnStart = i; + break; + } + } + if (turnStart < 0) return false; + + // Locate an existing summary, the assistant messages, and the mergeable steps. + let summaryIndex = -1; + const stepIndices: number[] = []; + const assistantIndices: number[] = []; + for (let i = turnStart + 1; i < children.length; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) { + summaryIndex = i; + continue; + } + if (child instanceof AssistantMessageComponent) { + assistantIndices.push(i); + continue; + } + stepIndices.push(i); + } + + // Fold the oldest steps / assistant messages beyond their respective caps; + // the most recent ones stay mounted. Children are chronological, so the + // oldest of each kind sit at the front of their index lists. + const stepMergeCount = keepSteps > 0 ? Math.max(0, stepIndices.length - keepSteps) : 0; + const assistantMergeCount = + keepAssistants > 0 ? Math.max(0, assistantIndices.length - keepAssistants) : 0; + if (stepMergeCount === 0 && assistantMergeCount === 0) return false; + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; + + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + if (thinkingCount === 0 && toolCount === 0 && assistantMergeCount === 0) return false; + + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } + + // Rebuild children: keep everything except the merged steps, with the summary + // sitting right after the user message. + const toMergeSet = new Set(toMergeIndices); + const newChildren: Component[] = []; + for (let i = 0; i <= turnStart; i++) newChildren.push(children[i]!); + newChildren.push(summary); + for (let i = turnStart + 1; i < children.length; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (hasDispose(child)) child.dispose(); + } + + children.splice(0, children.length, ...newChildren); + return true; + } + + mergeAllTurnSteps(): void { + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0 && TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED <= 0) + return; + const children = this.state.transcriptContainer.children; + + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isFoldSegmentBoundaryComponent(children[i]!)) boundaries.push(i); + } + if (boundaries.length === 0) return; + + const newChildren: Component[] = []; + const toDispose: Component[] = []; + for (let i = 0; i < boundaries[0]!; i++) newChildren.push(children[i]!); + + for (let t = 0; t < boundaries.length; t++) { + const turnStart = boundaries[t]!; + const turnEnd = t + 1 < boundaries.length ? boundaries[t + 1]! : children.length; + newChildren.push(children[turnStart]!); + + let summaryIndex = -1; + const stepIndices: number[] = []; + const assistantIndices: number[] = []; + for (let i = turnStart + 1; i < turnEnd; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) summaryIndex = i; + else if (child instanceof AssistantMessageComponent) assistantIndices.push(i); + else stepIndices.push(i); + } + + const stepMergeCount = + TRANSCRIPT_KEEP_RECENT_STEPS > 0 + ? Math.max(0, stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS) + : 0; + // Replayed turns are all completed turns, so the stricter completed-turn + // assistant cap applies (matching what live turns fold to on turn end). + const assistantMergeCount = + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED > 0 + ? Math.max(0, assistantIndices.length - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED) + : 0; + if (stepMergeCount > 0 || assistantMergeCount > 0) { + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } + newChildren.push(summary); + for (const idx of toMergeIndices) toDispose.push(children[idx]!); + const toMergeSet = new Set(toMergeIndices); + for (let i = turnStart + 1; i < turnEnd; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + } else { + for (let i = turnStart + 1; i < turnEnd; i++) newChildren.push(children[i]!); + } + } + + for (const child of toDispose) { + if (hasDispose(child)) child.dispose(); + } + children.splice(0, children.length, ...newChildren); + } + + showStatus(message: string, color?: ColorToken): void { + this.state.transcriptContainer.addChild(new StatusMessageComponent(message, color)); + this.state.ui.requestRender(); + } + + showNotice(title: string, detail?: string): void { + this.state.transcriptContainer.addChild(new NoticeMessageComponent(title, detail)); + this.state.ui.requestRender(); + } + + showError(message: string): void { + this.showStatus(`Error: ${message}`, 'error'); + } + + showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { + return this.showProgressSpinner(label); + } + + showProgressSpinner(label: string): LoginProgressSpinnerHandle { + const tint = (s: string): string => currentTheme.fg('primary', s); + const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); + this.state.transcriptContainer.addChild(new Spacer(1)); + this.state.transcriptContainer.addChild(spinner); + this.state.ui.requestRender(); + return { + stop: ({ ok, label: finalLabel }) => { + spinner.stop(); + const tone = ok ? 'success' : 'error'; + const symbol = ok ? '✓' : '✗'; + spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); + this.state.ui.requestRender(); + }, + setLabel: (nextLabel) => { + spinner.setLabel(nextLabel); + }, + }; + } + + showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { + openUrl(auth.verificationUriComplete); + this.state.transcriptContainer.addChild( + new DeviceCodeBoxComponent({ + title: 'Sign in to Kimi Code', + url: auth.verificationUriComplete, + code: auth.userCode, + hint: 'Press Ctrl-C to cancel', + }), + ); + this.state.ui.requestRender(); + return this.showLoginProgressSpinner('Waiting for authorization…'); + } + + // ========================================================================= + // Panes / Presentation State + // ========================================================================= + + updateActivityPane(): void { + const effectiveMode = this.resolveActivityPaneMode(); + const tipKind = loadingTipKind(effectiveMode); + // Pick a fresh loading tip when the loading kind changes. The same kind + // covers waiting/tool (both moon spinners) and any intermediate thinking + // phase, so a continuous burst of tool calls does not flip tips. Clear the + // cache only when there is no loading UI at all. + if (effectiveMode === 'idle' || effectiveMode === 'session' || effectiveMode === 'hidden') { + this.currentLoadingTip = undefined; + } else if ( + tipKind !== undefined && + (this.currentLoadingTip === undefined || this.currentLoadingTip.kind !== tipKind) + ) { + const previousTip = this.currentLoadingTip?.tip; + this.currentLoadingTip = { + kind: tipKind, + tip: pickRandomWorkingTip(previousTip)?.text, + }; + } + this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); + const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); + // Carry the retry state in the mode key so an incoming/cleared + // `turn.step.retrying` rebuilds the waiting pane with fresh label and + // detail instead of hitting the cached-pane early return below. + const retry = effectiveMode === 'waiting' ? this.state.appState.stepRetry : null; + const retryKey = + retry === null ? '' : `${formatStepRetryLabel(retry)}|${formatStepRetryDetail(retry)}`; + const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}:${retryKey}`; + + if ( + activityModeKey === this.lastActivityMode && + (effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool') + ) { + if (placeSpinnerInAgentSwarm) { + this.syncAgentSwarmActivitySpinner(this.state.activitySpinner?.instance); + } + return; + } + + this.lastActivityMode = activityModeKey; + this.state.activityContainer.clear(); + + switch (effectiveMode) { + case 'hidden': + this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); + this.state.ui.requestRender(); + return; + case 'waiting': { + const stepRetry = this.state.appState.stepRetry; + const spinner = this.ensureActivitySpinner('moon', waitingSpinnerLabel(stepRetry)); + this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); + if (placeSpinnerInAgentSwarm) break; + this.state.activityContainer.addChild( + new ActivityPaneComponent({ + mode: 'waiting', + spinner, + tip: stepRetry === null ? this.currentLoadingTip?.tip : undefined, + detail: stepRetry === null ? undefined : formatStepRetryDetail(stepRetry), + }), + ); + break; + } + case 'thinking': { + this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); + break; + } + case 'composing': { + const spinner = this.ensureActivitySpinner('braille', 'working…', (s) => + currentTheme.fg('primary', s), + ); + this.syncAgentSwarmActivitySpinner(undefined); + this.state.activityContainer.addChild( + new ActivityPaneComponent({ + mode: 'composing', + spinner, + tip: this.currentLoadingTip?.tip, + }), + ); + break; + } + case 'tool': { + const spinner = this.ensureActivitySpinner('moon'); + this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); + if (placeSpinnerInAgentSwarm) break; + this.state.activityContainer.addChild( + new ActivityPaneComponent({ + mode: 'tool', + spinner, + tip: this.currentLoadingTip?.tip, + }), + ); + break; + } + case 'idle': + case 'session': { + this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); + // Keep a placeholder row so the activity area does not fully shrink + // when the spinner is removed at the end of streaming; combined with + // pi-tui's clamp, this avoids a destructive full redraw (viewport jump). + this.state.activityContainer.addChild(new Spacer(1)); + break; + } + } + this.state.ui.requestRender(); + } + + private resolveActivityPaneMode(): EffectiveActivityPaneMode { + if (this.state.activeDialog === 'session-picker') return 'hidden'; + if (this.state.livePane.pendingApproval !== null) return 'hidden'; + if (this.state.appState.isCompacting) return 'hidden'; + if (this.state.livePane.pendingQuestion !== null) return 'hidden'; + + const streamingPhase = this.state.appState.streamingPhase; + + // A running `!` shell command shows the moon spinner (same as `waiting`) + // until it finishes, signalling that input is busy / queued. + if (streamingPhase === 'shell') return 'waiting'; + + if (this.state.livePane.mode === 'idle') { + if (streamingPhase === 'thinking' || streamingPhase === 'composing') { + return streamingPhase; + } + } + + return this.state.livePane.mode; + } + + updateQueueDisplay(): void { + this.state.queueContainer.clear(); + const queued = this.state.queuedMessages; + if (queued.length === 0) return; + + this.state.queueContainer.addChild( + new QueuePaneComponent({ + messages: queued, + isCompacting: this.state.appState.isCompacting, + isStreaming: this.state.appState.streamingPhase !== 'idle', + canSteerImmediately: !this.deferUserMessages, + }), + ); + } + + /** + * Index of the first transcript child ctrl+o may expand: a component is + * expandable only if it sits at or after the start of the + * (totalTurns - expandTurns)-th turn, i.e. it belongs to one of the most + * recent `expandTurns` turns. Position-based so it also covers streaming + * components that have no entry in the metadata map. + */ + private expandCutoff(children: readonly Component[]): number { + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + return expandCutoffIndex(children.length, boundaries, TRANSCRIPT_EXPAND_TURNS); + } + + /** + * What the footer's ctrl+o hint should offer: `expand` while a card in the + * expandable window keeps content out of its collapsed form, `collapse` + * once the toggle shows it, `null` when ctrl+o would change nothing. + */ + private toolOutputExpandHint(): 'expand' | 'collapse' | null { + const children = this.state.transcriptContainer.children; + if (this.state.toolOutputExpanded) { + // Toggling off collapses every expanded card, including one that slid + // out of the expansion window since it was expanded, so any expanded + // card with hidden content keeps the collapse hint on. + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (isExpandedComponent(child) && hasHiddenContent(child)) return 'collapse'; + } + return null; + } + const cutoff = this.expandCutoff(children); + for (let i = children.length - 1; i >= cutoff; i--) { + if (hasHiddenContent(children[i])) return 'expand'; + } + return null; + } + + toggleToolOutputExpansion(): void { + this.state.toolOutputExpanded = !this.state.toolOutputExpanded; + const children = this.state.transcriptContainer.children; + const expandCutoff = this.expandCutoff(children); + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + if (!isExpandable(child)) continue; + child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); + } + // Differential render only — no destructive full redraw on expand/collapse. + // (When the expanded region reaches above the viewport, the engine's own + // fallback may still do a full render; that path is not forced from here.) + this.state.ui.requestRender(); + } + + toggleTodoPanelExpansion(): void { + this.state.todoPanel.toggleExpanded(); + this.state.ui.requestRender(); + } + + toggleNotifyPanelFocus(): boolean { + return this.sessionEventHandler.notifications.toggleFocus(); + } + + handleNotifyPanelKey(key: 'left' | 'right' | 'up' | 'down' | 'escape'): boolean { + return this.sessionEventHandler.notifications.handlePanelKey(key); + } + + private async detachRunningShellCommand(): Promise { + // Only one `!` command runs at a time (input is queued while busy). + const next = this.shellOutputStreams.entries().next(); + if (next.done) { + this.showDetachHint('No shell command running.'); + return; + } + const [commandId, stream] = next.value; + if (stream.taskId === undefined) { + this.showDetachHint('Command is still starting — try again.'); + return; + } + const session = this.session; + if (session === undefined) return; + try { + const info = await session.detachBackgroundTask(stream.taskId); + if (info === undefined) { + this.showDetachHint('Command already finished.'); + return; + } + } catch (error) { + this.showError(`Failed to move to background: ${formatErrorMessage(error)}`); + return; + } + // Finalize the card as backgrounded and drop the stream so the eventual + // runShellCommand resolution (which carries background metadata) is a no-op + // instead of overwriting this view. + stream.component.finishBackgrounded(); + stream.entry.content = 'Moved to background.'; + this.shellOutputStreams.delete(commandId); + // The backgrounded command's notification turn (started by the engine via + // appendSystemReminderAndNotify) owns the streaming phase and drains the + // queue when it completes, so we intentionally leave both untouched here. + this.showDetachHint('Moved to background. /tasks to view.'); + } + + async detachCurrentForegroundTask(): Promise { + // A running `!` shell command takes priority over agent foreground tasks. + if (this.shellOutputStreams.size > 0) { + await this.detachRunningShellCommand(); + return; + } + + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let tasks: readonly BackgroundTaskInfo[]; + try { + // activeOnly defaults to true; foreground running tasks are non-terminal + // and therefore included. We filter to `detached === false` ourselves. + tasks = await session.listBackgroundTasks(); + } catch (error) { + this.showError(`Failed to list tasks: ${formatErrorMessage(error)}`); + return; + } + + const targets = pickForegroundTasks(tasks); + if (targets.length === 0) { + this.showDetachHint('No foreground task running.'); + return; + } + + let detached = 0; + let alreadyFinished = 0; + for (const target of targets) { + try { + const info = await session.detachBackgroundTask(target.taskId); + if (info === undefined) alreadyFinished++; + else detached++; + } catch (error) { + this.showError(`Failed to detach ${target.taskId}: ${formatErrorMessage(error)}`); + } + } + + let hint: string; + if (detached === 0 && alreadyFinished > 0) { + hint = alreadyFinished === 1 ? 'Task already finished.' : 'Tasks already finished.'; + } else if (detached === targets.length) { + hint = + detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`; + } else { + hint = `Moved ${detached} of ${targets.length} tasks to background.`; + } + if (detached > 0) hint = `${hint} /tasks to view.`; + this.showDetachHint(hint); + } + + /** Show a one-shot footer hint that auto-clears after DETACH_HINT_DISPLAY_MS. */ + private showDetachHint(hint: string): void { + if (this.detachHintClearTimer !== undefined) { + clearTimeout(this.detachHintClearTimer); + this.detachHintClearTimer = undefined; + } + this.state.footer.setTransientHint(hint); + this.detachHintClearTimer = setTimeout(() => { + this.detachHintClearTimer = undefined; + // Don't clobber a newer transient hint (e.g. the exit-confirmation + // prompt) that took over while this timer was pending. + if (this.state.footer.getTransientHint() !== hint) return; + this.state.footer.setTransientHint(null); + this.state.ui.requestRender(); + }, DETACH_HINT_DISPLAY_MS); + this.state.ui.requestRender(); + } + + updateEditorBorderHighlight(text?: string): void { + const trimmed = (text ?? this.state.editor.getText()).trimStart(); + const isBash = this.state.appState.inputMode === 'bash'; + const highlighted = this.state.appState.planMode || isBash || trimmed.startsWith('/'); + this.state.editor.borderHighlighted = highlighted; + // Shell mode gets its own hue; plan-mode and slash context stay primary. + const borderToken = isBash ? 'shellMode' : highlighted ? 'primary' : 'border'; + this.state.editor.borderColor = (s: string) => currentTheme.fg(borderToken, s); + this.state.ui.requestRender(); + } + + /** + * Live pre-send warning in the footer while the typed `/goal` objective + * exceeds the length limit, so the user can trim it (or move it into a + * file) before submitting instead of losing the input to a rejection. + * `undefined` input means the text cannot be a `/goal` command and is not + * measured at all. The footer keeps this warning in its own slot, so + * transient hints (exit confirm, detach, image paste) only displace it + * temporarily. + */ + updateGoalLengthWarning(text: string | undefined): void { + const warning = text === undefined ? undefined : goalObjectiveLengthWarning(text); + this.state.footer.setWarningHint(warning ?? null); + this.state.ui.requestRender(); + } + + async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise { + const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName); + currentTheme.setPalette(palette); + this.setAppState({ theme: themeName }); + this.updateEditorBorderHighlight(); + // Force every historical message to re-render so Markdown/Text caches + // (which hold old ANSI colour codes) are cleared. + this.state.transcriptContainer.invalidate(); + this.state.ui.requestRender(true); + } + + refreshTerminalThemeTracking(): void { + this.stopTerminalThemeTracking(); + if (!isBuiltInTheme(this.state.appState.theme) || this.state.appState.theme !== 'auto') return; + + this.terminalThemeTrackingDispose = installTerminalThemeTracking(this.state, (resolved) => { + void this.applyResolvedAutoTheme(resolved); + }); + } + + private stopTerminalThemeTracking(): void { + this.terminalThemeTrackingDispose?.(); + this.terminalThemeTrackingDispose = undefined; + } + + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise { + if (this.state.appState.theme !== 'auto') return; + const palette = getBuiltInPalette(resolved); + if (currentTheme.palette === palette) return; + currentTheme.setPalette(palette); + this.updateEditorBorderHighlight(); + // Repaint already-rendered transcript entries (status/markdown caches hold + // old ANSI codes), matching applyTheme()'s behaviour. + this.state.transcriptContainer.invalidate(); + this.state.ui.requestRender(true); + } + + private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { + if (this.state.appState.isCompacting) return true; + return ( + effectiveMode === 'waiting' || + effectiveMode === 'thinking' || + effectiveMode === 'composing' || + effectiveMode === 'tool' + ); + } + + private shouldPlaceActivitySpinnerInAgentSwarm( + effectiveMode: EffectiveActivityPaneMode, + ): boolean { + return ( + this.sessionEventHandler.hasActiveAgentSwarmToolCall() && + (effectiveMode === 'waiting' || effectiveMode === 'tool') + ); + } + + private syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + this.sessionEventHandler.syncAgentSwarmActivitySpinner(spinner); + } + + private syncTerminalProgress(active: boolean): void { + if (!this.state.terminalState.supportsProgress) return; + if (this.state.terminalState.progressActive === active) return; + this.state.terminal.setProgress(active); + this.state.terminalState.progressActive = active; + } + + private ensureActivitySpinner( + style: SpinnerStyle, + label = '', + colorFn?: (s: string) => string, + ): MoonLoader { + if (this.state.activitySpinner?.style !== style) { + this.stopActivitySpinner(); + } + + if (this.state.activitySpinner === null) { + const instance = new MoonLoader(this.state.ui, style, colorFn, label); + this.state.activitySpinner = { instance, style }; + return instance; + } + + this.state.activitySpinner.instance.setLabel(label); + if (colorFn !== undefined) { + this.state.activitySpinner.instance.setColorFn(colorFn); + } + return this.state.activitySpinner.instance; + } + + private stopActivitySpinner(): void { + if (this.state.activitySpinner !== null) { + this.state.activitySpinner.instance.stop(); + this.state.activitySpinner = null; + } + } + + // ========================================================================= + // Dialogs / Selectors + // ========================================================================= + + mountEditorReplacement(panel: Component & Focusable): void { + this.surveyController.closeSilently(); + this.state.editorReplacementMounted = true; + this.state.editorContainer.clear(); + this.state.editorContainer.addChild(panel); + this.state.ui.setFocus(panel); + this.state.ui.requestRender(); + } + + restoreEditor(): void { + this.state.editorReplacementMounted = false; + this.state.editorContainer.clear(); + this.state.editorContainer.addChild(this.state.editor); + this.state.ui.setFocus(this.state.editor); + // Differential render only: closing a tall panel leaves the editor a few + // rows above the bottom (blank tail) until the next append, but avoids a + // destructive full redraw on every dialog close. + this.state.ui.requestRender(); + } + + restoreInputText(text: string): void { + this.restoreEditor(); + this.state.editor.setText(text); + this.updateEditorBorderHighlight(text); + this.state.ui.requestRender(); + } + + /** Latest in-process LLM round-trip; feeds the idle cache-hint scenario. */ + recordSessionActivity(): void { + this.cacheHint.recordActivity(); + } + + /** Per-step usage for the client-side cache-break detector. */ + noteStepUsage(usage: TokenUsage | undefined): void { + this.cacheHint.noteStepUsage(usage); + } + + /** Compaction shrinks the cached prefix — reset the cache-break baseline. */ + noteCompactionFinished(): void { + this.cacheHint.resetCacheBreakBaseline(); + } + + /** /undo cut the context — the next step's cache drop is expected. */ + noteContextCut(): void { + this.cacheHint.resetCacheBreakBaseline(); + } + + private async runMigrationScreen(plan: MigrationPlan): Promise { + const result = await new Promise((resolve) => { + const screen = new MigrationScreenComponent({ + plan, + sourceHome: plan.sourceHome, + targetHome: this.harness.homeDir, + skipDecisionStep: this.migrateOnly, + requestRender: () => { + this.state.ui.requestRender(); + }, + onComplete: (r) => { + resolve(r); + }, + }); + this.mountEditorReplacement(screen); + }); + this.restoreEditor(); + if (result.decision === 'never') { + // Persist the skip marker `detectPendingMigration` checks, so "Never ask + // again" actually stops the prompt from reappearing every launch. + try { + writeFileSync(join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), '', 'utf-8'); + } catch { + // Non-blocking: a failed marker write must never crash startup. + } + } + return result; + } + + /** + * agent-core-v2 startup gate: before any session is created, ask whether to + * trust this folder when the workspace is not trusted yet (project-level MCP + * servers stay disabled while untrusted). Best-effort throughout — a failed + * check or trust write never blocks startup. Choosing "don't trust" (or Esc) + * exits the program before any session is created; the prompt reappears on + * the next launch: the engine's untrusted state is indistinguishable from + * never-trusted. Returns true when the prompt started the event loop (the + * caller must not start it again). + */ + private async maybeRunWorkspaceTrustPrompt(): Promise { + const workDir = this.state.appState.workDir; + let info: WorkspaceTrustInfo; + try { + info = await this.harness.getWorkspaceTrustInfo(workDir); + } catch { + return false; + } + if (info.trusted) return false; + this.startEventLoop(); + const choice = await new Promise((resolve) => { + this.state.activeDialog = 'trust-prompt'; + this.mountEditorReplacement( + new TrustPromptComponent({ + workDir, + gatedMcpServers: info.gatedMcpServers, + onSelect: (c) => { + resolve(c); + }, + }), + ); + }); + this.state.activeDialog = null; + if (choice !== 'trust') { + // Declining trust exits the program (Claude Code's "No, exit" semantics): + // stop() runs the standard shutdown path and ends in process.exit. The + // editor is NOT restored first — its frame would linger as an orphaned + // input box above the exit message; the prompt stays as the last frame. + await this.stop(); + return true; + } + this.restoreEditor(); + try { + await this.harness.trustWorkspace(workDir); + } catch { + // A failed write leaves the workspace untrusted (re-asked next launch). + } + return true; + } + + showHelpPanel(): void { + this.state.activeDialog = 'help'; + this.mountEditorReplacement( + new HelpPanelComponent({ + commands: this.getSlashCommands(), + onClose: () => { + this.hideHelpPanel(); + }, + }), + ); + } + + private hideHelpPanel(): void { + this.state.activeDialog = null; + this.restoreEditor(); + } + + private sessionPickerOptions: { + readonly applyStartupModes: boolean; + readonly closeOnCancel: boolean; + readonly forwardEditorExit: boolean; + } = { + applyStartupModes: false, + closeOnCancel: false, + forwardEditorExit: false, + }; + private sessionPickerScopeRequestToken = 0; + private sessionPickerComponent: SessionPickerComponent | undefined; + private sessionsPageFetchInFlight: Promise | undefined; + + async showSessionPicker(): Promise { + await this.openSessionPicker({ + applyStartupModes: false, + closeOnCancel: false, + forwardEditorExit: false, + }); + } + + private async bootstrapFromPicker(): Promise { + await this.openSessionPicker({ + applyStartupModes: true, + closeOnCancel: true, + forwardEditorExit: true, + }); + } + + private async openSessionPicker(options: { + readonly applyStartupModes: boolean; + readonly closeOnCancel: boolean; + readonly forwardEditorExit: boolean; + }): Promise { + this.sessionPickerOptions = options; + await this.fetchSessions('cwd'); + this.remountSessionPicker(); + } + + private async toggleSessionPickerScope(selectedSessionId: string): Promise { + const requestToken = ++this.sessionPickerScopeRequestToken; + const nextScope = this.state.sessionsScope === 'cwd' ? 'all' : 'cwd'; + await this.fetchSessions(nextScope); + if (requestToken !== this.sessionPickerScopeRequestToken) return; + if (this.state.activeDialog !== 'session-picker') return; + this.remountSessionPicker(selectedSessionId); + } + + private remountSessionPicker(initialSelectedSessionId?: string): void { + this.mountSessionPicker({ + initialSelectedSessionId, + applyStartupModes: this.sessionPickerOptions.applyStartupModes, + onCancel: () => { + this.hideSessionPicker(); + if (this.sessionPickerOptions.closeOnCancel) void this.stop(); + }, + onCtrlC: this.sessionPickerOptions.forwardEditorExit + ? () => { + this.state.editor.onCtrlC?.(); + } + : undefined, + onCtrlD: this.sessionPickerOptions.forwardEditorExit + ? () => { + this.state.editor.onCtrlD?.(); + } + : undefined, + }); + } + + hideSessionPicker(): void { + this.sessionPickerScopeRequestToken += 1; + this.sessionPickerComponent = undefined; + this.editorKeyboard.clearPendingExit(); + this.state.activeDialog = null; + this.restoreEditor(); + } + + private async deleteSessionFromPicker(session: SessionRow): Promise { + // Invalidate any pending scope-toggle remount: it would replace the picker + // that is about to lock itself for the delete. + this.sessionPickerScopeRequestToken += 1; + try { + await this.waitForLazyCreation(); + if (session.id === this.state.appState.sessionId && this.session !== undefined) { + await this.deleteCurrentSessionFromPicker(session); + return; + } + await this.harness.deleteSession(session.id); + // fetchSessions swallows refetch errors, so drop the row locally first — + // a failed refetch must not resurrect it in the remounted list. + this.state.sessions = this.state.sessions.filter((row) => row.id !== session.id); + const requestToken = ++this.sessionPickerScopeRequestToken; + await this.fetchSessions(this.state.sessionsScope); + if (requestToken !== this.sessionPickerScopeRequestToken) return; + if (this.state.activeDialog !== 'session-picker') return; + this.remountSessionPicker(); + this.showStatus('Session deleted.'); + } catch (error) { + this.showError(`Failed to delete session ${session.id}: ${formatErrorMessage(error)}`); + } + } + + private async deleteCurrentSessionFromPicker(session: SessionRow): Promise { + // The picker stays mounted (locking input) until the replacement session + // is ready — restoring the editor mid-flight would let a prompt race the swap. + try { + // Tear down before deleting so no events from the dying session reach the UI. + await this.closeSession('deleting session'); + await this.harness.deleteSession(session.id); + } catch (error) { + // The engine aborts a failed delete and keeps the session: reattach, + // falling back to a fresh session if it is gone. showError runs after + // the switch because switchToSession clears the transcript. + const message = `Failed to delete session ${session.id}: ${formatErrorMessage(error)}`; + try { + const resumed = await this.harness.resumeSession({ + id: session.id, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + await this.switchToSession(resumed, `Resumed session (${resumed.id}).`); + } catch { + // Reattach failed and the session is already unloaded: detach before + // the fallback create so a failed create leaves no ghost UI behind. + this.setAppState({ sessionId: '' }); + this.clearTranscriptAndRedraw(); + await this.createNewSession(); + } + this.showError(message); + this.hideSessionPicker(); + return; + } + // The session is gone whether or not replacement creation succeeds: detach + // first so a failed create leaves no ghost (stale id + transcript) behind. + this.setAppState({ sessionId: '' }); + this.clearTranscriptAndRedraw(); + await this.createNewSession(); + this.hideSessionPicker(); + } + + openUndoSelector(): void { + void slashCommands.handleUndoCommand(this, ''); + } + + private mountSessionPicker(options: { + readonly onCancel: () => void; + readonly onCtrlC?: () => void; + readonly onCtrlD?: () => void; + readonly initialSelectedSessionId?: string; + // CLI mode flags (--auto/--yolo/--plan) target the session picked at + // startup (bare --session); later /sessions switches keep the picked + // session's own persisted modes. + readonly applyStartupModes?: boolean; + }): void { + this.state.activeDialog = 'session-picker'; + const picker = new SessionPickerComponent({ + sessions: this.state.sessions, + loading: this.state.loadingSessions, + currentSessionId: this.state.appState.sessionId, + scope: this.state.sessionsScope, + initialSelectedSessionId: options.initialSelectedSessionId, + pageSize: SESSION_LIST_PAGE_SIZE, + hasMore: this.state.sessionsNextCursor !== undefined, + loadingMore: this.state.sessionsLoadingMore, + onLoadMore: () => { + void this.fetchMoreSessions(); + }, + onSearchDrain: () => { + void this.drainSessionsForSearch(); + }, + onSelect: (session: SessionRow) => + this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + (error) => { + this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); + }, + ), + onCancel: options.onCancel, + onCtrlC: options.onCtrlC, + onCtrlD: options.onCtrlD, + onToggleScope: (selectedSessionId: string) => { + void this.toggleSessionPickerScope(selectedSessionId); + }, + onDeleteRequest: (session: SessionRow) => this.deleteSessionFromPicker(session), + }); + this.sessionPickerComponent = picker; + this.mountEditorReplacement(picker); + } + + private async handleSessionPickerSelect( + session: SessionRow, + applyStartupModes: boolean, + ): Promise { + // Invalidate any pending scope-toggle remount: it would replace the picker + // and drop the selection lock. + this.sessionPickerScopeRequestToken += 1; + if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) { + await this.showResumeOtherWorkDirHint(session); + if (applyStartupModes) await this.stop(0); + return; + } + + const switched = await this.resumeSession(session.id); + if (!switched) return; + if (applyStartupModes) { + await this.applyStartupModesToResumedSession(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); + } + this.hideSessionPicker(); + } + + private showApprovalPanel(payload: ApprovalPanelData): void { + this.patchLivePane({ pendingApproval: { data: payload } }); + notifyTerminalOnce(this.state, `approval:${payload.id}`, { + title: 'Kimi Code approval required', + body: payload.tool_name, + }); + const panel = new ApprovalPanelComponent( + { data: payload }, + (response: ApprovalPanelResponse) => { + this.approvalController.respond(adaptPanelResponse(response)); + }, + () => { + this.toggleToolOutputExpansion(); + }, + (block) => { + this.openApprovalPreview(panel, block); + }, + ); + this.activeApprovalPanel = panel; + this.mountEditorReplacement(panel); + } + + private hideApprovalPanel(): void { + // If the full-screen preview is open, fold it back first so the saved- + // children stack stays consistent with what mountEditorReplacement set up. + if (this.approvalPreview !== undefined) this.closeApprovalPreview(); + this.activeApprovalPanel = undefined; + this.patchLivePane({ pendingApproval: null }); + this.restoreEditor(); + } + + // Mounts the full-screen approval preview viewer on top of the current + // approval panel. Uses the same nested-takeover pattern as + // openTaskOutputViewer: beginScreenTakeover swaps the viewer in (root + // children in regular mode, layout root in fullscreen) and closing restores + // it. The approval panel instance is + // kept around in `activeApprovalPanel` so its selection state survives. + private openApprovalPreview(panel: ApprovalPanelComponent, block: ApprovalPreviewBlock): void { + if (this.approvalPreview !== undefined) return; + const viewer = new ApprovalPreviewViewer( + { + block, + onClose: () => { + this.closeApprovalPreview(); + }, + }, + this.state.terminal, + ); + const takeover = beginScreenTakeover(this.state.ui, viewer); + this.state.ui.setFocus(viewer); + this.state.ui.requestRender(true); + this.approvalPreview = { component: viewer, takeover, panel }; + } + + private closeApprovalPreview(): void { + const preview = this.approvalPreview; + if (preview === undefined) return; + this.approvalPreview = undefined; + endScreenTakeover(this.state.ui, preview.takeover); + this.state.ui.setFocus(preview.panel); + this.state.ui.requestRender(true); + } + + private showQuestionDialog(payload: QuestionPanelData): void { + this.patchLivePane({ pendingQuestion: { data: payload } }); + notifyTerminalOnce(this.state, `question:${payload.id}`, { + title: 'Kimi Code needs your answer', + body: payload.questions[0]?.question, + }); + const dialog = new QuestionDialogComponent( + { data: payload }, + (response) => { + this.questionController.respond(response); + }, + 6, + () => { + this.toggleToolOutputExpansion(); + }, + ); + this.mountEditorReplacement(dialog); + } + + private hideQuestionDialog(): void { + this.patchLivePane({ pendingQuestion: null }); + this.restoreEditor(); + } +} diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4f4e8f14cabfd4c27607bdc5e101cc979d5f4e6 --- /dev/null +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -0,0 +1,207 @@ +import { + Container, + ProcessTerminal, + ScrollView, + TuiAltScreen, + TuiMainScreen, + VStack, + type TUI, +} from '@moonshot-ai/pi-tui'; + +import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { openUrl } from '#/utils/open-url'; + +import { FooterComponent } from './components/chrome/footer';import { GutterContainer } from './components/chrome/gutter-container'; +import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; +import { NotifyPanelComponent } from './components/chrome/notify-panel'; +import { TodoPanelComponent } from './components/chrome/todo-panel'; +import type { SessionRow } from './components/dialogs/session-picker'; +import { CustomEditor } from './components/editor/custom-editor'; +import { DEFAULT_TUI_CONFIG } from './config'; +import { CHROME_GUTTER } from './constant/rendering'; +import type { TasksBrowserState } from './controllers/tasks-browser'; +import { currentTheme, type Theme } from './theme'; +import { setMarkdownRenderLatex } from './utils/markdown-options'; +import { createTerminalState, type TerminalState } from './utils/terminal-state'; +import { + INITIAL_LIVE_PANE, + type AppState, + type KimiTUIOptions, + type LivePaneState, + type QueuedMessage, + type TranscriptEntry, + type TUIStartupState, +} from './types'; + +export interface TUIState { + ui: TUI; + terminal: ProcessTerminal; + transcriptContainer: Container; + activityContainer: Container; + todoPanelContainer: Container; + todoPanel: TodoPanelComponent; + notifyPanelContainer: Container; + notifyPanel: NotifyPanelComponent; + queueContainer: Container; + btwPanelContainer: Container; + surveyContainer: Container; + editorContainer: Container; + /** + * Fullscreen mode only: the bottom dock (activity/todo/notify/queue/btw/editor + + * footer) stacked under the transcript ScrollView. Undefined in regular + * mode, where all chrome is a direct child of the root container. + */ + dockContainer: VStack | undefined; + footer: FooterComponent; + editor: CustomEditor; + theme: Theme; + appState: AppState; + startupState: TUIStartupState; + livePane: LivePaneState; + transcriptEntries: TranscriptEntry[]; + terminalState: TerminalState; + activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; + toolOutputExpanded: boolean; + sessions: SessionRow[]; + loadingSessions: boolean; + /** Keyset cursor for the next older page; `undefined` when the listing is exhausted. */ + sessionsNextCursor: string | undefined; + /** A follow-up session page fetch is in flight. */ + sessionsLoadingMore: boolean; + sessionsScope: 'cwd' | 'all'; + activeDialog: 'session-picker' | 'help' | 'trust-prompt' | 'cache-hint' | null; + /** + * True while an editor-replacement panel (help, trust prompt, goal queue + * manager, …) is mounted in place of the editor. Delayed input restores + * must not run in that state — they would displace the newer panel. + */ + editorReplacementMounted: boolean; + tasksBrowser: TasksBrowserState | undefined; + externalEditorRunning: boolean; + queuedMessages: QueuedMessage[]; + /** + * True while a queued user message has been shifted out of + * {@link queuedMessages} but its deferred send has not run yet. The queue + * looks empty during this window, so queued-goal promotion must also check + * this flag to avoid starting a goal ahead of the user's earlier message. + */ + queuedMessageDispatchPending: boolean; + swarmModeEntry: 'manual' | 'task' | undefined; +} + +export function createTUIState(options: KimiTUIOptions): TUIState { + const initialAppState = options.initialAppState; + const theme = currentTheme; + + const terminal = new ProcessTerminal(); + setMarkdownRenderLatex(initialAppState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true); + // Fullscreen is experimental and env-gated for now: KIMI_CODE_TUI_FULL_SCREEN=1. + const fullscreen = process.env['KIMI_CODE_TUI_FULL_SCREEN'] === '1'; + const ui = + fullscreen + ? new TuiAltScreen(terminal, undefined, undefined, { + // Mouse capture takes over the terminal's native link activation, so + // route OSC 8 clicks through our own opener. + openUrl, + // Likewise, on Windows the terminal's native right-click paste is + // intercepted; feed the clipboard to the focused component as a + // bracketed paste instead (renderer only calls this on win32). + onRightClickPaste: () => { + const target = ui.getFocusedComponent(); + if (!target?.handleInput || clipboard?.getText === undefined) return; + void clipboard + .getText() + .then((text) => { + if (!text || ui.getFocusedComponent() !== target) return; + target.handleInput?.(`\x1b[200~${text}\x1b[201~`); + ui.requestRender(); + }) + .catch(() => {}); + }, + }) + : new TuiMainScreen(terminal); + + const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const todoPanel = new TodoPanelComponent(); + const notifyPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const notifyPanel = new NotifyPanelComponent(); + const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const surveyContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const editor = new CustomEditor(ui, { + disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + }); + const footer = new FooterComponent({ ...initialAppState }, () => { + ui.requestRender(); + }); + + let dockContainer: VStack | undefined; + if (ui instanceof TuiAltScreen) { + // Fullscreen (alternate screen): the transcript scrolls inside the primary + // ScrollView while the rest of the chrome stays docked at the bottom. The + // footer joins the dock later via mountFooter(). + // Sizing contract (mirrors pi's interactive layout): the transcript starts + // from basis 0 and grows; the dock keeps its intrinsic height, with the + // editor never squeezed below its 3 rows (top border / input / bottom + // border) and the footer below 1 — otherwise the box outline gets clipped. + const scrollView = new ScrollView(transcriptContainer, { + follow: 'end', + primary: true, + overscroll: 'chain', + scrollbar: 'auto', + }); + dockContainer = new VStack(); + dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(notifyPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(surveyContainer, { shrink: 0, minSize: 0 }); + dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 }); + const root = new VStack(); + root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 }); + root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 }); + ui.setLayoutRoot(root); + } + + return { + ui, + terminal, + transcriptContainer, + activityContainer, + todoPanelContainer, + todoPanel, + notifyPanelContainer, + notifyPanel, + queueContainer, + btwPanelContainer, + surveyContainer, + editorContainer, + dockContainer, + editor, + footer, + theme, + appState: { ...initialAppState }, + startupState: 'pending', + livePane: { ...INITIAL_LIVE_PANE }, + transcriptEntries: [], + terminalState: createTerminalState(), + activitySpinner: null, + toolOutputExpanded: false, + sessions: [], + loadingSessions: false, + sessionsNextCursor: undefined, + sessionsLoadingMore: false, + sessionsScope: 'cwd', + activeDialog: null, + editorReplacementMounted: false, + tasksBrowser: undefined, + externalEditorRunning: false, + queuedMessages: [], + queuedMessageDispatchPending: false, + swarmModeEntry: undefined, + }; +} diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f15e757ed823bef464b5dc2992d1d4c584feada --- /dev/null +++ b/apps/kimi-code/src/tui/types.ts @@ -0,0 +1,349 @@ +import type { + GoalChange, + GoalSnapshot, + ModelAlias, + PermissionMode, + ProviderConfig, + PromptPart, + ThinkingEffort, + TokenUsage, + ToolInputDisplay, +} from '@moonshot-ai/kimi-code-sdk'; + +import type { NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; +import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; +import type { ColorToken, ThemeName } from './theme'; + +export type BannerDisplay = 'always' | 'once' | 'cooldown'; + +export interface BannerState { + key: string; + tag: string | null; + mainText: string; + subText: string | null; + display: BannerDisplay; + ttlHours?: number; +} + +export interface AppState { + model: string; + workDir: string; + additionalDirs: readonly string[]; + sessionId: string; + permissionMode: PermissionMode; + planMode: boolean; + /** Resolved profile name from --agent/--agent-file, carried to the + * lazy-created first session when the TUI starts session-less. */ + agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + agentFiles?: readonly string[]; + /** 'bash' when the editor is in `!` shell-command mode. */ + inputMode: 'prompt' | 'bash'; + swarmMode: boolean; + towerMode: boolean; + /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); + * mirrors the runtime. The single source of truth for the thinking state in + * the TUI. */ + thinkingEffort: ThinkingEffort; + /** + * The current `defaultPlanMode` value from config (false when absent), + * refreshed by `hydrateLazyConfigDefaults`. Used to tell a config-driven + * plan-mode entry apart from an explicit CLI `--plan` when lazy-creating + * the first session (the engine applies the config default itself). + */ + configDefaultPlanMode?: boolean; + /** + * Session-only thinking effort chosen (e.g. via the model picker's Alt+S) + * while no session exists yet on the v2 engine. Applied to the first + * lazy-created session and cleared once it exists; the engine's config + * default is used instead when unset. + */ + lazySessionThinking?: ThinkingEffort; + contextUsage: number; + contextTokens: number; + maxContextTokens: number; + cumulativeTokens?: number; + isCompacting: boolean; + isReplaying: boolean; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; + streamingStartTime: number; + /** Pending step retry backoff (fed by `turn.step.retrying`); null when no retry is in flight. */ + stepRetry: StepRetryState | null; + theme: ThemeName; + version: string; + editorCommand: string | null; + /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ + disablePasteBurst?: boolean; + /** LaTeX math rendering in Markdown; defaults to true when absent from older fixtures. */ + renderLatex?: boolean; + /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ + cacheExpiryHint?: boolean; + disableFeedbackSurvey?: boolean; + notifications: NotificationsConfig; + upgrade: UpgradePreferences; + /** Footer status line customization from tui.toml; absent means the default layout. */ + statusLine?: StatusLineConfig; + availableModels: Record; + availableProviders: Record; + sessionTitle: string | null; + /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ + goal?: GoalSnapshot | null; + mcpServersSummary: string | null; + /** Optional banner shown below the welcome panel; null means no banner to render. */ + banner?: BannerState | null; +} + +export function sumTokenUsage(total: TokenUsage): number { + return total.inputOther + total.output + total.inputCacheRead + total.inputCacheCreation; +} + +export interface StepRetryState { + /** Upcoming attempt number (1-based). */ + nextAttempt: number; + maxAttempts: number; + /** Backoff wait before the next attempt, in milliseconds. */ + delayMs: number; + errorName: string; + errorMessage: string; + /** HTTP status code for `APIStatusError`; undefined for network/timeout failures. */ + statusCode?: number; + /** + * `backoff` while sleeping before the next attempt (label shows the + * countdown); `attempt` once the `delayMs` backoff has elapsed and the next + * attempt is running — the countdown has expired by then and is dropped. + */ + phase: 'backoff' | 'attempt'; +} + +export interface ToolCallBlockData { + id: string; + name: string; + args: Record; + description?: string; + display?: ToolInputDisplay; + streamingArguments?: string; + streamingStartedAtMs?: number; + result?: ToolResultBlockData; + subagent?: SubagentReplayBlockData; + step?: number; + turnId?: string; + /** Set when the step ended (e.g. max_tokens) before the tool call's + * arguments finished streaming. Renderer flips the header verb to + * "Truncated" and stops showing the in-progress argument preview. */ + truncated?: boolean; +} + +export interface ToolResultBlockData { + tool_call_id: string; + output: string; + is_error?: boolean; + synthetic?: boolean; +} + +export interface SubagentReplayToolCallData { + id: string; + name: string; + args: Record; + description?: string; + result?: ToolResultBlockData; +} + +export interface SubagentReplayBlockData { + id: string; + name?: string; + text?: string; + toolCalls?: readonly SubagentReplayToolCallData[]; +} + +export interface BackgroundAgentMetadata { + readonly agentId: string; + readonly parentToolCallId: string; + readonly agentName?: string; + readonly description?: string; + /** Display name of the model the agent is bound to (resolved at spawn). */ + readonly model?: string; + /** Thinking effort, set only for concrete levels (boolean on/off hidden). */ + readonly effort?: string; +} + +export type BackgroundAgentStatusPhase = 'started' | 'completed' | 'failed' | 'killed'; + +export interface BackgroundAgentStatusData { + readonly phase: BackgroundAgentStatusPhase; + readonly headline: string; + readonly detail?: string; +} + +export interface CompactionTranscriptData { + readonly result?: 'cancelled'; + readonly summary?: string; + readonly tokensBefore?: number; + readonly tokensAfter?: number; + readonly instruction?: string; +} + +export interface CronTranscriptData { + readonly jobId?: string; + readonly cron?: string; + readonly recurring?: boolean; + readonly coalescedCount?: number; + readonly stale?: boolean; + readonly missedCount?: number; +} + +export type GoalTranscriptData = + | { readonly kind: 'created' } + | { readonly kind: 'lifecycle'; readonly change: GoalChange }; + +export type TranscriptEntryKind = + | 'welcome' + | 'user' + | 'assistant' + | 'tool_call' + | 'thinking' + | 'status' + | 'skill_activation' + | 'plugin_command' + | 'cron' + | 'goal'; + +export type SkillActivationTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; + +export interface PluginCommandTranscriptData { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly args?: string; + readonly trigger: 'user-slash'; +} + +export interface TranscriptEntry { + id: string; + kind: TranscriptEntryKind; + turnId?: string; + renderMode: 'markdown' | 'plain' | 'notice'; + content: string; + /** + * True only for entries holding real model-authored text (created by the + * assistant stream). Derived cards — hook results, goal completions, goal + * reminders — share kind 'assistant' but are not replies, so /copy must + * skip them. + */ + modelText?: boolean; + color?: ColorToken; + detail?: string; + /** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */ + bullet?: string; + toolCallData?: ToolCallBlockData; + backgroundAgentStatus?: BackgroundAgentStatusData; + compactionData?: CompactionTranscriptData; + cronData?: CronTranscriptData; + goalData?: GoalTranscriptData; + imageAttachmentIds?: readonly number[]; + skillActivationId?: string; + skillName?: string; + skillArgs?: string; + skillTrigger?: SkillActivationTrigger; + /** Card belongs to the following prompt's bundled submission: undo removes them together. */ + bundledWithPrompt?: boolean; + /** Entry renders a UserPromptSubmit hook result (sits inside its prompt's group window). */ + hookResult?: boolean; + pluginCommandData?: PluginCommandTranscriptData; +} + +export type LivePaneMode = + | 'idle' + | 'waiting' + | 'thinking' + | 'tool' + | 'session'; + +export interface LivePaneState { + mode: LivePaneMode; + pendingApproval: PendingApproval | null; + pendingQuestion: PendingQuestion | null; +} + +export interface InlineSkillActivation { + readonly skillName: string; + /** + * Skill arguments. Only set for a leading `/skill: args` command that + * is combined with further inline skills; inline tokens carry no args. + */ + readonly args?: string; +} + +export interface QueuedMessage { + readonly text: string; + readonly agentId?: string; + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; + readonly videoAttachmentIds?: readonly number[]; + /** `bash` for a `!` shell command queued while another command is running; + * `skill` for a slash-skill activation queued while the session is busy; + * undefined (=`prompt`) for a normal message. */ + readonly mode?: 'prompt' | 'bash' | 'skill'; + /** Set when mode === 'skill': the skill to activate when the item drains. + * `text` then holds the display/recall string (`/name args`). */ + readonly skillName?: string; + /** Set when mode === 'skill': the raw (media-rewritten) args to activate with. */ + readonly skillArgs?: string; + /** Skills to activate together with this queued message's prompt. */ + readonly inlineSkillActivations?: readonly InlineSkillActivation[]; +} + +/** + * One unit of Ctrl-S steer input: a queued message or the editor draft, + * with the media parts extracted at submit/paste time so images and video + * tags survive the steer path (which accepts full prompt parts, not just + * text). + */ +export interface SteerInputItem { + readonly text: string; + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; + readonly videoAttachmentIds?: readonly number[]; +} + +export const INITIAL_LIVE_PANE: LivePaneState = { + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, +}; + +// --------------------------------------------------------------------------- +// TUI startup / options types (extracted from kimi-tui.ts) +// --------------------------------------------------------------------------- + +export interface TUIStartupOptions { + readonly sessionFlag?: string; + readonly continueLast: boolean; + readonly yolo: boolean; + readonly auto: boolean; + readonly plan: boolean; + readonly model?: string; + /** Resolved profile name from --agent/--agent-file; bound to the startup session only. */ + readonly agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + readonly agentFiles?: readonly string[]; + readonly startupNotice?: string; +} + +export type TUIStartupState = 'pending' | 'ready' | 'picker'; + +export interface KimiTUIOptions { + initialAppState: AppState; + startup: TUIStartupOptions; +} + +export interface PendingExit { + readonly kind: 'ctrl-c' | 'ctrl-d'; + readonly timer: ReturnType; +} + +export interface LoginProgressSpinnerHandle { + stop(opts: { ok: boolean; label: string }): void; + setLabel(label: string): void; +} + +export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/src/utils/cache-hint-config.ts b/apps/kimi-code/src/utils/cache-hint-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..4909970da0f8218a839318bab9e97bcc3b274249 --- /dev/null +++ b/apps/kimi-code/src/utils/cache-hint-config.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import { + getClientConfig, + peekClientConfig, + resetClientConfigCache, + type ClientConfigFetchOptions, +} from '#/utils/client-configs'; + +/** The cache-hint rules are one named config on the client-configs endpoint. */ +const CONFIG_NAME = 'estimated_cache_duration'; + +const cacheHintModelRuleSchema = z.object({ + min_tokens_to_hint: z.number(), + cache_duration: z.number(), +}); + +const cacheHintConfigSchema = z.object({ + version: z.literal(1), + config: z.record(z.string(), cacheHintModelRuleSchema), +}); + +export type CacheHintConfig = z.infer; +export type CacheHintConfigFetchOptions = ClientConfigFetchOptions; + +/** + * Returns the cache-hint config, preferring the cache (1 day, persisted + * across restarts). Any failure resolves to `undefined` — callers treat + * that as "do not hint". + */ +export async function getCacheHintConfig( + options: CacheHintConfigFetchOptions = {}, +): Promise { + return getClientConfig(CONFIG_NAME, cacheHintConfigSchema, options); +} + +/** Fire-and-forget refresh, e.g. on new-session creation. Never throws. */ +export function refreshCacheHintConfigInBackground( + options: CacheHintConfigFetchOptions = {}, +): void { + void getCacheHintConfig(options).catch(() => undefined); +} + +/** Synchronous peek at the fresh cache; undefined when missing or stale. */ +export function peekCacheHintConfig(now?: number): CacheHintConfig | undefined { + return peekClientConfig(CONFIG_NAME, cacheHintConfigSchema, now); +} + +/** Test hook: drop the in-process cache. */ +export function resetCacheHintConfigCache(): void { + resetClientConfigCache(CONFIG_NAME); +} diff --git a/apps/kimi-code/src/utils/catalog-fetch.ts b/apps/kimi-code/src/utils/catalog-fetch.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f3ffdddfb8c1b561966ebbc62172812293ff511 --- /dev/null +++ b/apps/kimi-code/src/utils/catalog-fetch.ts @@ -0,0 +1,57 @@ +import { + DEFAULT_CATALOG_URL, + fetchCatalog, + loadBuiltInCatalog, + type Catalog, + type FetchCatalogOptions, +} from '@moonshot-ai/kimi-code-sdk'; + +import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; + +export interface FetchCatalogOrBuiltInResult { + readonly catalog: Catalog; + /** True when the network fetch failed and the release-build snapshot was used. */ + readonly fromBuiltIn: boolean; +} + +export interface FetchCatalogOrBuiltInOptions extends FetchCatalogOptions { + /** + * Override the built-in snapshot JSON (tests). Defaults to the tsdown-injected + * `__KIMI_CODE_BUILT_IN_CATALOG__` constant. + */ + readonly builtInJson?: string; +} + +/** + * Fetches a models.dev-style catalog, falling back to the release-build + * snapshot when the public default URL is unreachable. + * + * Custom `--url` overrides never fall back — a private registry must fail + * loudly rather than silently substitute models.dev. User abort + * (`signal.aborted`) also skips the fallback so Cancel stays Cancel. + */ +export async function fetchCatalogOrBuiltIn( + url: string, + options: FetchCatalogOrBuiltInOptions = {}, +): Promise { + try { + const catalog = await fetchCatalog(url, options); + return { catalog, fromBuiltIn: false }; + } catch (error) { + if (options.signal?.aborted) throw error; + if (isAbortError(error)) throw error; + if (url !== DEFAULT_CATALOG_URL) throw error; + const builtIn = loadBuiltInCatalog(options.builtInJson ?? BUILT_IN_CATALOG_JSON); + if (builtIn === undefined) throw error; + return { catalog: builtIn, fromBuiltIn: true }; + } +} + +function isAbortError(error: unknown): boolean { + return ( + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} diff --git a/apps/kimi-code/src/utils/client-configs.ts b/apps/kimi-code/src/utils/client-configs.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1955d8551afa381cf182d1ecc72ad7727959785 --- /dev/null +++ b/apps/kimi-code/src/utils/client-configs.ts @@ -0,0 +1,201 @@ +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { getCacheDir } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; +import { currentKimiProfile, currentKimiRegion } from '#/utils/region'; + +/** + * Generic client for the public client-configs endpoint: + * `POST {baseUrl}/client_configs {"name": ""}` returns + * `{ name, config: }`, where the payload shape is config-specific + * and validated by the caller-supplied schema. + * + * Each named config is cached for a day, in two layers: an in-process map + * (the only layer the synchronous peek can see) and a JSON file under the + * CLI cache dir (survives restarts, so the TTL holds across processes). An + * entry missing/stale in both layers triggers a refetch. Any failure + * resolves to `undefined` — callers treat that as "config unavailable" and + * degrade quietly. + */ +const CLIENT_CONFIGS_PATH = '/client_configs'; + +/** Cache validity per config name: 1 day. */ +const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 5000; + +/** The endpoint's API base: the env override keeps winning (custom/internal + envs); otherwise the active region profile, so a global login's token is + not sent to the mainland-China deployment. */ +function clientConfigsBaseUrl(): string { + return (process.env['KIMI_CODE_BASE_URL'] ?? currentKimiProfile().baseUrl).replace(/\/+$/, ''); +} + +/** Cache entries are partitioned by region so a login switch never serves + the other deployment's cached config. */ +function cacheKeyFor(name: string): string { + return `${currentKimiRegion()}:${name}`; +} + +export interface ClientConfigFetchOptions { + /** Managed OAuth token; sent as Bearer when present. The endpoint is + * public, so anonymous fetches work too. */ + readonly accessToken?: string; + /** Test hook. */ + readonly fetchImpl?: typeof fetch; + /** Test hook. */ + readonly now?: number; + /** Test hook: override the cache file path, or null to skip the disk + * layer entirely. */ + readonly cacheFile?: string | null; +} + +const cache = new Map(); + +const cacheFileEnvelopeSchema = z.object({ + version: z.literal(1), + fetchedAt: z.number(), + config: z.unknown(), +}); + +function cacheFileFor(name: string, options: ClientConfigFetchOptions): string | undefined { + if (options.cacheFile === null) return undefined; + if (options.cacheFile !== undefined) return options.cacheFile; + return join(getCacheDir(), 'client-configs', `${cacheKeyFor(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`); +} + +/** Fresh disk entry, or undefined when missing/stale/invalid. */ +async function readDiskCache( + file: string, + schema: S, + now: number, +): Promise<{ readonly fetchedAt: number; readonly data: z.infer } | undefined> { + let envelope: z.infer; + try { + // The sentinel's fetchedAt=0 reads as stale, i.e. missing. + envelope = await readJsonFile(file, cacheFileEnvelopeSchema, { + version: 1, + fetchedAt: 0, + config: undefined, + }); + } catch { + return undefined; // malformed cache file — treat as missing + } + if (now - envelope.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined; + const parsed = schema.safeParse(envelope.config); + return parsed.success + ? { fetchedAt: envelope.fetchedAt, data: parsed.data as z.infer } + : undefined; +} + +/** Best-effort persist; a cache write failure must never break the caller. */ +async function writeDiskCache(file: string, data: unknown, now: number): Promise { + try { + await writeJsonFile(file, cacheFileEnvelopeSchema, { + version: 1, + fetchedAt: now, + config: data, + }); + } catch { + // A cache that cannot be written just means the next process refetches. + } +} + +/** Returns the named client config, preferring the caches over the network. */ +export async function getClientConfig( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): Promise | undefined> { + const now = options.now ?? Date.now(); + const key = cacheKeyFor(name); + const hit = cache.get(key); + if (hit !== undefined && now - hit.fetchedAt < CONFIG_CACHE_TTL_MS) { + return hit.data as z.infer; + } + const file = cacheFileFor(name, options); + if (file !== undefined) { + const diskHit = await readDiskCache(file, schema, now); + if (diskHit !== undefined) { + // Warm the in-process layer with the original fetch time, so the entry + // still expires a day after it was actually fetched. + cache.set(key, diskHit); + return diskHit.data; + } + } + const data = await fetchClientConfig(name, schema, options); + if (data === undefined) return undefined; + cache.set(key, { fetchedAt: now, data }); + if (file !== undefined) await writeDiskCache(file, data, now); + return data; +} + +/** Fire-and-forget refresh of a named config. Never throws. */ +export function refreshClientConfigInBackground( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): void { + void getClientConfig(name, schema, options).catch(() => undefined); +} + +/** + * Synchronous peek at the fresh in-process cache; undefined when missing or + * stale. Only sees the in-process layer — the disk layer is read by the + * async `getClientConfig`, which warms this layer. + */ +export function peekClientConfig( + name: string, + schema: S, + now: number = Date.now(), +): z.infer | undefined { + const hit = cache.get(cacheKeyFor(name)); + if (hit === undefined || now - hit.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined; + const parsed = schema.safeParse(hit.data); + return parsed.success ? (parsed.data as z.infer) : undefined; +} + +export async function fetchClientConfig( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): Promise | undefined> { + const fetchFn = options.fetchImpl ?? fetch; + const headers: Record = { + accept: 'application/json', + 'content-type': 'application/json', + }; + if (options.accessToken !== undefined) { + headers['authorization'] = `Bearer ${options.accessToken}`; + } + try { + const response = await fetchFn(`${clientConfigsBaseUrl()}${CLIENT_CONFIGS_PATH}`, { + method: 'POST', + headers, + body: JSON.stringify({ name }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return undefined; + const body: unknown = await response.json(); + if (typeof body !== 'object' || body === null) return undefined; + const envelope = body as Record; + if (envelope['name'] !== name) return undefined; + const parsed = schema.safeParse(envelope['config']); + return parsed.success ? (parsed.data as z.infer) : undefined; + } catch { + return undefined; + } +} + +/** + * Test hook: drop one or all in-process cached configs. Disk files in tests + * are isolated via the `cacheFile` option. + */ +export function resetClientConfigCache(name?: string): void { + if (name === undefined) { + cache.clear(); + } else { + cache.delete(cacheKeyFor(name)); + } +} diff --git a/apps/kimi-code/src/utils/experimental-features.ts b/apps/kimi-code/src/utils/experimental-features.ts new file mode 100644 index 0000000000000000000000000000000000000000..b14e083fb1d93423fabf64b51fe4120485a0e70c --- /dev/null +++ b/apps/kimi-code/src/utils/experimental-features.ts @@ -0,0 +1,10 @@ +import type { + ExperimentalFeatureState, + ExperimentalFlagMap, +} from '@moonshot-ai/kimi-code-sdk'; + +export function experimentalFeatureMap( + features: readonly Pick[], +): ExperimentalFlagMap { + return Object.fromEntries(features.map((feature) => [feature.id, feature.enabled])); +} diff --git a/apps/kimi-code/src/utils/open-url.ts b/apps/kimi-code/src/utils/open-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..4112d9c6f0daffb6f56df089a39869dd6657eed5 --- /dev/null +++ b/apps/kimi-code/src/utils/open-url.ts @@ -0,0 +1,11 @@ +import { execFile } from 'node:child_process'; + +export function openUrl(url: string): void { + const command: [string, string[]] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + execFile(command[0], command[1], () => {}); +} diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b147d1dcdcbdc2a449bb166cddc801146b696e9 --- /dev/null +++ b/apps/kimi-code/src/utils/paths.ts @@ -0,0 +1,146 @@ +/** + * CLI-owned data path helpers. + * + * These paths are for local app data such as logs and input history. Config + * files are owned by Core/SDK and intentionally do not live behind this module. + */ + +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { + KIMI_CODE_BANNER_DIR_NAME, + KIMI_CODE_BANNER_STATE_FILE_NAME, + KIMI_CODE_BIN_DIR_NAME, + KIMI_CODE_CACHE_DIR_NAME, + KIMI_CODE_DATA_DIR_NAME, + KIMI_CODE_HOME_ENV, + KIMI_CODE_INPUT_HISTORY_DIR_NAME, + KIMI_CODE_LOG_DIR_NAME, + KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME, + KIMI_CODE_NATIVE_STAGING_DIR_NAME, + KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, + KIMI_CODE_RECOMMENDED_EFFORT_STATE_FILE_NAME, + KIMI_CODE_SURVEY_STATE_FILE_NAME, + KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, + KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, + KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME, + KIMI_CODE_UPDATE_STATE_FILE_NAME, +} from '#/constant/app'; + +/** + * Return the root data directory for Kimi Code. + * + * Priority: `KIMI_CODE_HOME` env var > `~/.kimi-code`. + */ +export function getDataDir(): string { + const envDir = process.env[KIMI_CODE_HOME_ENV]; + if (envDir) { + return envDir; + } + return join(homedir(), KIMI_CODE_DATA_DIR_NAME); +} + +/** + * Return the diagnostic log directory: `/logs/`. + */ +export function getLogDir(): string { + return join(getDataDir(), KIMI_CODE_LOG_DIR_NAME); +} + +/** + * Return the CLI cache directory: `/cache/`. + */ +export function getCacheDir(): string { + return join(getDataDir(), KIMI_CODE_CACHE_DIR_NAME); +} + +/** + * Return the managed tools directory: `/bin/`. + */ +export function getBinDir(): string { + return join(getDataDir(), KIMI_CODE_BIN_DIR_NAME); +} + +/** + * Return the update cache file: `/updates/latest.json`. + */ +export function getUpdateStateFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME); +} + +/** + * Return the update install state file: `/updates/install.json`. + */ +export function getUpdateInstallStateFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME); +} + +/** + * Return the update install lock file: `/updates/install.lock`. + */ +export function getUpdateInstallLockFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME); +} + +/** + * Return the rollout decision log: `/updates/rollout.log`. + */ +export function getUpdateRolloutLogFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); +} + +/** + * Return the plugin update notice state file: `/updates/plugin-notices.json`. + */ +export function getPluginUpdateNoticeStateFile(): string { + return join( + getDataDir(), + KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, + ); +} + +/** + * Return the native staged-update directory: `/.staging/`. + * + * Anchored on the running executable (not `~/.kimi-code/bin`) because the + * Windows installer honors `KIMI_INSTALL_DIR`, and the swap's atomic renames + * require the staged binary to sit on the same volume as the exe. + */ +export function getNativeStagingDir(exePath: string): string { + return join(dirname(exePath), KIMI_CODE_NATIVE_STAGING_DIR_NAME); +} + +/** + * Return the staged-update metadata file: `/.staging/staged.json`. + */ +export function getNativeStagedStateFile(exePath: string): string { + return join(getNativeStagingDir(exePath), KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME); +} + +/** + * Return the banner display state file: `/cache/banner/state.json`. + */ +export function getBannerStateFile(): string { + return join(getCacheDir(), KIMI_CODE_BANNER_DIR_NAME, KIMI_CODE_BANNER_STATE_FILE_NAME); +} + +export function getSurveyStateFile(): string { + return join(getDataDir(), KIMI_CODE_SURVEY_STATE_FILE_NAME); +} + +export function getRecommendedEffortStateFile(): string { + return join(getDataDir(), KIMI_CODE_RECOMMENDED_EFFORT_STATE_FILE_NAME); +} + +/** + * Return the user input history file for a given working directory. + * Layout: `/user-history/.jsonl`. + */ +export function getInputHistoryFile(workDir: string): string { + const hash = createHash('md5').update(workDir, 'utf-8').digest('hex'); + return join(getDataDir(), KIMI_CODE_INPUT_HISTORY_DIR_NAME, `${hash}.jsonl`); +} diff --git a/apps/kimi-code/src/utils/persistence.ts b/apps/kimi-code/src/utils/persistence.ts new file mode 100644 index 0000000000000000000000000000000000000000..50cca0c778ca74ad432bf263db8d8cf197eb3aa1 --- /dev/null +++ b/apps/kimi-code/src/utils/persistence.ts @@ -0,0 +1,157 @@ +/** + * Small persistence helpers for CLI-owned data files. + * + * This module is intentionally for non-config files only. User-facing + * configuration is owned by core/SDK; do not route `config.toml` through + * these helpers. + */ + +import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { appendFile, link, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import type { z } from 'zod'; + +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT' + ); +} + +/** + * Hard links need filesystem support: FAT/exFAT (and some network mounts) + * answer link() with ENOTSUP/ENOSYS/EPERM instead. + */ +function isHardLinkUnsupported(error: unknown): boolean { + const code = (error as { code?: string } | null)?.code; + return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EPERM'; +} + +function assertNonConfigWrite(filePath: string): void { + if (basename(filePath) === 'config.toml') { + throw new Error( + 'CLI persistence helpers must not write config.toml; use core/SDK config APIs.', + ); + } +} + +function tempPathFor(filePath: string): string { + const dir = dirname(filePath); + const base = basename(filePath); + const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + return join(dir, `.${base}.${nonce}.tmp`); +} + +export async function readJsonFile( + filePath: string, + schema: z.ZodType, + fallback: T, +): Promise { + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (error) { + if (isNotFound(error)) return fallback; + throw error; + } + const parsed = JSON.parse(raw) as unknown; + return schema.parse(parsed); +} + +export async function writeJsonFile( + filePath: string, + schema: z.ZodType, + value: T, +): Promise { + assertNonConfigWrite(filePath); + const parsed = schema.parse(value); + await mkdir(dirname(filePath), { recursive: true }); + const tmpPath = tempPathFor(filePath); + try { + await writeFile(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); + await rename(tmpPath, filePath); + } catch (error) { + await unlink(tmpPath).catch(() => {}); + throw error; + } +} + +export function writeJsonFileSync(filePath: string, schema: z.ZodType, value: T): void { + assertNonConfigWrite(filePath); + const parsed = schema.parse(value); + mkdirSync(dirname(filePath), { recursive: true }); + const tmpPath = tempPathFor(filePath); + try { + writeFileSync(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); + renameSync(tmpPath, filePath); + } catch (error) { + try { + unlinkSync(tmpPath); + } catch {} + throw error; + } +} + +/** + * Create `filePath` with `content` only while the path is still free — + * atomically, and throwing EEXIST when it is already taken. + * + * Primary primitive: hard-link a fully written temp file into place, so the + * destination is never observable in an empty/partial state. Filesystems + * without hard-link support (FAT/exFAT, some network mounts) fall back to an + * exclusive create + write — whose create→write gap IS observable, so readers + * of such files must grant young unparseable content a publish grace before + * treating it as corrupt (see the update install lock for an example). + */ +export async function createFileIfAbsent(filePath: string, content: string): Promise { + assertNonConfigWrite(filePath); + await mkdir(dirname(filePath), { recursive: true }); + const tmpPath = tempPathFor(filePath); + await writeFile(tmpPath, content, { encoding: 'utf-8', mode: 0o600 }); + try { + await link(tmpPath, filePath); + } catch (error) { + if (!isHardLinkUnsupported(error)) throw error; + await writeFile(filePath, content, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }); + } finally { + await unlink(tmpPath).catch(() => {}); + } +} + +export async function readJsonlFile( + filePath: string, + lineSchema: z.ZodType, +): Promise { + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + + const entries: T[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + const result = lineSchema.safeParse(parsed); + if (result.success) entries.push(result.data); + } catch { + // JSONL is append-only user data; tolerate bad rows and keep the rest. + } + } + return entries; +} + +export async function appendJsonlLine( + filePath: string, + lineSchema: z.ZodType, + value: T, +): Promise { + assertNonConfigWrite(filePath); + const parsed = lineSchema.parse(value); + await mkdir(dirname(filePath), { recursive: true }); + await appendFile(filePath, `${JSON.stringify(parsed)}\n`, 'utf-8'); +} diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbc2dc6730f67764fc1b0fa986632d8808bbcce1 --- /dev/null +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -0,0 +1,113 @@ +/** + * `#/utils/plugin-marketplace` — CLI-side wrapper over the shared plugin + * marketplace client/parser (`@moonshot-ai/agent-core-v2`, + * `app/plugin/marketplace`). The shared module owns catalog reading, the + * lenient entry normalization, source resolution, and version derivation; + * this wrapper adds only the CLI's configured-source resolution (option → + * env → production default), the source-checkout fallback for offline dev, + * and the caller-supplied built-in capability entry injection. + */ + +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + parsePluginMarketplace, + readPluginMarketplace, + withBuiltInEntries, + withLatestVersions, + type MarketplaceLocation, + type PluginMarketplace, + type PluginMarketplaceEntry, +} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; + +import { + KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, + kimiCodePluginMarketplaceUrl, + MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS, +} from '#/constant/app'; + +export { + computeUpdateStatus, + PLUGIN_MARKETPLACE_TIERS, + withBuiltInEntries, + type PluginMarketplace, + type PluginMarketplaceEntry, + type PluginMarketplaceTier, + type MarketplaceUpdateStatus, +} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; + +export interface LoadPluginMarketplaceOptions { + readonly workDir: string; + readonly source?: string; + readonly fetchImpl?: typeof fetch; + /** + * Built-in capability rows to inject, supplied by the caller from the + * engine's capability registry (this util owns no product knowledge). + * Undefined means no injection. + */ + readonly builtInEntries?: readonly PluginMarketplaceEntry[]; + /** + * Skip the per-entry "latest GitHub release" lookups so the catalog can be + * rendered as soon as it is parsed; the caller resolves versions in the + * background via {@link withMarketplaceLatestVersions} and re-renders. + */ + readonly skipLatestVersions?: boolean; +} + +/** + * Second phase of the marketplace load: fill in `version` for entries that + * need a GitHub `releases/latest` lookup. Every lookup gets a hard timeout + * (MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS) and per-entry failures degrade to + * a missing version (badge-less row), so this never throws for network + * reasons and never blocks the first paint. + */ +export async function withMarketplaceLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch = fetch, +): Promise { + const timedFetch: typeof fetch = (input, init) => + fetchImpl(input, { + ...init, + signal: AbortSignal.timeout(MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS), + }); + return withLatestVersions(marketplace, timedFetch); +} + +export async function loadPluginMarketplace( + options: LoadPluginMarketplaceOptions, +): Promise { + const configuredSource = options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + const source = configuredSource ?? kimiCodePluginMarketplaceUrl(); + const fetchImpl = options.fetchImpl ?? fetch; + let read: { raw: string; location: MarketplaceLocation }; + try { + read = await readPluginMarketplace({ + source, + workDir: options.workDir, + fetchImpl, + sourceCheckoutLocation: + configuredSource === undefined ? getSourceCheckoutMarketplaceLocation : undefined, + }); + } catch (error) { + if (options.builtInEntries !== undefined) { + // The built-in entries do not come from the catalog — keep them + // visible when the catalog itself is unreachable. + return withBuiltInEntries({ source, plugins: [] }, options.builtInEntries); + } + throw error; + } + const marketplace = options.skipLatestVersions === true + ? parsePluginMarketplace(read.raw, read.location) + : await withLatestVersions(parsePluginMarketplace(read.raw, read.location), fetchImpl); + return options.builtInEntries !== undefined + ? withBuiltInEntries(marketplace, options.builtInEntries) + : marketplace; +} + +async function getSourceCheckoutMarketplaceLocation(): Promise { + const marketplacePath = resolve(import.meta.dirname, '../../../../plugins/marketplace.json'); + const info = await stat(marketplacePath).catch(() => undefined); + if (info?.isFile() !== true) return undefined; + return { raw: marketplacePath, kind: 'local', resolved: marketplacePath }; +} diff --git a/apps/kimi-code/src/utils/plugin-update-notice-state.ts b/apps/kimi-code/src/utils/plugin-update-notice-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..7baebbe77959239e7b30b147ec5128c9f475e5f4 --- /dev/null +++ b/apps/kimi-code/src/utils/plugin-update-notice-state.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { getPluginUpdateNoticeStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +/** + * Records, per plugin, the newest marketplace version an update notice was + * already shown for. A plugin is re-notified only when the marketplace + * advertises a version different from the recorded one. + */ +export type PluginUpdateNoticeState = { + version: 1; + /** pluginId -> latest marketplace version already notified. */ + notified: Record; +}; + +const PluginUpdateNoticeStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const notified = (value as { notified?: unknown }).notified; + if (typeof notified !== 'object' || notified === null) { + return { ...(value as Record), notified: {} }; + } + + const normalizedNotified: Record = {}; + for (const [key, record] of Object.entries(notified)) { + if (key.length === 0 || typeof record !== 'string' || record.length === 0) continue; + normalizedNotified[key] = record; + } + + return { ...(value as Record), notified: normalizedNotified }; + }, + z + .object({ + version: z.literal(1), + notified: z.record(z.string().min(1), z.string().min(1)), + }) + .strict(), +); + +export function emptyPluginUpdateNoticeState(): PluginUpdateNoticeState { + return { + version: 1, + notified: {}, + }; +} + +export async function readPluginUpdateNoticeState( + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise { + try { + return await readJsonFile( + filePath, + PluginUpdateNoticeStateSchema, + emptyPluginUpdateNoticeState(), + ); + } catch { + return emptyPluginUpdateNoticeState(); + } +} + +export async function writePluginUpdateNoticeState( + value: PluginUpdateNoticeState, + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise { + await writeJsonFile(filePath, PluginUpdateNoticeStateSchema, value); +} diff --git a/apps/kimi-code/src/utils/recommended-effort-config.ts b/apps/kimi-code/src/utils/recommended-effort-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b06b3e6b2d6f17857c7b33c2f48d4ac2eb4836e --- /dev/null +++ b/apps/kimi-code/src/utils/recommended-effort-config.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +import { + getClientConfig, + peekClientConfig, + resetClientConfigCache, + type ClientConfigFetchOptions, +} from '#/utils/client-configs'; + +const CONFIG_NAME = 'recommended_effort'; + +export interface RecommendedEffortEntry { + version: number; + recommended_default_effort: string; +} + +export type RecommendedEffortConfig = Record; + +const recommendedEffortEntrySchema = z.object({ + version: z.number().int().min(0), + recommended_default_effort: z.string(), +}); + +const recommendedEffortConfigSchema = z + .record(z.string(), z.unknown()) + .transform((record): RecommendedEffortConfig => { + const config: RecommendedEffortConfig = {}; + for (const [model, rawEntry] of Object.entries(record)) { + const parsed = recommendedEffortEntrySchema.safeParse(rawEntry); + if (parsed.success) config[model] = parsed.data; + } + return config; + }); + +export async function getRecommendedEffortConfig( + options: ClientConfigFetchOptions = {}, +): Promise { + return getClientConfig(CONFIG_NAME, recommendedEffortConfigSchema, options); +} + +export function peekRecommendedEffortConfig(now?: number): RecommendedEffortConfig | undefined { + return peekClientConfig(CONFIG_NAME, recommendedEffortConfigSchema, now); +} + +export function resetRecommendedEffortConfigCache(): void { + resetClientConfigCache(CONFIG_NAME); +} diff --git a/apps/kimi-code/src/utils/recommended-effort-state-store.ts b/apps/kimi-code/src/utils/recommended-effort-state-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..d93dc723a7d61afb3fa57f0c6fb2acfee460c766 --- /dev/null +++ b/apps/kimi-code/src/utils/recommended-effort-state-store.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { getRecommendedEffortStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFileSync } from '#/utils/persistence'; + +const RecommendedEffortStateSchema = z.record( + z.string(), + z.object({ + version: z.number().int().min(0), + applied_at: z.string(), + }), +); + +export type RecommendedEffortState = z.infer; + +export async function readRecommendedEffortState( + filePath: string = getRecommendedEffortStateFile(), +): Promise { + try { + return await readJsonFile(filePath, RecommendedEffortStateSchema, {}); + } catch { + return {}; + } +} + +export function writeRecommendedEffortState( + state: RecommendedEffortState, + filePath: string = getRecommendedEffortStateFile(), +): void { + writeJsonFileSync(filePath, RecommendedEffortStateSchema, state); +} diff --git a/apps/kimi-code/src/utils/recommended-effort.ts b/apps/kimi-code/src/utils/recommended-effort.ts new file mode 100644 index 0000000000000000000000000000000000000000..68b0df17fb6288ee1ccba454ee7052b57c81b9e1 --- /dev/null +++ b/apps/kimi-code/src/utils/recommended-effort.ts @@ -0,0 +1,72 @@ +import { isManagedKimiCodeBaseUrl } from '@moonshot-ai/kimi-code-oauth'; +import type { KimiConfig, KimiConfigPatch, ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; + +import { getRecommendedEffortStateFile } from '#/utils/paths'; +import type { RecommendedEffortConfig } from '#/utils/recommended-effort-config'; +import { + readRecommendedEffortState, + writeRecommendedEffortState, +} from '#/utils/recommended-effort-state-store'; + +export interface ApplyRecommendedEffortDeps { + fetchConfig: () => Promise; + getConfig: () => Promise; + setConfig: (patch: KimiConfigPatch) => Promise; + track: (event: string, properties?: TelemetryProperties) => void; + stateFile?: string; + now?: () => Date; +} + +function eligibleModelEntry(config: KimiConfig): ModelAlias | undefined { + if (config.thinking?.enabled === false) return undefined; + const alias = config.defaultModel; + if (alias === undefined) return undefined; + const entry = config.models?.[alias]; + if (entry === undefined) return undefined; + const baseUrl = entry.baseUrl ?? config.providers[entry.provider]?.baseUrl; + return isManagedKimiCodeBaseUrl(baseUrl) ? entry : undefined; +} + +export async function applyRecommendedEffort(deps: ApplyRecommendedEffortDeps): Promise { + try { + if (eligibleModelEntry(await deps.getConfig()) === undefined) return; + const cloud = await deps.fetchConfig(); + if (cloud === undefined) return; + + const config = await deps.getConfig(); + const modelEntry = eligibleModelEntry(config); + if (modelEntry === undefined) return; + const campaign = cloud[modelEntry.model]; + if (campaign === undefined) return; + const supportEfforts = modelEntry.overrides?.supportEfforts ?? modelEntry.supportEfforts; + if (!supportEfforts?.includes(campaign.recommended_default_effort)) return; + + const stateFile = deps.stateFile ?? getRecommendedEffortStateFile(); + const state = await readRecommendedEffortState(stateFile); + const applied = state[modelEntry.model]; + if (applied !== undefined && campaign.version <= applied.version) return; + + const previousEffort = config.thinking?.effort; + if (previousEffort !== campaign.recommended_default_effort) { + await deps.setConfig({ thinking: { effort: campaign.recommended_default_effort } }); + } + writeRecommendedEffortState( + { + ...state, + [modelEntry.model]: { + version: campaign.version, + applied_at: (deps.now?.() ?? new Date()).toISOString(), + }, + }, + stateFile, + ); + deps.track('recommended_effort_applied', { + model: modelEntry.model, + version: campaign.version, + effort: campaign.recommended_default_effort, + previous_effort: previousEffort, + }); + } catch { + } +} diff --git a/apps/kimi-code/src/utils/region.ts b/apps/kimi-code/src/utils/region.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b34050c3397dd8d123c1b002bfab1fff4f2ea20 --- /dev/null +++ b/apps/kimi-code/src/utils/region.ts @@ -0,0 +1,81 @@ +/** + * Process-wide region cache for the CLI/TUI. + * + * Region decides which deployment (mainland-China .com / international .ai) + * the client's off-session endpoints point at: CDN (updates, plugins, tips), + * site links, telemetry. The OAuth login flow itself does NOT read this — it + * takes explicit hosts; this cache is for everything derived afterwards. + * + * Resolution lives in `@moonshot-ai/kimi-code-oauth` (see `resolveKimiRegion`); + * this module only adds the one thing that package deliberately does not own: + * reading the persisted login's oauth ref (credential key + `oauthHost`) out + * of config.toml, synchronously, via the SDK's safe config reader. First call + * wins; `refreshKimiRegion` re-resolves after login/logout rewrote the oauth + * ref. + */ + +import { loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/kimi-code-sdk'; +import { + KIMI_CODE_OAUTH_KEY, + KIMI_REGION_PROFILES, + resolveKimiRegion, + type KimiRegion, + type KimiRegionProfile, +} from '@moonshot-ai/kimi-code-oauth'; + +// Same value as DEFAULT_OAUTH_PROVIDER_NAME in '#/constant/app' — inlined here +// to keep the import one-directional (constant/app derives URLs from this +// module, so this module must not import back from it). +const MANAGED_KIMI_CODE_PROVIDER_KEY = 'managed:kimi-code'; + +/** Platform-selector value for the global OAuth login entry. */ +export const KIMI_CODE_GLOBAL_PLATFORM_VALUE = 'kimi-code-global'; + +let cached: KimiRegion | undefined; + +export interface PersistedKimiOAuthRef { + readonly key: string; + readonly oauthHost?: string; +} + +/** The oauth ref persisted by a previous login, if any. */ +export function persistedKimiOAuthRef(): PersistedKimiOAuthRef | undefined { + const result = loadRuntimeConfigSafe(resolveConfigPath({})); + // `providers` is always present on a real config load; the `?.` guards + // hosts/tests that hand us a partial config shape. + const oauth = result.config.providers?.[MANAGED_KIMI_CODE_PROVIDER_KEY]?.oauth; + if (oauth === undefined) return undefined; + return { key: oauth.key, oauthHost: oauth.oauthHost }; +} + +/** Region for a no-flag `kimi login` / `kimi acp --login`: a fresh install + follows the resolved region (env/marker/default); the default slot (only + ever a mainland-cn login) re-pins the profile explicitly; a scoped slot — + a global login, or a custom env persisted with only KIMI_CODE_BASE_URL and + no oauthHost — keeps its configured hosts (`undefined`). */ +export function regionForBareLogin(ref: PersistedKimiOAuthRef | undefined): KimiRegion | undefined { + if (ref === undefined) return currentKimiRegion(); + return ref.key === KIMI_CODE_OAUTH_KEY ? 'mainland-cn' : undefined; +} + +export function currentKimiRegion(): KimiRegion { + if (cached === undefined) { + const persisted = persistedKimiOAuthRef(); + cached = resolveKimiRegion({ + configuredOAuthHost: persisted?.oauthHost, + configuredOAuthKey: persisted?.key, + readMarker: process.env['KIMI_CODE_REGION_MARKER'] !== 'off', + }); + } + return cached; +} + +export function currentKimiProfile(): KimiRegionProfile { + return KIMI_REGION_PROFILES[currentKimiRegion()]; +} + +/** Drop the cache and re-resolve. Call after login/logout rewrote config. */ +export function refreshKimiRegion(): KimiRegion { + cached = undefined; + return currentKimiRegion(); +} diff --git a/apps/kimi-code/src/utils/remote-control-qr.ts b/apps/kimi-code/src/utils/remote-control-qr.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7e3fab6e6db7ce8ec71f6d4b22112a33771edb2 --- /dev/null +++ b/apps/kimi-code/src/utils/remote-control-qr.ts @@ -0,0 +1,62 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + getCapabilities, + getCellDimensions, + getPngDimensions, + renderImage, +} from '@moonshot-ai/pi-tui'; +import * as QRCode from 'qrcode'; + +const TERMINAL_QR_MARGIN = 2; +const TERMINAL_QR_DARK = '0;0;0'; +const TERMINAL_QR_LIGHT = '255;255;255'; +const ANSI_RESET = '\u001B[0m'; + +const QR_PNG_MARGIN = 4; +const QR_IMAGE_MIN_PX_PER_MODULE = 4; + +export async function generateRemoteControlQr( + url: string, + dataDir: string, +): Promise<{ terminal: string; pngPath: string }> { + await mkdir(dataDir, { recursive: true }); + const pngPath = resolve(dataDir, 'rc-qrcode.png'); + const png = await QRCode.toBuffer(url, { type: 'png', margin: QR_PNG_MARGIN }); + await writeFile(pngPath, png); + const terminal = renderInlineImageQr(url, png) ?? renderTerminalQr(url); + return { terminal, pngPath }; +} + +function renderInlineImageQr(url: string, png: Buffer): string | null { + if (getCapabilities().images === null) return null; + const base64 = png.toString('base64'); + const dimensions = getPngDimensions(base64); + if (dimensions === null) return null; + const moduleCount = + QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size + QR_PNG_MARGIN * 2; + const maxWidthCells = Math.ceil( + (moduleCount * QR_IMAGE_MIN_PX_PER_MODULE) / getCellDimensions().widthPx, + ); + const rendered = renderImage(base64, dimensions, { maxWidthCells }); + return rendered === null ? null : `${rendered.sequence}\n`; +} + +export function renderTerminalQr(url: string): string { + const qr = QRCode.create(url, { errorCorrectionLevel: 'M' }); + const size: number = qr.modules.size; + const data: Uint8Array = qr.modules.data; + const isDark = (x: number, y: number): boolean => + x >= 0 && y >= 0 && x < size && y < size && data[y * size + x] === 1; + let output = ''; + for (let y = -TERMINAL_QR_MARGIN; y < size + TERMINAL_QR_MARGIN; y += 2) { + for (let x = -TERMINAL_QR_MARGIN; x < size + TERMINAL_QR_MARGIN; x++) { + const top = isDark(x, y) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT; + const bottom = isDark(x, y + 1) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT; + output += `\u001B[38;2;${top}m\u001B[48;2;${bottom}m▀`; + } + output += `${ANSI_RESET}\n`; + } + return output + ANSI_RESET; +} diff --git a/apps/kimi-code/src/utils/shell-quote.ts b/apps/kimi-code/src/utils/shell-quote.ts new file mode 100644 index 0000000000000000000000000000000000000000..eafe1c1e8355b2509e05934911e626c1e3df7f4d --- /dev/null +++ b/apps/kimi-code/src/utils/shell-quote.ts @@ -0,0 +1,11 @@ +export function quoteShellArg(value: string): string { + return process.platform === 'win32' ? quoteCmdArg(value) : quotePosixArg(value); +} + +function quotePosixArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function quoteCmdArg(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} diff --git a/apps/kimi-code/src/utils/startup-trace.ts b/apps/kimi-code/src/utils/startup-trace.ts new file mode 100644 index 0000000000000000000000000000000000000000..65ac7eb441eec0b99bd7b3edddaa75dab14b4b56 --- /dev/null +++ b/apps/kimi-code/src/utils/startup-trace.ts @@ -0,0 +1,34 @@ +// src/utils/startup-trace.ts +// +// Debug-only startup phase tracer, enabled with KIMI_STARTUP_TRACE=1. +// Each call appends one `