Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- apps/kimi-code/dist-web/boot.js +31 -0
- apps/kimi-code/dist-web/favicon.ico +0 -0
- apps/kimi-code/dist-web/index.html +23 -0
- apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +264 -0
- apps/kimi-code/scripts/build-vis-asset.mjs +54 -0
- apps/kimi-code/scripts/built-in-catalog.mjs +10 -0
- apps/kimi-code/scripts/check-web-assets.mjs +32 -0
- apps/kimi-code/scripts/copy-native-assets.mjs +37 -0
- apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs +239 -0
- apps/kimi-code/scripts/dev-server-restart.mjs +127 -0
- apps/kimi-code/scripts/dev.mjs +82 -0
- apps/kimi-code/scripts/plugin-manifest-version.mjs +38 -0
- apps/kimi-code/scripts/postinstall.mjs +175 -0
- apps/kimi-code/scripts/smoke.mjs +71 -0
- apps/kimi-code/scripts/update-catalog.mjs +100 -0
- apps/kimi-code/src/built-in-catalog.ts +8 -0
- apps/kimi-code/src/cli/agent-selection.ts +42 -0
- apps/kimi-code/src/cli/build-info.ts +34 -0
- apps/kimi-code/src/cli/commands.ts +191 -0
- apps/kimi-code/src/cli/goal-prompt.ts +103 -0
- apps/kimi-code/src/cli/headless-exit.ts +96 -0
- apps/kimi-code/src/cli/options.ts +121 -0
- apps/kimi-code/src/cli/prompt-render.ts +410 -0
- apps/kimi-code/src/cli/prompt-session.ts +26 -0
- apps/kimi-code/src/cli/run-prompt.ts +94 -0
- apps/kimi-code/src/cli/run-shell.ts +288 -0
- apps/kimi-code/src/cli/startup-error.ts +35 -0
- apps/kimi-code/src/cli/sub/acp.ts +75 -0
- apps/kimi-code/src/cli/sub/doctor.ts +335 -0
- apps/kimi-code/src/cli/sub/export.ts +251 -0
- apps/kimi-code/src/cli/sub/login.ts +26 -0
- apps/kimi-code/src/cli/sub/plugin-run-node.ts +26 -0
- apps/kimi-code/src/cli/sub/update-download.ts +185 -0
- apps/kimi-code/src/cli/sub/vis.ts +158 -0
- apps/kimi-code/src/cli/telemetry.ts +131 -0
- apps/kimi-code/src/cli/update/cache.ts +38 -0
- apps/kimi-code/src/cli/update/cdn.ts +106 -0
- apps/kimi-code/src/cli/update/install-lock.ts +254 -0
- apps/kimi-code/src/cli/update/install-state.ts +64 -0
- apps/kimi-code/src/cli/update/native-manifest.ts +115 -0
- apps/kimi-code/src/cli/update/native-stage.ts +556 -0
- apps/kimi-code/src/cli/update/native-swap.ts +642 -0
- apps/kimi-code/src/cli/update/preflight.ts +924 -0
- apps/kimi-code/src/cli/update/prompt.ts +188 -0
- apps/kimi-code/src/cli/update/refresh.ts +36 -0
- apps/kimi-code/src/cli/update/rollout.ts +210 -0
- apps/kimi-code/src/cli/update/select.ts +13 -0
- apps/kimi-code/src/cli/update/source.ts +172 -0
- apps/kimi-code/src/cli/update/types.ts +85 -0
- apps/kimi-code/src/cli/v2/run-v2-print.ts +1114 -0
apps/kimi-code/dist-web/boot.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function () {
|
| 2 |
+
try {
|
| 3 |
+
var v = localStorage.getItem('kimi-web.color-scheme');
|
| 4 |
+
if (v === 'light' || v === 'dark' || v === 'system') {
|
| 5 |
+
document.documentElement.dataset.colorScheme = v;
|
| 6 |
+
}
|
| 7 |
+
// Font scale, with the same migration rules as useAppearance (app-core):
|
| 8 |
+
// the retired 'xxlarge' lands on 'xlarge', and a legacy px key maps onto
|
| 9 |
+
// the nearest step — seeded pre-paint so a non-Medium user never flashes
|
| 10 |
+
// the Medium scale before the bundle runs.
|
| 11 |
+
var fs = localStorage.getItem('kimi-web.font-scale');
|
| 12 |
+
if (fs === 'xxlarge') fs = 'xlarge';
|
| 13 |
+
if (fs !== 'small' && fs !== 'medium' && fs !== 'large' && fs !== 'xlarge') {
|
| 14 |
+
var legacy = localStorage.getItem('kimi-web.ui-font-size');
|
| 15 |
+
var px = Number(legacy);
|
| 16 |
+
fs =
|
| 17 |
+
legacy !== null && isFinite(px)
|
| 18 |
+
? px <= 13
|
| 19 |
+
? 'small'
|
| 20 |
+
: px <= 15
|
| 21 |
+
? 'medium'
|
| 22 |
+
: px <= 17
|
| 23 |
+
? 'large'
|
| 24 |
+
: 'xlarge'
|
| 25 |
+
: 'medium';
|
| 26 |
+
}
|
| 27 |
+
document.documentElement.dataset.fontScale = fs;
|
| 28 |
+
} catch {
|
| 29 |
+
/* ignore */
|
| 30 |
+
}
|
| 31 |
+
})();
|
apps/kimi-code/dist-web/favicon.ico
ADDED
|
|
apps/kimi-code/dist-web/index.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" href="/favicon.ico" sizes="64x64" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
| 7 |
+
<meta name="color-scheme" content="light dark" />
|
| 8 |
+
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
| 9 |
+
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)" />
|
| 10 |
+
<!-- Apply persisted display prefs BEFORE the bundle loads: without this,
|
| 11 |
+
users can get a color-scheme/font flash before useKimiWebClient mirrors
|
| 12 |
+
the data attributes. Mirrors applyColorSchemeToDocument. Loaded from the
|
| 13 |
+
external /boot.js (a classic script, so still render-blocking) because
|
| 14 |
+
the server's Content-Security-Policy forbids inline scripts. -->
|
| 15 |
+
<script src="/boot.js"></script>
|
| 16 |
+
<title>Kimi Code Web</title>
|
| 17 |
+
<script type="module" crossorigin src="/assets/index-Baap3_X9.js"></script>
|
| 18 |
+
<link rel="stylesheet" crossorigin href="/assets/index-dAouBbjn.css">
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<div id="app"></div>
|
| 22 |
+
</body>
|
| 23 |
+
</html>
|
apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import { createWriteStream } from 'node:fs';
|
| 3 |
+
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
| 4 |
+
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
| 5 |
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
| 6 |
+
|
| 7 |
+
import yazl from 'yazl';
|
| 8 |
+
|
| 9 |
+
import { readPluginManifestVersion } from './plugin-manifest-version.mjs';
|
| 10 |
+
|
| 11 |
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
| 12 |
+
const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
|
| 13 |
+
const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
|
| 14 |
+
const DEFAULT_OUT_DIR = resolve(DEFAULT_PLUGINS_ROOT, 'cdn');
|
| 15 |
+
const SENTINEL = '.kimi-plugin-marketplace-build.json';
|
| 16 |
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
| 17 |
+
const SKIP_FILES = new Set(['.DS_Store']);
|
| 18 |
+
const EXTRA_CDN_PLUGIN_SOURCES = ['./official/kimi-webbridge'];
|
| 19 |
+
|
| 20 |
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
| 21 |
+
if (isMain) {
|
| 22 |
+
try {
|
| 23 |
+
const options = parseArgs(process.argv.slice(2));
|
| 24 |
+
const pluginsRoot = resolve(options.pluginsRoot ?? DEFAULT_PLUGINS_ROOT);
|
| 25 |
+
const outDir = resolve(options.outDir ?? DEFAULT_OUT_DIR);
|
| 26 |
+
await buildPluginMarketplaceCdn({ pluginsRoot, outDir });
|
| 27 |
+
} catch (error) {
|
| 28 |
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
| 29 |
+
process.exit(1);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) {
|
| 34 |
+
assertSafeOutputDir(pluginsRoot, outDir);
|
| 35 |
+
await prepareOutputDir(outDir);
|
| 36 |
+
|
| 37 |
+
const marketplacePath = resolveInsideRoot(pluginsRoot, 'marketplace.json');
|
| 38 |
+
const raw = await readFile(marketplacePath, 'utf8');
|
| 39 |
+
const parsed = JSON.parse(raw);
|
| 40 |
+
if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) {
|
| 41 |
+
throw new Error('plugins/marketplace.json must contain a "plugins" array.');
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const archives = [];
|
| 45 |
+
const plugins = [];
|
| 46 |
+
for (const entry of parsed.plugins) {
|
| 47 |
+
if (!isRecord(entry) || typeof entry.source !== 'string') {
|
| 48 |
+
plugins.push(entry);
|
| 49 |
+
continue;
|
| 50 |
+
}
|
| 51 |
+
const result = await materializeEntrySource(entry.source, pluginsRoot, outDir);
|
| 52 |
+
let stamped = { ...entry, source: result.source };
|
| 53 |
+
if (isLocalRelativeSource(entry.source)) {
|
| 54 |
+
// Stamp the version from the plugin's real manifest so "latest" stays truthful.
|
| 55 |
+
const version = await readPluginManifestVersion(resolveInsideRoot(pluginsRoot, entry.source));
|
| 56 |
+
if (version !== undefined) stamped = { ...stamped, version };
|
| 57 |
+
}
|
| 58 |
+
plugins.push(stamped);
|
| 59 |
+
if (result.archive !== undefined) archives.push(result.archive);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// WebBridge is injected by v2 clients rather than listed in the remote
|
| 63 |
+
// catalog, but its managed plugin still needs a CDN artifact.
|
| 64 |
+
for (const source of EXTRA_CDN_PLUGIN_SOURCES) {
|
| 65 |
+
const archive = stripRelativePrefix(withZipExtension(source));
|
| 66 |
+
if (archives.includes(archive)) continue;
|
| 67 |
+
const result = await materializeEntrySource(source, pluginsRoot, outDir);
|
| 68 |
+
if (result.archive !== undefined) archives.push(result.archive);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const outputMarketplace = {
|
| 72 |
+
...parsed,
|
| 73 |
+
plugins,
|
| 74 |
+
};
|
| 75 |
+
await writeFile(
|
| 76 |
+
resolveInsideRoot(outDir, 'marketplace.json'),
|
| 77 |
+
JSON.stringify(outputMarketplace, null, 2) + '\n',
|
| 78 |
+
'utf8',
|
| 79 |
+
);
|
| 80 |
+
await writeFile(
|
| 81 |
+
resolveInsideRoot(outDir, SENTINEL),
|
| 82 |
+
JSON.stringify(
|
| 83 |
+
{
|
| 84 |
+
generatedBy: 'build-plugin-marketplace-cdn',
|
| 85 |
+
generatedAt: new Date().toISOString(),
|
| 86 |
+
pluginsRoot,
|
| 87 |
+
},
|
| 88 |
+
null,
|
| 89 |
+
2,
|
| 90 |
+
) + '\n',
|
| 91 |
+
'utf8',
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
process.stdout.write(`Plugin marketplace CDN artifacts written to ${outDir}\n`);
|
| 95 |
+
process.stdout.write(` marketplace.json\n`);
|
| 96 |
+
for (const archive of archives) {
|
| 97 |
+
process.stdout.write(` ${archive}\n`);
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async function materializeEntrySource(source, pluginsRoot, outDir) {
|
| 102 |
+
if (!isLocalRelativeSource(source)) return { source };
|
| 103 |
+
|
| 104 |
+
const sourcePath = resolveInsideRoot(pluginsRoot, source);
|
| 105 |
+
const info = await stat(sourcePath).catch(() => undefined);
|
| 106 |
+
if (info === undefined) {
|
| 107 |
+
throw new Error(`Marketplace source does not exist: ${source}`);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (info.isDirectory()) {
|
| 111 |
+
const zipSource = withZipExtension(source);
|
| 112 |
+
const zipRel = stripRelativePrefix(zipSource);
|
| 113 |
+
await zipDirectory(sourcePath, resolveInsideRoot(outDir, zipRel));
|
| 114 |
+
return { source: zipSource, archive: zipRel };
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (info.isFile() && extname(sourcePath) === '.zip') {
|
| 118 |
+
const zipRel = stripRelativePrefix(source);
|
| 119 |
+
await mkdir(dirname(resolveInsideRoot(outDir, zipRel)), { recursive: true });
|
| 120 |
+
await cp(sourcePath, resolveInsideRoot(outDir, zipRel));
|
| 121 |
+
return { source, archive: zipRel };
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
throw new Error(`Marketplace source must be a directory or .zip file: ${source}`);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async function zipDirectory(sourceRoot, outputFile) {
|
| 128 |
+
await mkdir(dirname(outputFile), { recursive: true });
|
| 129 |
+
const zipfile = new yazl.ZipFile();
|
| 130 |
+
const output = createWriteStream(outputFile);
|
| 131 |
+
const done = new Promise((resolveDone, rejectDone) => {
|
| 132 |
+
output.on('close', resolveDone);
|
| 133 |
+
output.on('error', rejectDone);
|
| 134 |
+
zipfile.outputStream.on('error', rejectDone);
|
| 135 |
+
});
|
| 136 |
+
zipfile.outputStream.pipe(output);
|
| 137 |
+
await addDirectoryToZip(zipfile, sourceRoot, basename(sourceRoot));
|
| 138 |
+
zipfile.end();
|
| 139 |
+
await done;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
async function addDirectoryToZip(zipfile, root, zipRoot) {
|
| 143 |
+
const entries = await readdir(root, { withFileTypes: true });
|
| 144 |
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
| 145 |
+
for (const entry of entries) {
|
| 146 |
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
|
| 147 |
+
if (entry.isFile() && SKIP_FILES.has(entry.name)) continue;
|
| 148 |
+
|
| 149 |
+
const absolutePath = resolve(root, entry.name);
|
| 150 |
+
const zipPath = `${zipRoot}/${relative(root, absolutePath).replaceAll(sep, '/')}`;
|
| 151 |
+
if (entry.isDirectory()) {
|
| 152 |
+
await addDirectoryToZip(zipfile, absolutePath, zipPath);
|
| 153 |
+
} else if (entry.isFile()) {
|
| 154 |
+
zipfile.addFile(absolutePath, zipPath);
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
async function prepareOutputDir(outDir) {
|
| 160 |
+
const existing = await stat(outDir).catch(() => undefined);
|
| 161 |
+
if (existing === undefined) {
|
| 162 |
+
await mkdir(outDir, { recursive: true });
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
if (!existing.isDirectory()) {
|
| 166 |
+
throw new Error(`Output path exists and is not a directory: ${outDir}`);
|
| 167 |
+
}
|
| 168 |
+
const entries = await readdir(outDir);
|
| 169 |
+
if (entries.length > 0 && !entries.includes(SENTINEL)) {
|
| 170 |
+
throw new Error(
|
| 171 |
+
`Refusing to overwrite non-generated output directory: ${outDir}\n` +
|
| 172 |
+
`Choose an empty --out-dir or remove it manually.`,
|
| 173 |
+
);
|
| 174 |
+
}
|
| 175 |
+
await rm(outDir, { recursive: true, force: true });
|
| 176 |
+
await mkdir(outDir, { recursive: true });
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function assertSafeOutputDir(pluginsRoot, outDir) {
|
| 180 |
+
if (outDir === pluginsRoot) {
|
| 181 |
+
throw new Error('Output directory must not be the plugins root.');
|
| 182 |
+
}
|
| 183 |
+
if (isWithin(pluginsRoot, outDir)) {
|
| 184 |
+
throw new Error('Output directory must not contain the plugins root.');
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function resolveInsideRoot(root, input) {
|
| 189 |
+
const resolved = resolve(root, input);
|
| 190 |
+
if (!isWithin(resolved, root)) {
|
| 191 |
+
throw new Error(`Path escapes root: ${input}`);
|
| 192 |
+
}
|
| 193 |
+
return resolved;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function isWithin(candidate, root) {
|
| 197 |
+
const relativePath = relative(root, candidate);
|
| 198 |
+
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function isLocalRelativeSource(source) {
|
| 202 |
+
const trimmed = source.trim();
|
| 203 |
+
return (
|
| 204 |
+
trimmed.length > 0 &&
|
| 205 |
+
!trimmed.startsWith('http://') &&
|
| 206 |
+
!trimmed.startsWith('https://') &&
|
| 207 |
+
!trimmed.startsWith('file://') &&
|
| 208 |
+
!trimmed.startsWith('/') &&
|
| 209 |
+
!trimmed.startsWith('~/') &&
|
| 210 |
+
trimmed !== '~'
|
| 211 |
+
);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function withZipExtension(source) {
|
| 215 |
+
const trimmed = source.trim().replace(/\/+$/, '');
|
| 216 |
+
return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function stripRelativePrefix(source) {
|
| 220 |
+
return source.trim().replace(/^\.\//, '');
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function isRecord(value) {
|
| 224 |
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function parseArgs(args) {
|
| 228 |
+
const out = {};
|
| 229 |
+
for (let i = 0; i < args.length; i++) {
|
| 230 |
+
const arg = args[i];
|
| 231 |
+
if (arg === '--plugins-root') {
|
| 232 |
+
out.pluginsRoot = requiredValue(args, ++i, arg);
|
| 233 |
+
continue;
|
| 234 |
+
}
|
| 235 |
+
if (arg === '--out-dir') {
|
| 236 |
+
out.outDir = requiredValue(args, ++i, arg);
|
| 237 |
+
continue;
|
| 238 |
+
}
|
| 239 |
+
if (arg === '-h' || arg === '--help') {
|
| 240 |
+
printHelp();
|
| 241 |
+
process.exit(0);
|
| 242 |
+
}
|
| 243 |
+
throw new Error(`Unknown argument: ${arg}`);
|
| 244 |
+
}
|
| 245 |
+
return out;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function requiredValue(args, index, flag) {
|
| 249 |
+
const value = args[index];
|
| 250 |
+
if (value === undefined || value.startsWith('--')) {
|
| 251 |
+
throw new Error(`${flag} requires a value.`);
|
| 252 |
+
}
|
| 253 |
+
return value;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function printHelp() {
|
| 257 |
+
process.stdout.write(`Usage: pnpm run build:plugin-marketplace [-- --out-dir <dir>]\n`);
|
| 258 |
+
process.stdout.write(`\n`);
|
| 259 |
+
process.stdout.write(`Build CDN-ready plugin marketplace artifacts.\n`);
|
| 260 |
+
process.stdout.write(`\n`);
|
| 261 |
+
process.stdout.write(`Options:\n`);
|
| 262 |
+
process.stdout.write(` --plugins-root <dir> Source plugins root. Default: ${DEFAULT_PLUGINS_ROOT}\n`);
|
| 263 |
+
process.stdout.write(` --out-dir <dir> Output directory. Default: ${DEFAULT_OUT_DIR}\n`);
|
| 264 |
+
}
|
apps/kimi-code/scripts/build-vis-asset.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Builds the vis web single-file bundle, gzips it, and writes a generated
|
| 2 |
+
// TS module that embeds it as base64 so tsdown can later bundle it into
|
| 3 |
+
// dist/main.mjs (works identically for the npm package and the native SEA
|
| 4 |
+
// binary).
|
| 5 |
+
import { execSync } from 'node:child_process';
|
| 6 |
+
import { gzipSync } from 'node:zlib';
|
| 7 |
+
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
| 8 |
+
import { dirname, join, resolve } from 'node:path';
|
| 9 |
+
import { fileURLToPath } from 'node:url';
|
| 10 |
+
|
| 11 |
+
const here = dirname(fileURLToPath(import.meta.url));
|
| 12 |
+
const repoRoot = resolve(here, '..', '..', '..');
|
| 13 |
+
const visWeb = join(repoRoot, 'apps', 'vis', 'web');
|
| 14 |
+
const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts');
|
| 15 |
+
|
| 16 |
+
console.log('[build-vis-asset] building vis web single-file bundle…');
|
| 17 |
+
try {
|
| 18 |
+
// Run vite with VIS_SINGLEFILE set on the spawn so the build is
|
| 19 |
+
// cross-platform (Node sets the env, not a POSIX-only inline-env shell
|
| 20 |
+
// prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up
|
| 21 |
+
// vis-web's vite.config.ts, which gates the single-file output on
|
| 22 |
+
// `process.env.VIS_SINGLEFILE === '1'`.
|
| 23 |
+
// execSync runs through the platform shell, which is required on Windows:
|
| 24 |
+
// pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no
|
| 25 |
+
// PATHEXT without a shell). The win32 native binary IS built on Windows
|
| 26 |
+
// runners (.github/workflows/_native-build.yml), which run this generator.
|
| 27 |
+
// A single command string (not an args array) avoids the args+shell
|
| 28 |
+
// deprecation; the command is static (no injection surface).
|
| 29 |
+
execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', {
|
| 30 |
+
stdio: 'inherit',
|
| 31 |
+
cwd: repoRoot,
|
| 32 |
+
env: { ...process.env, VIS_SINGLEFILE: '1' },
|
| 33 |
+
});
|
| 34 |
+
} catch (err) {
|
| 35 |
+
throw new Error(
|
| 36 |
+
`[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)}`,
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const html = readFileSync(join(visWeb, 'dist-single', 'index.html'));
|
| 41 |
+
if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes('<!doctype html')) {
|
| 42 |
+
throw new Error(
|
| 43 |
+
`[build-vis-asset] dist-single/index.html looks invalid (${html.length} bytes) — the web build may have failed`,
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
const b64 = gzipSync(html, { level: 9 }).toString('base64');
|
| 47 |
+
|
| 48 |
+
mkdirSync(dirname(out), { recursive: true });
|
| 49 |
+
writeFileSync(
|
| 50 |
+
out,
|
| 51 |
+
`// GENERATED by scripts/build-vis-asset.mjs — do not edit.\n` +
|
| 52 |
+
`export const VIS_WEB_GZIP_B64 = ${JSON.stringify(b64)};\n`,
|
| 53 |
+
);
|
| 54 |
+
console.log(`[build-vis-asset] wrote ${out} (${(b64.length / 1024).toFixed(0)} KB base64)`);
|
apps/kimi-code/scripts/built-in-catalog.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFileSync } from 'node:fs';
|
| 2 |
+
|
| 3 |
+
export const BUILT_IN_CATALOG_ENV = 'KIMI_CODE_BUILT_IN_CATALOG_FILE';
|
| 4 |
+
export const BUILT_IN_CATALOG_DEFINE = '__KIMI_CODE_BUILT_IN_CATALOG__';
|
| 5 |
+
|
| 6 |
+
export function builtInCatalogDefine(env = process.env) {
|
| 7 |
+
const file = env[BUILT_IN_CATALOG_ENV];
|
| 8 |
+
if (file === undefined || file.length === 0) return 'undefined';
|
| 9 |
+
return JSON.stringify(readFileSync(file, 'utf-8'));
|
| 10 |
+
}
|
apps/kimi-code/scripts/check-web-assets.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Verify the prebuilt web bundle is present before packaging.
|
| 2 |
+
//
|
| 3 |
+
// apps/kimi-web no longer exists in this repo: the web UI is developed in the
|
| 4 |
+
// code-app repo (apps/web) and the built bundle is synced here and committed
|
| 5 |
+
// at apps/kimi-code/dist-web (gitignored, force-added). This script replaces
|
| 6 |
+
// the old copy-from-source step — it only checks that the committed bundle is
|
| 7 |
+
// in place, so a packaging run never silently ships a CLI without the web UI.
|
| 8 |
+
|
| 9 |
+
import { readdir, stat } from 'node:fs/promises';
|
| 10 |
+
import { dirname, resolve } from 'node:path';
|
| 11 |
+
import { fileURLToPath } from 'node:url';
|
| 12 |
+
|
| 13 |
+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
| 14 |
+
const target = resolve(appRoot, 'dist-web');
|
| 15 |
+
|
| 16 |
+
async function assertWebAssets() {
|
| 17 |
+
try {
|
| 18 |
+
const info = await stat(resolve(target, 'index.html'));
|
| 19 |
+
if (!info.isFile()) {
|
| 20 |
+
throw new Error('index.html is not a file');
|
| 21 |
+
}
|
| 22 |
+
} catch {
|
| 23 |
+
throw new Error(
|
| 24 |
+
`未找到已提交的 web 产物 ${target}/index.html。web 产物由 code-app 仓同步(见根 AGENTS.md),` +
|
| 25 |
+
'请从该仓运行 `KIMI_CODE_REPO=<此 checkout> pnpm run sync:web` 并提交 dist-web。',
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
await assertWebAssets();
|
| 31 |
+
const files = await readdir(target, { recursive: true });
|
| 32 |
+
console.log(`Web assets OK: ${target} (${files.length} entries, synced from code-app)`);
|
apps/kimi-code/scripts/copy-native-assets.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cp, mkdir, rm, stat } from 'node:fs/promises';
|
| 2 |
+
import { dirname, resolve } from 'node:path';
|
| 3 |
+
import { fileURLToPath } from 'node:url';
|
| 4 |
+
|
| 5 |
+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
| 6 |
+
const repoRoot = resolve(appRoot, '../..');
|
| 7 |
+
const source = resolve(repoRoot, 'packages/pi-tui/native');
|
| 8 |
+
const target = resolve(appRoot, 'native');
|
| 9 |
+
|
| 10 |
+
// pi-tui ships platform-specific native helpers for darwin/linux/win32.
|
| 11 |
+
const PLATFORMS = ['darwin', 'linux', 'win32'];
|
| 12 |
+
|
| 13 |
+
async function assertPrebuilds(platform) {
|
| 14 |
+
const dir = resolve(source, platform, 'prebuilds');
|
| 15 |
+
try {
|
| 16 |
+
const info = await stat(dir);
|
| 17 |
+
if (!info.isDirectory()) {
|
| 18 |
+
throw new Error('not a directory');
|
| 19 |
+
}
|
| 20 |
+
} catch {
|
| 21 |
+
throw new Error(
|
| 22 |
+
`pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`,
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
return dir;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
await rm(target, { recursive: true, force: true });
|
| 29 |
+
await mkdir(target, { recursive: true });
|
| 30 |
+
|
| 31 |
+
for (const platform of PLATFORMS) {
|
| 32 |
+
const srcPrebuilds = await assertPrebuilds(platform);
|
| 33 |
+
const dstPrebuilds = resolve(target, platform, 'prebuilds');
|
| 34 |
+
await cp(srcPrebuilds, dstPrebuilds, { recursive: true });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
console.log(`Copied pi-tui native prebuilds to ${target}`);
|
apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import { createReadStream } from 'node:fs';
|
| 3 |
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
| 4 |
+
import { createServer } from 'node:http';
|
| 5 |
+
import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
|
| 6 |
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
| 7 |
+
|
| 8 |
+
import yazl from 'yazl';
|
| 9 |
+
|
| 10 |
+
import { readPluginManifestVersion } from './plugin-manifest-version.mjs';
|
| 11 |
+
|
| 12 |
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
| 13 |
+
const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
|
| 14 |
+
const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
|
| 15 |
+
|
| 16 |
+
export async function startPluginMarketplaceServer(options = {}) {
|
| 17 |
+
const pluginsRoot = resolve(
|
| 18 |
+
options.pluginsRoot ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_ROOT ?? DEFAULT_PLUGINS_ROOT,
|
| 19 |
+
);
|
| 20 |
+
const host = options.host ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_HOST ?? '127.0.0.1';
|
| 21 |
+
const port = Number(options.port ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_PORT ?? 0);
|
| 22 |
+
const server = createServer((req, res) => {
|
| 23 |
+
void handleRequest(req, res, pluginsRoot);
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
await new Promise((resolveStarted, rejectStarted) => {
|
| 27 |
+
const onError = (error) => {
|
| 28 |
+
rejectStarted(error);
|
| 29 |
+
};
|
| 30 |
+
server.once('error', onError);
|
| 31 |
+
server.listen(port, host, () => {
|
| 32 |
+
server.off('error', onError);
|
| 33 |
+
resolveStarted();
|
| 34 |
+
});
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const address = server.address();
|
| 38 |
+
if (address === null || typeof address === 'string') {
|
| 39 |
+
throw new Error('Plugin marketplace dev server did not bind to a TCP port.');
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const marketplaceUrl = `http://${host}:${address.port}/marketplace.json`;
|
| 43 |
+
return {
|
| 44 |
+
server,
|
| 45 |
+
pluginsRoot,
|
| 46 |
+
marketplaceUrl,
|
| 47 |
+
close: () =>
|
| 48 |
+
new Promise((resolveClosed, rejectClosed) => {
|
| 49 |
+
server.close((error) => {
|
| 50 |
+
if (error !== undefined) rejectClosed(error);
|
| 51 |
+
else resolveClosed();
|
| 52 |
+
});
|
| 53 |
+
}),
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async function handleRequest(req, res, pluginsRoot) {
|
| 58 |
+
const method = req.method ?? 'GET';
|
| 59 |
+
if (method !== 'GET' && method !== 'HEAD') {
|
| 60 |
+
res.writeHead(405, { Allow: 'GET, HEAD' });
|
| 61 |
+
res.end();
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
let pathname;
|
| 66 |
+
try {
|
| 67 |
+
pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname);
|
| 68 |
+
} catch {
|
| 69 |
+
res.writeHead(400);
|
| 70 |
+
res.end('Bad request');
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
try {
|
| 75 |
+
if (pathname === '/' || pathname === '/marketplace.json') {
|
| 76 |
+
await serveMarketplaceJson(res, pluginsRoot, method === 'HEAD');
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
if (pathname.endsWith('.zip')) {
|
| 80 |
+
await servePluginZip(res, pluginsRoot, pathname, method === 'HEAD');
|
| 81 |
+
return;
|
| 82 |
+
}
|
| 83 |
+
await serveStaticFile(res, pluginsRoot, pathname, method === 'HEAD');
|
| 84 |
+
} catch (error) {
|
| 85 |
+
const message = error instanceof Error ? error.message : String(error);
|
| 86 |
+
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
| 87 |
+
res.end(message);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async function serveMarketplaceJson(res, pluginsRoot, headOnly) {
|
| 92 |
+
const file = resolveInsideRoot(pluginsRoot, 'marketplace.json');
|
| 93 |
+
const raw = await readFile(file, 'utf8');
|
| 94 |
+
const body = Buffer.from(
|
| 95 |
+
JSON.stringify(await rewriteMarketplaceJson(raw, pluginsRoot), null, 2) + '\n',
|
| 96 |
+
);
|
| 97 |
+
res.writeHead(200, {
|
| 98 |
+
'Content-Type': 'application/json; charset=utf-8',
|
| 99 |
+
'Content-Length': String(body.byteLength),
|
| 100 |
+
});
|
| 101 |
+
res.end(headOnly ? undefined : body);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
async function rewriteMarketplaceJson(raw, pluginsRoot) {
|
| 105 |
+
const parsed = JSON.parse(raw);
|
| 106 |
+
if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return parsed;
|
| 107 |
+
|
| 108 |
+
const plugins = await Promise.all(
|
| 109 |
+
parsed.plugins.map(async (entry) => {
|
| 110 |
+
if (!isRecord(entry) || typeof entry.source !== 'string') return entry;
|
| 111 |
+
if (!isLocalRelativeSource(entry.source)) return entry;
|
| 112 |
+
const sourcePath = resolveInsideRoot(pluginsRoot, entry.source);
|
| 113 |
+
if (!(await isDirectory(sourcePath))) return entry;
|
| 114 |
+
// Stamp the version from the plugin's real manifest so "latest" stays truthful.
|
| 115 |
+
const version = await readPluginManifestVersion(sourcePath);
|
| 116 |
+
const withVersion = version !== undefined ? { ...entry, version } : entry;
|
| 117 |
+
return { ...withVersion, source: withZipExtension(withVersion.source) };
|
| 118 |
+
}),
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
return { ...parsed, plugins };
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
async function servePluginZip(res, pluginsRoot, pathname, headOnly) {
|
| 125 |
+
const zipRel = pathname.replace(/^\/+/, '');
|
| 126 |
+
const sourceRel = zipRel.slice(0, -'.zip'.length);
|
| 127 |
+
const sourceRoot = resolveInsideRoot(pluginsRoot, sourceRel);
|
| 128 |
+
if (!(await isDirectory(sourceRoot))) {
|
| 129 |
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
| 130 |
+
res.end('Not found');
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
res.writeHead(200, { 'Content-Type': 'application/zip' });
|
| 135 |
+
if (headOnly) {
|
| 136 |
+
res.end();
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const zipfile = new yazl.ZipFile();
|
| 141 |
+
zipfile.outputStream.on('error', (error) => {
|
| 142 |
+
res.destroy(error);
|
| 143 |
+
});
|
| 144 |
+
zipfile.outputStream.pipe(res);
|
| 145 |
+
await addDirectoryToZip(zipfile, sourceRoot, basename(sourceRoot));
|
| 146 |
+
zipfile.end();
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async function serveStaticFile(res, pluginsRoot, pathname, headOnly) {
|
| 150 |
+
const file = resolveInsideRoot(pluginsRoot, pathname.replace(/^\/+/, ''));
|
| 151 |
+
const info = await stat(file).catch(() => undefined);
|
| 152 |
+
if (info === undefined || !info.isFile()) {
|
| 153 |
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
| 154 |
+
res.end('Not found');
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
res.writeHead(200, {
|
| 159 |
+
'Content-Type': contentType(file),
|
| 160 |
+
'Content-Length': String(info.size),
|
| 161 |
+
});
|
| 162 |
+
if (headOnly) {
|
| 163 |
+
res.end();
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
createReadStream(file).on('error', (error) => res.destroy(error)).pipe(res);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function addDirectoryToZip(zipfile, root, zipRoot) {
|
| 170 |
+
const entries = await readdir(root, { withFileTypes: true });
|
| 171 |
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
| 172 |
+
for (const entry of entries) {
|
| 173 |
+
const absolutePath = resolve(root, entry.name);
|
| 174 |
+
const zipPath = `${zipRoot}/${relative(root, absolutePath).replaceAll(sep, '/')}`;
|
| 175 |
+
if (entry.isDirectory()) {
|
| 176 |
+
await addDirectoryToZip(zipfile, absolutePath, zipPath);
|
| 177 |
+
} else if (entry.isFile()) {
|
| 178 |
+
zipfile.addFile(absolutePath, zipPath);
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function resolveInsideRoot(root, input) {
|
| 184 |
+
const resolved = resolve(root, input);
|
| 185 |
+
const rootWithSep = root.endsWith(sep) ? root : root + sep;
|
| 186 |
+
if (resolved !== root && !resolved.startsWith(rootWithSep)) {
|
| 187 |
+
throw new Error(`Path escapes plugin marketplace root: ${input}`);
|
| 188 |
+
}
|
| 189 |
+
return resolved;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function isLocalRelativeSource(source) {
|
| 193 |
+
const trimmed = source.trim();
|
| 194 |
+
return (
|
| 195 |
+
trimmed.length > 0 &&
|
| 196 |
+
!trimmed.startsWith('http://') &&
|
| 197 |
+
!trimmed.startsWith('https://') &&
|
| 198 |
+
!trimmed.startsWith('file://') &&
|
| 199 |
+
!trimmed.startsWith('/') &&
|
| 200 |
+
!trimmed.startsWith('~/') &&
|
| 201 |
+
trimmed !== '~'
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function withZipExtension(source) {
|
| 206 |
+
const trimmed = source.trim().replace(/\/+$/, '');
|
| 207 |
+
return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
async function isDirectory(path) {
|
| 211 |
+
return (await stat(path).catch(() => undefined))?.isDirectory() === true;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function contentType(path) {
|
| 215 |
+
switch (extname(path)) {
|
| 216 |
+
case '.json':
|
| 217 |
+
return 'application/json; charset=utf-8';
|
| 218 |
+
case '.mjs':
|
| 219 |
+
case '.js':
|
| 220 |
+
return 'text/javascript; charset=utf-8';
|
| 221 |
+
case '.md':
|
| 222 |
+
return 'text/markdown; charset=utf-8';
|
| 223 |
+
case '.txt':
|
| 224 |
+
return 'text/plain; charset=utf-8';
|
| 225 |
+
default:
|
| 226 |
+
return 'application/octet-stream';
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function isRecord(value) {
|
| 231 |
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
| 235 |
+
if (isMain) {
|
| 236 |
+
const started = await startPluginMarketplaceServer();
|
| 237 |
+
console.error(`Plugin marketplace dev server: ${started.marketplaceUrl}`);
|
| 238 |
+
console.error(`Serving: ${started.pluginsRoot}`);
|
| 239 |
+
}
|
apps/kimi-code/scripts/dev-server-restart.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Press-Enter-to-restart wrapper for the local server. No file watcher.
|
| 3 |
+
//
|
| 4 |
+
// Spawns `tsx ./src/main.ts web --no-open …extraArgs` once, then on each
|
| 5 |
+
// newline read from stdin SIGTERMs the child and respawns after it has
|
| 6 |
+
// cleanly exited. SIGTERM triggers the server's own `shutdown()` handler
|
| 7 |
+
// (apps/kimi-code/src/cli/sub/web/run.ts) which releases the instance
|
| 8 |
+
// registration and closes WS conns before exit, so a fresh start can
|
| 9 |
+
// re-acquire 58627 without a stale-entry fight.
|
| 10 |
+
//
|
| 11 |
+
// CLI args after `--` (or any extras) are passed straight through, so:
|
| 12 |
+
// pnpm dev:server:restart -- --host 0.0.0.0 --port 58627 --log-level debug
|
| 13 |
+
// is equivalent to `pnpm dev:server` with that arg list, but with the restart
|
| 14 |
+
// loop on top.
|
| 15 |
+
|
| 16 |
+
import { spawn } from 'node:child_process';
|
| 17 |
+
import { dirname, resolve } from 'node:path';
|
| 18 |
+
import { fileURLToPath } from 'node:url';
|
| 19 |
+
|
| 20 |
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
| 21 |
+
const APP_ROOT = resolve(SCRIPT_DIR, '..');
|
| 22 |
+
|
| 23 |
+
const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
| 24 |
+
|
| 25 |
+
const cliArgs = process.argv.slice(2);
|
| 26 |
+
if (cliArgs[0] === '--') cliArgs.shift();
|
| 27 |
+
|
| 28 |
+
const tsxArgs = [
|
| 29 |
+
'--tsconfig',
|
| 30 |
+
'./tsconfig.dev.json',
|
| 31 |
+
'--import',
|
| 32 |
+
'../../build/register-raw-text-loader.mjs',
|
| 33 |
+
'./src/main.ts',
|
| 34 |
+
'web',
|
| 35 |
+
'--no-open',
|
| 36 |
+
...cliArgs,
|
| 37 |
+
];
|
| 38 |
+
|
| 39 |
+
let child = null;
|
| 40 |
+
let restarting = false;
|
| 41 |
+
let shuttingDown = false;
|
| 42 |
+
let killTimer = null;
|
| 43 |
+
|
| 44 |
+
function start() {
|
| 45 |
+
console.error('[dev:server:restart] starting server…');
|
| 46 |
+
child = spawn(tsxBin, tsxArgs, {
|
| 47 |
+
cwd: APP_ROOT,
|
| 48 |
+
env: { ...process.env, KIMI_CODE_DEV_SERVER: '1' },
|
| 49 |
+
// Server does not read stdin; keep ours free for the Enter trigger.
|
| 50 |
+
stdio: ['ignore', 'inherit', 'inherit'],
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
child.on('error', (err) => {
|
| 54 |
+
console.error(`[dev:server:restart] spawn error: ${err.message}`);
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
child.on('exit', (code, signal) => {
|
| 58 |
+
if (killTimer !== null) {
|
| 59 |
+
clearTimeout(killTimer);
|
| 60 |
+
killTimer = null;
|
| 61 |
+
}
|
| 62 |
+
const prev = child;
|
| 63 |
+
child = null;
|
| 64 |
+
if (shuttingDown) {
|
| 65 |
+
process.exit(code ?? 0);
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
if (restarting) {
|
| 69 |
+
restarting = false;
|
| 70 |
+
start();
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
// Server died on its own (port conflict, runtime error, etc.). Stay alive
|
| 74 |
+
// so the user can fix the issue and press Enter to retry.
|
| 75 |
+
const tag = signal !== null ? `signal=${signal}` : `code=${code}`;
|
| 76 |
+
console.error(
|
| 77 |
+
`[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`,
|
| 78 |
+
);
|
| 79 |
+
void prev; // silence unused warning
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function restart() {
|
| 84 |
+
if (shuttingDown) return;
|
| 85 |
+
if (child === null) {
|
| 86 |
+
// Previous run already exited; just spin up a new one.
|
| 87 |
+
start();
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
if (restarting) return; // debounce — multiple Enters during shutdown collapse
|
| 91 |
+
restarting = true;
|
| 92 |
+
console.error('[dev:server:restart] restarting…');
|
| 93 |
+
child.kill('SIGTERM');
|
| 94 |
+
// Safety net: if the child ignores SIGTERM, force-kill after 5s so the
|
| 95 |
+
// restart loop doesn't wedge.
|
| 96 |
+
killTimer = setTimeout(() => {
|
| 97 |
+
if (child !== null && child.exitCode === null && child.signalCode === null) {
|
| 98 |
+
console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL');
|
| 99 |
+
child.kill('SIGKILL');
|
| 100 |
+
}
|
| 101 |
+
}, 5000);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
process.stdin.setEncoding('utf8');
|
| 105 |
+
process.stdin.on('data', (chunk) => {
|
| 106 |
+
// Any newline (Enter on most terminals) triggers a restart. Empty Enter is
|
| 107 |
+
// the canonical signal; typing `r<Enter>` works too.
|
| 108 |
+
if (chunk.includes('\n') || chunk.includes('\r')) {
|
| 109 |
+
restart();
|
| 110 |
+
}
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
const onShutdownSignal = (signal) => {
|
| 114 |
+
if (shuttingDown) return;
|
| 115 |
+
shuttingDown = true;
|
| 116 |
+
if (child !== null) {
|
| 117 |
+
child.kill(signal);
|
| 118 |
+
// Give the server a moment to flush logs / release the lock.
|
| 119 |
+
setTimeout(() => process.exit(0), 1000).unref();
|
| 120 |
+
} else {
|
| 121 |
+
process.exit(0);
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
process.on('SIGINT', () => onShutdownSignal('SIGINT'));
|
| 125 |
+
process.on('SIGTERM', () => onShutdownSignal('SIGTERM'));
|
| 126 |
+
|
| 127 |
+
start();
|
apps/kimi-code/scripts/dev.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import { spawn } from 'node:child_process';
|
| 3 |
+
import { createRequire } from 'node:module';
|
| 4 |
+
import { dirname, resolve } from 'node:path';
|
| 5 |
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
| 6 |
+
|
| 7 |
+
import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs';
|
| 8 |
+
|
| 9 |
+
const require = createRequire(import.meta.url);
|
| 10 |
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
| 11 |
+
const APP_ROOT = resolve(SCRIPT_DIR, '..');
|
| 12 |
+
// Monorepo root. Used as the dev CLI's working directory so `make dev` opens
|
| 13 |
+
// the whole repo instead of just apps/kimi-code.
|
| 14 |
+
const REPO_ROOT = resolve(APP_ROOT, '../..');
|
| 15 |
+
// Runtime variable the CLI reads to locate the marketplace JSON.
|
| 16 |
+
const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
|
| 17 |
+
// Opt-in for dev: point this run at an external marketplace instead of a local one.
|
| 18 |
+
const EXTERNAL_MARKETPLACE_ENV = 'KIMI_CODE_DEV_MARKETPLACE_URL';
|
| 19 |
+
|
| 20 |
+
let marketplaceServer;
|
| 21 |
+
const env = { ...process.env };
|
| 22 |
+
|
| 23 |
+
const externalUrl = process.env[EXTERNAL_MARKETPLACE_ENV]?.trim();
|
| 24 |
+
if (externalUrl !== undefined && externalUrl.length > 0) {
|
| 25 |
+
// Explicitly asked to use an external marketplace; don't start a local server.
|
| 26 |
+
env[MARKETPLACE_ENV] = externalUrl;
|
| 27 |
+
console.error(`Using external plugin marketplace: ${externalUrl}`);
|
| 28 |
+
} else {
|
| 29 |
+
// Default: every `pnpm run dev:cli` runs its own isolated marketplace server on a
|
| 30 |
+
// random port, so multiple concurrent dev instances never collide. Overwrite any
|
| 31 |
+
// inherited MARKETPLACE_ENV so a stale URL from a dead instance can't break this run.
|
| 32 |
+
const inherited = process.env[MARKETPLACE_ENV]?.trim();
|
| 33 |
+
marketplaceServer = await startPluginMarketplaceServer();
|
| 34 |
+
env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl;
|
| 35 |
+
// Marks the URL as the dev server's own (serving this repo's catalog), so
|
| 36 |
+
// the CLI can tell it apart from a user-configured marketplace override.
|
| 37 |
+
env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] = '1';
|
| 38 |
+
console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`);
|
| 39 |
+
if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) {
|
| 40 |
+
console.error(
|
| 41 |
+
`(ignored inherited ${MARKETPLACE_ENV}=${inherited}; set ${EXTERNAL_MARKETPLACE_ENV} to use an external marketplace)`,
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const tsxCli = require.resolve('tsx/cli');
|
| 47 |
+
const cliArgs = process.argv.slice(2);
|
| 48 |
+
if (cliArgs[0] === '--') cliArgs.shift();
|
| 49 |
+
const child = spawn(
|
| 50 |
+
process.execPath,
|
| 51 |
+
[
|
| 52 |
+
tsxCli,
|
| 53 |
+
// Use the dev tsconfig whose `include` covers packages/*/src, so tsx's
|
| 54 |
+
// esbuild transform sees `experimentalDecorators: true` for DI parameter
|
| 55 |
+
// decorators in agent-core-v2. Mirrors `dev:server` in package.json.
|
| 56 |
+
'--tsconfig',
|
| 57 |
+
resolve(APP_ROOT, 'tsconfig.dev.json'),
|
| 58 |
+
'--import',
|
| 59 |
+
pathToFileURL(resolve(REPO_ROOT, 'build/register-raw-text-loader.mjs')).href,
|
| 60 |
+
resolve(APP_ROOT, 'src/main.ts'),
|
| 61 |
+
...cliArgs,
|
| 62 |
+
],
|
| 63 |
+
{
|
| 64 |
+
cwd: REPO_ROOT,
|
| 65 |
+
env,
|
| 66 |
+
stdio: 'inherit',
|
| 67 |
+
},
|
| 68 |
+
);
|
| 69 |
+
|
| 70 |
+
child.on('error', async (error) => {
|
| 71 |
+
console.error(`Failed to start Kimi Code dev CLI: ${error.message}`);
|
| 72 |
+
await marketplaceServer?.close();
|
| 73 |
+
process.exit(1);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
child.on('exit', async (code, signal) => {
|
| 77 |
+
await marketplaceServer?.close();
|
| 78 |
+
if (signal !== null) {
|
| 79 |
+
process.exit(1);
|
| 80 |
+
}
|
| 81 |
+
process.exit(code ?? 0);
|
| 82 |
+
});
|
apps/kimi-code/scripts/plugin-manifest-version.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFile } from 'node:fs/promises';
|
| 2 |
+
import { resolve } from 'node:path';
|
| 3 |
+
|
| 4 |
+
// Read a local plugin directory's declared version from its manifest, mirroring
|
| 5 |
+
// the plugin loader's precedence (packages/agent-core-v2/src/app/plugin/manager.ts):
|
| 6 |
+
// `kimi.plugin.json` is authoritative once it exists, and `.kimi-plugin/plugin.json`
|
| 7 |
+
// is only consulted when the root manifest is absent. Returns undefined when no
|
| 8 |
+
// manifest is present or the chosen manifest has no version — callers then leave
|
| 9 |
+
// the marketplace entry's existing version untouched.
|
| 10 |
+
export async function readPluginManifestVersion(pluginDir) {
|
| 11 |
+
for (const rel of ['kimi.plugin.json', '.kimi-plugin/plugin.json']) {
|
| 12 |
+
const raw = await readFileOrUndefined(resolve(pluginDir, rel));
|
| 13 |
+
if (raw === undefined) continue; // manifest absent — fall back to the next candidate
|
| 14 |
+
return versionFromManifest(raw); // the chosen manifest wins, even if it has no version
|
| 15 |
+
}
|
| 16 |
+
return undefined;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
async function readFileOrUndefined(file) {
|
| 20 |
+
try {
|
| 21 |
+
return await readFile(file, 'utf8');
|
| 22 |
+
} catch {
|
| 23 |
+
return undefined;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function versionFromManifest(raw) {
|
| 28 |
+
try {
|
| 29 |
+
const parsed = JSON.parse(raw);
|
| 30 |
+
if (parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string') {
|
| 31 |
+
const trimmed = parsed.version.trim();
|
| 32 |
+
return trimmed.length > 0 ? trimmed : undefined;
|
| 33 |
+
}
|
| 34 |
+
} catch {
|
| 35 |
+
return undefined;
|
| 36 |
+
}
|
| 37 |
+
return undefined;
|
| 38 |
+
}
|
apps/kimi-code/scripts/postinstall.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* Postinstall hook for @moonshot-ai/kimi-code.
|
| 4 |
+
*
|
| 5 |
+
* Goal: when this package is installed globally, ensure typing `kimi`
|
| 6 |
+
* invokes the new TypeScript CLI. The npm `package.json` bin field
|
| 7 |
+
* installs a fresh `kimi` shim into the global bin dir; this script
|
| 8 |
+
* removes any pre-existing `kimi` shim left behind by the previous
|
| 9 |
+
* Python CLI (installed via `uv tool install`, `pipx install`,
|
| 10 |
+
* `pip install`, etc.) that would otherwise shadow ours via PATH
|
| 11 |
+
* ordering. The renamed shim is kept as `kimi-legacy` so users can
|
| 12 |
+
* still invoke the old CLI if they want to fall back.
|
| 13 |
+
*
|
| 14 |
+
* ## Hard rules
|
| 15 |
+
*
|
| 16 |
+
* - Only runs for global installs across npm, yarn (classic), and
|
| 17 |
+
* pnpm. Non-global installs (npx, local project deps, workspace
|
| 18 |
+
* bootstraps, `pnpm dlx`) are silent no-ops.
|
| 19 |
+
* - Never fails the install. Any error here is caught and reported,
|
| 20 |
+
* but the script always exits 0.
|
| 21 |
+
* - Does not touch a `kimi` we don't recognize as the previous
|
| 22 |
+
* Python CLI (matched by realpath-resolved shim head containing
|
| 23 |
+
* `kimi_cli`).
|
| 24 |
+
* - Cross-platform: POSIX and Windows. Windows-specific bits live
|
| 25 |
+
* in the helpers (PATHEXT-aware PATH walking, whole-file marker
|
| 26 |
+
* sniff for uv's Rust launcher .exe, extension-preserving
|
| 27 |
+
* rename target like `kimi.exe` → `kimi-legacy.exe`).
|
| 28 |
+
*
|
| 29 |
+
* ## Code layout
|
| 30 |
+
*
|
| 31 |
+
* This file is the orchestrator; the actual logic lives in
|
| 32 |
+
* sibling modules to keep each file under a manageable size:
|
| 33 |
+
*
|
| 34 |
+
* - `./postinstall/reach.mjs` — package-manager detection,
|
| 35 |
+
* global-install gate, own-package-root resolution, user-shell
|
| 36 |
+
* PATH lookup, reachability check.
|
| 37 |
+
* - `./postinstall/migrate.mjs` — legacy detection,
|
| 38 |
+
* `kimi`-vs-`kimi-legacy` classification, the rename / unlink
|
| 39 |
+
* primitives.
|
| 40 |
+
* - `./postinstall/takeover.mjs` — the plan → execute → verify
|
| 41 |
+
* state machine this orchestrator drives.
|
| 42 |
+
* - `./postinstall/ui.mjs` — `notify()` (with `/dev/tty` fallback),
|
| 43 |
+
* ANSI styling, the fixed-width box, and the outcome renderers.
|
| 44 |
+
*
|
| 45 |
+
* ## Workflow
|
| 46 |
+
*
|
| 47 |
+
* What runs when a user types `npm install -g @moonshot-ai/kimi-code`
|
| 48 |
+
* (or the yarn / pnpm equivalent):
|
| 49 |
+
*
|
| 50 |
+
* 1. The manager extracts the package and runs lifecycle scripts.
|
| 51 |
+
* The `bin.kimi` mapping in `package.json` tells the manager to
|
| 52 |
+
* install a `kimi` shim under its global bin directory.
|
| 53 |
+
* 2. The manager invokes this script via the `scripts.postinstall`
|
| 54 |
+
* entry — orchestrated by `main` below.
|
| 55 |
+
* 3. Install-context gate: only proceed when this is a global
|
| 56 |
+
* install (`isGlobalInstall` checks `npm_config_global` /
|
| 57 |
+
* `pnpm_config_global` / `npm_config_location`).
|
| 58 |
+
* 4. Probe PATH once via `postinstallPaths()`: detection uses the
|
| 59 |
+
* union of shell PATH + process PATH; reachability uses the
|
| 60 |
+
* shell PATH alone (with a fallback to process PATH if the
|
| 61 |
+
* shell can't be probed). Sharing one probe keeps detection
|
| 62 |
+
* and reachability symmetric and avoids running `$SHELL -l`
|
| 63 |
+
* twice.
|
| 64 |
+
* 5. `planTakeover`: detect EVERY previous Python `kimi-cli` shim
|
| 65 |
+
* on the detection PATH, pre-flight classify each (no writes),
|
| 66 |
+
* and simulate PATH resolution with the actionable shims gone:
|
| 67 |
+
* - `own` wins → proceed.
|
| 68 |
+
* - a blocked legacy still wins → `logMigrationBlocked`.
|
| 69 |
+
* - a foreign `kimi` wins → `logForeignKimiInTheWay`.
|
| 70 |
+
* - nothing resolves → `logNewCliNotOnPath`.
|
| 71 |
+
* The abort branches touch NOTHING.
|
| 72 |
+
* 6. `executeTakeover`: the FIRST shim in PATH order that can be
|
| 73 |
+
* preserved becomes `kimi-legacy`; each subsequent shim is
|
| 74 |
+
* `unlink`ed. A failed preserve attempt does not promote the
|
| 75 |
+
* next shim to deletion — it gets its own preserve attempt, so
|
| 76 |
+
* a usable legacy fallback survives whenever one is possible.
|
| 77 |
+
* 7. `verifyTakeover`: walk the reachability PATH as it actually
|
| 78 |
+
* is AFTER execution. Only `{ kind: 'own' }` renders the
|
| 79 |
+
* success box (`logMigrationDone`); anything else renders
|
| 80 |
+
* `logMigrationIncomplete` — what changed, what still blocks,
|
| 81 |
+
* and how to finish by hand. The pre-flight simulation in
|
| 82 |
+
* step 5 is never reported as proof of success.
|
| 83 |
+
* 8. The manager completes the install with its usual summary.
|
| 84 |
+
* This script always exits 0; any uncaught error is swallowed
|
| 85 |
+
* by the top-level `catch` so the install never fails because
|
| 86 |
+
* of the migration.
|
| 87 |
+
*/
|
| 88 |
+
|
| 89 |
+
import {
|
| 90 |
+
detectPackageManager,
|
| 91 |
+
isGlobalInstall,
|
| 92 |
+
ownPackageRoot,
|
| 93 |
+
postinstallPaths,
|
| 94 |
+
} from './postinstall/reach.mjs';
|
| 95 |
+
import {
|
| 96 |
+
executeTakeover,
|
| 97 |
+
planTakeover,
|
| 98 |
+
verifyTakeover,
|
| 99 |
+
} from './postinstall/takeover.mjs';
|
| 100 |
+
import {
|
| 101 |
+
logForeignKimiInTheWay,
|
| 102 |
+
logMigrationBlocked,
|
| 103 |
+
logMigrationDone,
|
| 104 |
+
logMigrationIncomplete,
|
| 105 |
+
logNewCliNotOnPath,
|
| 106 |
+
notify,
|
| 107 |
+
} from './postinstall/ui.mjs';
|
| 108 |
+
|
| 109 |
+
async function main() {
|
| 110 |
+
// Step 1: skip non-global installs (npx, local project deps,
|
| 111 |
+
// workspace bootstraps). Windows is supported natively; the
|
| 112 |
+
// platform-specific bits (PATHEXT-aware PATH walk, whole-file
|
| 113 |
+
// marker sniff for uv's launcher .exe, extension-preserving
|
| 114 |
+
// rename) live in the helpers.
|
| 115 |
+
if (!isGlobalInstall()) return;
|
| 116 |
+
|
| 117 |
+
// Step 2: locate our own installed package root once and share it
|
| 118 |
+
// with both detection (skip files inside our package) and
|
| 119 |
+
// reachability (only count our shim as "found").
|
| 120 |
+
const ownRoot = await ownPackageRoot(import.meta.dirname);
|
| 121 |
+
const pm = detectPackageManager();
|
| 122 |
+
|
| 123 |
+
// Step 3: probe the user's shell PATH once so detection and
|
| 124 |
+
// reachability share a single consistent view. Detection uses the
|
| 125 |
+
// union of shell PATH + process PATH (so we catch a legacy shim
|
| 126 |
+
// visible to either); reachability uses the shell PATH alone (so
|
| 127 |
+
// we don't claim "kimi works now" when the shim only sits in the
|
| 128 |
+
// installer's env).
|
| 129 |
+
const paths = await postinstallPaths();
|
| 130 |
+
|
| 131 |
+
// Step 4: plan against the whole detected shim set without writing.
|
| 132 |
+
const plan = await planTakeover(
|
| 133 |
+
ownRoot,
|
| 134 |
+
paths.detection,
|
| 135 |
+
paths.reachability,
|
| 136 |
+
process.platform,
|
| 137 |
+
);
|
| 138 |
+
if (plan.kind === 'noop') return;
|
| 139 |
+
if (plan.kind === 'blocked') {
|
| 140 |
+
logMigrationBlocked(plan.blocked, plan.actionable, pm);
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
if (plan.kind === 'foreign') {
|
| 144 |
+
logForeignKimiInTheWay(plan.path, pm);
|
| 145 |
+
return;
|
| 146 |
+
}
|
| 147 |
+
if (plan.kind === 'not-on-path') {
|
| 148 |
+
logNewCliNotOnPath(plan.detection, pm);
|
| 149 |
+
return;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// Step 5: execute (preserve the first preservable shim as
|
| 153 |
+
// `kimi-legacy`, delete the rest).
|
| 154 |
+
const outcomes = await executeTakeover(plan.classifications);
|
| 155 |
+
|
| 156 |
+
// Step 6: post-execution verification — the ONLY ground for a
|
| 157 |
+
// success claim. If reality diverged from the step-4 simulation
|
| 158 |
+
// (a rename failed, a new shim appeared), report it honestly.
|
| 159 |
+
const verify = await verifyTakeover(
|
| 160 |
+
ownRoot,
|
| 161 |
+
paths.reachability,
|
| 162 |
+
plan.classifications.map((c) => c.shimPath),
|
| 163 |
+
process.platform,
|
| 164 |
+
);
|
| 165 |
+
if (verify.kind === 'own') {
|
| 166 |
+
logMigrationDone({ ...outcomes, blockedHarmless: plan.blocked }, pm);
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
logMigrationIncomplete({ outcomes, verify, blocked: plan.blocked }, pm);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
main().catch((err) => {
|
| 173 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 174 |
+
notify(`[kimi-code] postinstall warning: ${message}`);
|
| 175 |
+
});
|
apps/kimi-code/scripts/smoke.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { execFile } from 'node:child_process';
|
| 2 |
+
import { readFile, stat } from 'node:fs/promises';
|
| 3 |
+
import { fileURLToPath } from 'node:url';
|
| 4 |
+
import { dirname, resolve } from 'node:path';
|
| 5 |
+
import { promisify } from 'node:util';
|
| 6 |
+
|
| 7 |
+
const execFileAsync = promisify(execFile);
|
| 8 |
+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
| 9 |
+
const bundlePath = resolve(appRoot, 'dist', 'main.mjs');
|
| 10 |
+
const webIndexPath = resolve(appRoot, 'dist-web', 'index.html');
|
| 11 |
+
const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8'));
|
| 12 |
+
const expectedVersion = packageJson.version;
|
| 13 |
+
|
| 14 |
+
function fail(message) {
|
| 15 |
+
console.error(message);
|
| 16 |
+
process.exit(1);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
async function ensureBundleExists() {
|
| 20 |
+
try {
|
| 21 |
+
await stat(bundlePath);
|
| 22 |
+
} catch {
|
| 23 |
+
fail(`Bundle not found at ${bundlePath}. Run \`pnpm build\` first.`);
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function ensureRuntimeAssetsExist() {
|
| 28 |
+
try {
|
| 29 |
+
await stat(webIndexPath);
|
| 30 |
+
} catch {
|
| 31 |
+
fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async function runBundle(args) {
|
| 36 |
+
try {
|
| 37 |
+
const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], {
|
| 38 |
+
cwd: appRoot,
|
| 39 |
+
maxBuffer: 1024 * 1024 * 16,
|
| 40 |
+
});
|
| 41 |
+
return `${stdout}${stderr}`;
|
| 42 |
+
} catch (error) {
|
| 43 |
+
const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message]
|
| 44 |
+
.filter(Boolean)
|
| 45 |
+
.join('\n');
|
| 46 |
+
fail(`Bundle smoke failed: node ${bundlePath} ${args.join(' ')}\n${detail}`);
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function assertIncludes(output, expected, command) {
|
| 51 |
+
if (!output.includes(expected)) {
|
| 52 |
+
fail(`Bundle smoke output for "${command}" did not include "${expected}".\n${output}`);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
await ensureBundleExists();
|
| 57 |
+
await ensureRuntimeAssetsExist();
|
| 58 |
+
|
| 59 |
+
const versionOutput = await runBundle(['--version']);
|
| 60 |
+
assertIncludes(versionOutput, expectedVersion, '--version');
|
| 61 |
+
|
| 62 |
+
const helpOutput = await runBundle(['--help']);
|
| 63 |
+
assertIncludes(helpOutput, 'Usage: kimi', '--help');
|
| 64 |
+
|
| 65 |
+
const exportHelpOutput = await runBundle(['export', '--help']);
|
| 66 |
+
assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help');
|
| 67 |
+
|
| 68 |
+
const webHelpOutput = await runBundle(['web', '--help']);
|
| 69 |
+
assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help');
|
| 70 |
+
|
| 71 |
+
console.log(`Bundle smoke passed: ${bundlePath}`);
|
apps/kimi-code/scripts/update-catalog.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* Fetches models.dev/api.json, strips fields not needed by kimi-code, and
|
| 4 |
+
* writes the result as raw JSON for release builds to inline.
|
| 5 |
+
*
|
| 6 |
+
* This script intentionally does not write into src/. The source tree keeps a
|
| 7 |
+
* placeholder so the generated catalog is not committed.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { mkdirSync, writeFileSync } from "node:fs";
|
| 11 |
+
import { dirname, resolve } from "node:path";
|
| 12 |
+
|
| 13 |
+
const scriptDir = import.meta.dirname;
|
| 14 |
+
const outFile = resolveOutputFile(process.argv.slice(2));
|
| 15 |
+
const modelsUrl = process.env.MODELS_DEV_URL || "https://models.dev/api.json";
|
| 16 |
+
|
| 17 |
+
const KEEP_PROVIDER = new Set(["id", "name", "api", "env", "npm", "type", "models"]);
|
| 18 |
+
const KEEP_MODEL = new Set([
|
| 19 |
+
"id",
|
| 20 |
+
"name",
|
| 21 |
+
"family",
|
| 22 |
+
"limit",
|
| 23 |
+
"tool_call",
|
| 24 |
+
"reasoning",
|
| 25 |
+
"interleaved",
|
| 26 |
+
"modalities",
|
| 27 |
+
// Message-level tool declarations capability — kosong's
|
| 28 |
+
// catalogModelToCapability reads it; stripping it here would silently
|
| 29 |
+
// disable tool-select for catalog-imported aliases.
|
| 30 |
+
"dynamically_loaded_tools",
|
| 31 |
+
]);
|
| 32 |
+
|
| 33 |
+
function resolveOutputFile(args) {
|
| 34 |
+
const index = args.indexOf("--out");
|
| 35 |
+
if (index !== -1) {
|
| 36 |
+
const value = args[index + 1];
|
| 37 |
+
if (value === undefined || value.length === 0) {
|
| 38 |
+
throw new Error("Missing value for --out");
|
| 39 |
+
}
|
| 40 |
+
return resolve(process.cwd(), value);
|
| 41 |
+
}
|
| 42 |
+
return resolve(scriptDir, "../dist/built-in-catalog.json");
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function stripModel(model) {
|
| 46 |
+
if (typeof model !== "object" || model === null) return undefined;
|
| 47 |
+
const result = {};
|
| 48 |
+
for (const key of Object.keys(model)) {
|
| 49 |
+
if (KEEP_MODEL.has(key)) result[key] = model[key];
|
| 50 |
+
}
|
| 51 |
+
return result;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function stripProvider(provider) {
|
| 55 |
+
if (typeof provider !== "object" || provider === null) return undefined;
|
| 56 |
+
const result = {};
|
| 57 |
+
for (const key of Object.keys(provider)) {
|
| 58 |
+
if (!KEEP_PROVIDER.has(key)) continue;
|
| 59 |
+
const value = provider[key];
|
| 60 |
+
if (key === "models") {
|
| 61 |
+
const stripped = {};
|
| 62 |
+
for (const [mId, m] of Object.entries(value)) {
|
| 63 |
+
const s = stripModel(m);
|
| 64 |
+
if (s !== undefined) stripped[mId] = s;
|
| 65 |
+
}
|
| 66 |
+
if (Object.keys(stripped).length > 0) result[key] = stripped;
|
| 67 |
+
} else {
|
| 68 |
+
result[key] = value;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
return result;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
async function fetchCatalog(url) {
|
| 75 |
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
| 76 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 77 |
+
const raw = await res.json();
|
| 78 |
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
| 79 |
+
throw new Error("invalid payload shape");
|
| 80 |
+
}
|
| 81 |
+
const stripped = {};
|
| 82 |
+
for (const [k, v] of Object.entries(raw)) {
|
| 83 |
+
const p = stripProvider(v);
|
| 84 |
+
if (p !== undefined && Object.keys(p).length > 0) stripped[k] = p;
|
| 85 |
+
}
|
| 86 |
+
return JSON.stringify(stripped);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function main() {
|
| 90 |
+
console.log(`Fetching ${modelsUrl} ...`);
|
| 91 |
+
const json = await fetchCatalog(modelsUrl);
|
| 92 |
+
mkdirSync(dirname(outFile), { recursive: true });
|
| 93 |
+
writeFileSync(outFile, json, "utf-8");
|
| 94 |
+
console.log(`Wrote ${outFile} (${(json.length / 1024).toFixed(0)} KB JSON)`);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
main().catch((error) => {
|
| 98 |
+
console.error(error.message);
|
| 99 |
+
process.exit(1);
|
| 100 |
+
});
|
apps/kimi-code/src/built-in-catalog.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Filled by tsdown define in release builds. Source stays empty so the
|
| 2 |
+
// generated models.dev snapshot is not committed.
|
| 3 |
+
declare const __KIMI_CODE_BUILT_IN_CATALOG__: string | undefined;
|
| 4 |
+
|
| 5 |
+
export const BUILT_IN_CATALOG_JSON: string | undefined =
|
| 6 |
+
typeof __KIMI_CODE_BUILT_IN_CATALOG__ === 'string'
|
| 7 |
+
? __KIMI_CODE_BUILT_IN_CATALOG__
|
| 8 |
+
: undefined;
|
apps/kimi-code/src/cli/agent-selection.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFile } from 'node:fs/promises';
|
| 2 |
+
import { homedir } from 'node:os';
|
| 3 |
+
|
| 4 |
+
import { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/kimi-code-sdk';
|
| 5 |
+
|
| 6 |
+
import type { CLIOptions } from './options';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Resolve which agent profile the launch flags select.
|
| 10 |
+
*
|
| 11 |
+
* `--agent` carries the profile name directly; `--agent-file` implicitly
|
| 12 |
+
* selects the profile the file defines, so the file is parsed here (fatal on
|
| 13 |
+
* error) so a bad file fails before any session work. Returns undefined when
|
| 14 |
+
* neither flag is present.
|
| 15 |
+
*/
|
| 16 |
+
export async function resolveAgentProfileSelection(
|
| 17 |
+
opts: Pick<CLIOptions, 'agent' | 'agentFiles'>,
|
| 18 |
+
workDir: string,
|
| 19 |
+
): Promise<string | undefined> {
|
| 20 |
+
if (opts.agent !== undefined) return opts.agent;
|
| 21 |
+
const agentFile = opts.agentFiles?.[0];
|
| 22 |
+
if (agentFile === undefined) return undefined;
|
| 23 |
+
|
| 24 |
+
const path = resolveAgentPath(agentFile, workDir, homedir());
|
| 25 |
+
let text: string;
|
| 26 |
+
try {
|
| 27 |
+
text = await readFile(path, 'utf8');
|
| 28 |
+
} catch (error) {
|
| 29 |
+
throw new Error(
|
| 30 |
+
`Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`,
|
| 31 |
+
{ cause: error },
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
try {
|
| 35 |
+
return parseAgentFileText({ path, source: 'explicit', text }).name;
|
| 36 |
+
} catch (error) {
|
| 37 |
+
throw new Error(
|
| 38 |
+
`Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`,
|
| 39 |
+
{ cause: error },
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
}
|
apps/kimi-code/src/cli/build-info.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare const __KIMI_CODE_VERSION__: string | undefined;
|
| 2 |
+
declare const __KIMI_CODE_CHANNEL__: string | undefined;
|
| 3 |
+
declare const __KIMI_CODE_COMMIT__: string | undefined;
|
| 4 |
+
declare const __KIMI_CODE_BUILD_TARGET__: string | undefined;
|
| 5 |
+
|
| 6 |
+
export interface KimiBuildInfo {
|
| 7 |
+
readonly version?: string;
|
| 8 |
+
readonly channel?: string;
|
| 9 |
+
readonly commit?: string;
|
| 10 |
+
readonly buildTarget?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function optionalBuildString(value: unknown): string | undefined {
|
| 14 |
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const KIMI_BUILD_INFO: KimiBuildInfo = {
|
| 18 |
+
version:
|
| 19 |
+
typeof __KIMI_CODE_VERSION__ === 'string'
|
| 20 |
+
? optionalBuildString(__KIMI_CODE_VERSION__)
|
| 21 |
+
: undefined,
|
| 22 |
+
channel:
|
| 23 |
+
typeof __KIMI_CODE_CHANNEL__ === 'string'
|
| 24 |
+
? optionalBuildString(__KIMI_CODE_CHANNEL__)
|
| 25 |
+
: undefined,
|
| 26 |
+
commit:
|
| 27 |
+
typeof __KIMI_CODE_COMMIT__ === 'string'
|
| 28 |
+
? optionalBuildString(__KIMI_CODE_COMMIT__)
|
| 29 |
+
: undefined,
|
| 30 |
+
buildTarget:
|
| 31 |
+
typeof __KIMI_CODE_BUILD_TARGET__ === 'string'
|
| 32 |
+
? optionalBuildString(__KIMI_CODE_BUILD_TARGET__)
|
| 33 |
+
: undefined,
|
| 34 |
+
};
|
apps/kimi-code/src/cli/commands.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CLI_COMMAND_NAME } from '#/constant/app';
|
| 2 |
+
import { registerMigrateCommand, type MigrateCommandOptions } from '#/migration/index';
|
| 3 |
+
import { Command, InvalidArgumentError, Option } from 'commander';
|
| 4 |
+
|
| 5 |
+
import type { CLIOptions } from './options';
|
| 6 |
+
import { registerAcpCommand } from './sub/acp';
|
| 7 |
+
import { registerDoctorCommand } from './sub/doctor';
|
| 8 |
+
import { registerExportCommand } from './sub/export';
|
| 9 |
+
import { registerForkCommand } from './sub/fork';
|
| 10 |
+
import { registerLoginCommand } from './sub/login';
|
| 11 |
+
import { registerProviderCommand } from './sub/provider';
|
| 12 |
+
import { registerSessionCommand } from './sub/session';
|
| 13 |
+
import { registerVisCommand } from './sub/vis';
|
| 14 |
+
import { registerWebCommand } from './sub/web';
|
| 15 |
+
|
| 16 |
+
export type MainCommandHandler = (opts: CLIOptions) => void;
|
| 17 |
+
export type MigrateCommandHandler = (options: MigrateCommandOptions) => void;
|
| 18 |
+
export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void;
|
| 19 |
+
export type UpgradeCommandHandler = (yes: boolean) => void | Promise<void>;
|
| 20 |
+
export type UpdateDownloadHandler = (version: string, manual: boolean) => void;
|
| 21 |
+
|
| 22 |
+
export function createProgram(
|
| 23 |
+
version: string,
|
| 24 |
+
onMain: MainCommandHandler,
|
| 25 |
+
onMigrate: MigrateCommandHandler,
|
| 26 |
+
onPluginNodeRunner: PluginNodeRunnerHandler = () => {},
|
| 27 |
+
onUpgrade: UpgradeCommandHandler = () => {},
|
| 28 |
+
onUpdateDownload: UpdateDownloadHandler = () => {},
|
| 29 |
+
): Command {
|
| 30 |
+
const program = new Command(CLI_COMMAND_NAME)
|
| 31 |
+
.description('The Starting Point for Next-Gen Agents')
|
| 32 |
+
.version(version, '-V, --version')
|
| 33 |
+
.allowUnknownOption(false)
|
| 34 |
+
.enablePositionalOptions()
|
| 35 |
+
.configureHelp({ helpWidth: 100 })
|
| 36 |
+
.helpOption('-h, --help', 'Show help.')
|
| 37 |
+
.usage('[options] [command]')
|
| 38 |
+
.addHelpText('after', '\nDocumentation: https://moonshotai.github.io/kimi-code/\n');
|
| 39 |
+
|
| 40 |
+
program
|
| 41 |
+
.addOption(
|
| 42 |
+
new Option(
|
| 43 |
+
'-S, --session [id]',
|
| 44 |
+
'Resume a session. With ID: resume that session. Without ID: interactively pick.',
|
| 45 |
+
).argParser((val: string | boolean) => (val === true ? '' : (val as string))),
|
| 46 |
+
)
|
| 47 |
+
.addOption(
|
| 48 |
+
new Option('-r, --resume [id]')
|
| 49 |
+
.hideHelp()
|
| 50 |
+
.argParser((val: string | boolean) => (val === true ? '' : (val as string))),
|
| 51 |
+
)
|
| 52 |
+
.option('-c, --continue', 'Continue the previous session for the working directory.', false)
|
| 53 |
+
.addOption(new Option('-C').hideHelp().default(false))
|
| 54 |
+
.option('-y, --yolo', 'Start in Ask When Needed mode: routine edits and commands run automatically; risky actions, questions, and plans still ask.', false)
|
| 55 |
+
.option('--auto', 'Start in Never Ask mode: never interrupts you; everything runs and is decided automatically.', false)
|
| 56 |
+
.addOption(
|
| 57 |
+
new Option(
|
| 58 |
+
'-m, --model <model>',
|
| 59 |
+
'LLM model alias to use for this invocation. Defaults to default_model in config.toml.',
|
| 60 |
+
),
|
| 61 |
+
)
|
| 62 |
+
.addOption(
|
| 63 |
+
new Option(
|
| 64 |
+
'-p, --prompt <prompt>',
|
| 65 |
+
'Run one prompt non-interactively and print the response.',
|
| 66 |
+
),
|
| 67 |
+
)
|
| 68 |
+
.addOption(
|
| 69 |
+
new Option(
|
| 70 |
+
'--output-format <format>',
|
| 71 |
+
'Output format for prompt mode. Defaults to text.',
|
| 72 |
+
).choices(['text', 'stream-json']),
|
| 73 |
+
)
|
| 74 |
+
.addOption(
|
| 75 |
+
new Option(
|
| 76 |
+
'--skills-dir <dir>',
|
| 77 |
+
'Load skills from this directory instead of auto-discovered user and project directories. Can be repeated.',
|
| 78 |
+
)
|
| 79 |
+
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
|
| 80 |
+
.default([]),
|
| 81 |
+
)
|
| 82 |
+
.addOption(
|
| 83 |
+
new Option(
|
| 84 |
+
'--agent <name>',
|
| 85 |
+
'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.',
|
| 86 |
+
)
|
| 87 |
+
.argParser((value: string, previous: string | undefined) => {
|
| 88 |
+
if (previous !== undefined) {
|
| 89 |
+
throw new InvalidArgumentError('--agent may only be specified once.');
|
| 90 |
+
}
|
| 91 |
+
return value;
|
| 92 |
+
})
|
| 93 |
+
.conflicts('agentFile'),
|
| 94 |
+
)
|
| 95 |
+
.addOption(
|
| 96 |
+
new Option(
|
| 97 |
+
'--agent-file <path>',
|
| 98 |
+
'Load an agent definition from a Markdown file and select it for the new session. Cannot be combined with --session/--continue.',
|
| 99 |
+
)
|
| 100 |
+
.argParser((value: string, previous: string[] | undefined) => {
|
| 101 |
+
if ((previous?.length ?? 0) > 0) {
|
| 102 |
+
throw new InvalidArgumentError('--agent-file may only be specified once.');
|
| 103 |
+
}
|
| 104 |
+
return [value];
|
| 105 |
+
})
|
| 106 |
+
.conflicts('agent')
|
| 107 |
+
.default([]),
|
| 108 |
+
)
|
| 109 |
+
.addOption(
|
| 110 |
+
new Option(
|
| 111 |
+
'--add-dir <dir>',
|
| 112 |
+
'Add an additional workspace directory for this session. Can be repeated.',
|
| 113 |
+
)
|
| 114 |
+
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
|
| 115 |
+
.default([]),
|
| 116 |
+
)
|
| 117 |
+
.addOption(new Option('--yes').hideHelp().default(false))
|
| 118 |
+
.addOption(new Option('--auto-approve').hideHelp().default(false))
|
| 119 |
+
.option('--plan', 'Start in plan mode.', false);
|
| 120 |
+
|
| 121 |
+
registerExportCommand(program);
|
| 122 |
+
registerForkCommand(program);
|
| 123 |
+
registerProviderCommand(program);
|
| 124 |
+
registerSessionCommand(program);
|
| 125 |
+
registerAcpCommand(program);
|
| 126 |
+
registerWebCommand(program);
|
| 127 |
+
registerLoginCommand(program);
|
| 128 |
+
registerDoctorCommand(program);
|
| 129 |
+
registerVisCommand(program);
|
| 130 |
+
registerMigrateCommand(program, onMigrate);
|
| 131 |
+
program
|
| 132 |
+
.command('upgrade')
|
| 133 |
+
.alias('update')
|
| 134 |
+
.description('Upgrade Kimi Code to the latest version.')
|
| 135 |
+
.option('-y, --yes', 'Skip the confirmation prompt and install the update directly.', false)
|
| 136 |
+
.action(async (options: { yes?: boolean }) => {
|
| 137 |
+
await onUpgrade(options.yes === true);
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
program
|
| 141 |
+
.command('__plugin_run_node', { hidden: true })
|
| 142 |
+
.argument('<entry>')
|
| 143 |
+
.argument('[args...]')
|
| 144 |
+
.allowUnknownOption(true)
|
| 145 |
+
.action((entry: string, args: string[]) => {
|
| 146 |
+
onPluginNodeRunner(entry, args);
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
// Self-spawned worker for native staged updates (detached background
|
| 150 |
+
// download, or foreground from `kimi upgrade` — `--manual` marks the
|
| 151 |
+
// latter's stage as user-requested). Hidden: not user-facing.
|
| 152 |
+
program
|
| 153 |
+
.command('__update_download', { hidden: true })
|
| 154 |
+
.argument('<version>')
|
| 155 |
+
.option('--manual', 'the stage answers an explicit user-initiated upgrade')
|
| 156 |
+
.action((targetVersion: string, options: { manual?: boolean }) => {
|
| 157 |
+
onUpdateDownload(targetVersion, options.manual === true);
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
program.argument('[args...]').action((args: string[]) => {
|
| 161 |
+
if (args.length > 0) {
|
| 162 |
+
program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const raw = program.opts<Record<string, unknown>>();
|
| 166 |
+
|
| 167 |
+
const rawSession = raw['session'] ?? raw['resume'];
|
| 168 |
+
const sessionValue = rawSession === true ? '' : (rawSession as string | undefined);
|
| 169 |
+
const yoloValue = raw['yolo'] === true || raw['yes'] === true || raw['autoApprove'] === true;
|
| 170 |
+
const autoValue = raw['auto'] === true;
|
| 171 |
+
|
| 172 |
+
const opts: CLIOptions = {
|
| 173 |
+
session: sessionValue,
|
| 174 |
+
continue: raw['continue'] === true || raw['C'] === true,
|
| 175 |
+
yolo: yoloValue,
|
| 176 |
+
auto: autoValue,
|
| 177 |
+
plan: raw['plan'] as boolean,
|
| 178 |
+
model: raw['model'] as string | undefined,
|
| 179 |
+
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
|
| 180 |
+
prompt: raw['prompt'] as string | undefined,
|
| 181 |
+
skillsDirs: raw['skillsDir'] as string[],
|
| 182 |
+
agent: raw['agent'] as string | undefined,
|
| 183 |
+
agentFiles: raw['agentFile'] as string[],
|
| 184 |
+
addDirs: raw['addDir'] as string[],
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
onMain(opts);
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
return program;
|
| 191 |
+
}
|
apps/kimi-code/src/cli/goal-prompt.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk';
|
| 2 |
+
|
| 3 |
+
import { parseGoalCommand } from '#/tui/commands/index';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Headless goal-mode support for the `kimi -p "/goal <objective>"` prompt path.
|
| 7 |
+
*
|
| 8 |
+
* The goal driver keeps the prompt's turn-run alive across continuation turns
|
| 9 |
+
* until the goal reaches a terminal state, so the existing prompt-turn waiter
|
| 10 |
+
* already blocks until then. This module adds the create-on-entry parsing, a
|
| 11 |
+
* machine-readable summary, and the terminal-status → exit-code mapping.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
export interface HeadlessGoalCreate {
|
| 15 |
+
readonly objective: string;
|
| 16 |
+
readonly replace: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Exit codes by final goal status. The lifecycle has only one success outcome
|
| 21 |
+
* (`complete` → 0) and two resumable stopped states: `blocked` (the system
|
| 22 |
+
* stopped pursuing — the model's UpdateGoal, a budget, or an error) and `paused`
|
| 23 |
+
* (a turn abort / SIGINT). Both are non-zero — the goal did not complete. An absent goal
|
| 24 |
+
* (should not happen on the create path) maps to success.
|
| 25 |
+
*/
|
| 26 |
+
export const GOAL_EXIT_CODES = {
|
| 27 |
+
complete: 0,
|
| 28 |
+
blocked: 3,
|
| 29 |
+
paused: 6,
|
| 30 |
+
} as const;
|
| 31 |
+
|
| 32 |
+
export function goalExitCode(status: string | undefined): number {
|
| 33 |
+
switch (status) {
|
| 34 |
+
case 'blocked':
|
| 35 |
+
return GOAL_EXIT_CODES.blocked;
|
| 36 |
+
case 'paused':
|
| 37 |
+
return GOAL_EXIT_CODES.paused;
|
| 38 |
+
default:
|
| 39 |
+
return GOAL_EXIT_CODES.complete;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const GOAL_PREFIX = /^\/goal(\s|$)/;
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Parses a headless prompt into a goal-create request, or `undefined` when the
|
| 47 |
+
* prompt is not a `/goal` create command (so the caller runs it as a normal
|
| 48 |
+
* prompt). Non-create goal subcommands are not supported headless and fall
|
| 49 |
+
* through to normal prompt handling. Malformed create commands throw instead of
|
| 50 |
+
* falling through, so validation errors are reported before anything is sent to
|
| 51 |
+
* the model.
|
| 52 |
+
*/
|
| 53 |
+
export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
|
| 54 |
+
const trimmed = prompt.trim();
|
| 55 |
+
if (!GOAL_PREFIX.test(trimmed)) return undefined;
|
| 56 |
+
const args = trimmed.replace(/^\/goal/, '').trim();
|
| 57 |
+
const parsed = parseGoalCommand(args);
|
| 58 |
+
if (parsed.kind === 'error') {
|
| 59 |
+
throw new Error(parsed.message);
|
| 60 |
+
}
|
| 61 |
+
if (parsed.kind !== 'create') return undefined;
|
| 62 |
+
return { objective: parsed.objective, replace: parsed.replace };
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export interface GoalSummary {
|
| 66 |
+
readonly type: 'goal.summary';
|
| 67 |
+
readonly goalId: string | null;
|
| 68 |
+
readonly status: string | null;
|
| 69 |
+
readonly reason: string | null;
|
| 70 |
+
readonly turnsUsed: number | null;
|
| 71 |
+
readonly tokensUsed: number | null;
|
| 72 |
+
readonly wallClockMs: number | null;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export function goalSummaryJson(goal: GoalSnapshot | null): GoalSummary {
|
| 76 |
+
if (goal === null) {
|
| 77 |
+
return {
|
| 78 |
+
type: 'goal.summary',
|
| 79 |
+
goalId: null,
|
| 80 |
+
status: null,
|
| 81 |
+
reason: null,
|
| 82 |
+
turnsUsed: null,
|
| 83 |
+
tokensUsed: null,
|
| 84 |
+
wallClockMs: null,
|
| 85 |
+
};
|
| 86 |
+
}
|
| 87 |
+
return {
|
| 88 |
+
type: 'goal.summary',
|
| 89 |
+
goalId: goal.goalId,
|
| 90 |
+
status: goal.status,
|
| 91 |
+
reason: goal.terminalReason ?? null,
|
| 92 |
+
turnsUsed: goal.turnsUsed,
|
| 93 |
+
tokensUsed: goal.tokensUsed,
|
| 94 |
+
wallClockMs: goal.wallClockMs,
|
| 95 |
+
};
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export function formatGoalSummaryText(goal: GoalSnapshot | null): string {
|
| 99 |
+
if (goal === null) return 'Goal: no goal found.';
|
| 100 |
+
const parts = [`Goal [${goal.status}]`];
|
| 101 |
+
if (goal.terminalReason !== undefined) parts.push(goal.terminalReason);
|
| 102 |
+
return `${parts.join(': ')} (turns: ${goal.turnsUsed}, tokens: ${goal.tokensUsed})`;
|
| 103 |
+
}
|
apps/kimi-code/src/cli/headless-exit.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Writable } from 'node:stream';
|
| 2 |
+
|
| 3 |
+
import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app';
|
| 4 |
+
|
| 5 |
+
/** Minimal process surface needed to force a headless run to terminate. */
|
| 6 |
+
export interface ExitableProcess {
|
| 7 |
+
exit(code?: number): void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Schedule a best-effort force-exit for a completed headless (`kimi -p`) run.
|
| 12 |
+
*
|
| 13 |
+
* Print mode does not call `process.exit()`; it relies on the Node event loop
|
| 14 |
+
* draining once the run is done. If a stray ref'd handle survives shutdown — a
|
| 15 |
+
* lingering socket (e.g. a connection blackholed by a restrictive firewall, or
|
| 16 |
+
* an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose
|
| 17 |
+
* pipes stay open — the loop never empties and the process hangs until an
|
| 18 |
+
* external timeout kills it.
|
| 19 |
+
*
|
| 20 |
+
* This arms an **unref'd** fallback timer: a healthy run drains and exits
|
| 21 |
+
* naturally before it fires (so behaviour is unchanged), and the timer itself
|
| 22 |
+
* never keeps the loop alive. It only force-exits a run whose loop is already
|
| 23 |
+
* wedged. The exit code is read lazily at fire time so callers may set
|
| 24 |
+
* `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal
|
| 25 |
+
* status to a non-zero code).
|
| 26 |
+
*
|
| 27 |
+
* Returns the timer handle so callers/tests can `clearTimeout` it.
|
| 28 |
+
*/
|
| 29 |
+
export function scheduleHeadlessForceExit(
|
| 30 |
+
proc: ExitableProcess,
|
| 31 |
+
getExitCode: () => number,
|
| 32 |
+
graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS,
|
| 33 |
+
): NodeJS.Timeout {
|
| 34 |
+
const timer = setTimeout(() => {
|
| 35 |
+
proc.exit(getExitCode());
|
| 36 |
+
}, graceMs);
|
| 37 |
+
timer.unref?.();
|
| 38 |
+
return timer;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** Resolve once a stream's currently-buffered writes have flushed to its sink. */
|
| 42 |
+
function flushStream(stream: Writable): Promise<void> {
|
| 43 |
+
return new Promise<void>((resolve) => {
|
| 44 |
+
try {
|
| 45 |
+
// An empty write's callback fires after all previously-queued writes have
|
| 46 |
+
// been flushed (writes are ordered), which is the documented way to know a
|
| 47 |
+
// stream's buffer has drained.
|
| 48 |
+
stream.write('', () => resolve());
|
| 49 |
+
} catch {
|
| 50 |
+
resolve();
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Wait for buffered output on the given streams to flush, bounded by `timeoutMs`.
|
| 57 |
+
*
|
| 58 |
+
* A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the
|
| 59 |
+
* pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any
|
| 60 |
+
* force-exit prevents truncating output from an otherwise-successful run. The
|
| 61 |
+
* wait is bounded so a permanently-stuck consumer can't re-introduce the hang.
|
| 62 |
+
*/
|
| 63 |
+
export async function drainStdio(
|
| 64 |
+
streams: readonly Writable[],
|
| 65 |
+
timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS,
|
| 66 |
+
): Promise<void> {
|
| 67 |
+
let timer: NodeJS.Timeout | undefined;
|
| 68 |
+
const timeout = new Promise<void>((resolve) => {
|
| 69 |
+
timer = setTimeout(resolve, timeoutMs);
|
| 70 |
+
timer.unref?.();
|
| 71 |
+
});
|
| 72 |
+
try {
|
| 73 |
+
await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]);
|
| 74 |
+
} finally {
|
| 75 |
+
if (timer !== undefined) clearTimeout(timer);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Finalize a completed headless run: flush stdio, then arm the force-exit
|
| 81 |
+
* backstop.
|
| 82 |
+
*
|
| 83 |
+
* Draining first means in-flight legitimate output is fully written before the
|
| 84 |
+
* backstop can fire, and — since drained stdio no longer holds the loop — only a
|
| 85 |
+
* genuinely leaked handle can keep it alive afterwards, which is exactly what
|
| 86 |
+
* the backstop is for.
|
| 87 |
+
*/
|
| 88 |
+
export async function finalizeHeadlessRun(
|
| 89 |
+
proc: ExitableProcess,
|
| 90 |
+
streams: readonly Writable[],
|
| 91 |
+
getExitCode: () => number,
|
| 92 |
+
options: { drainTimeoutMs?: number; graceMs?: number } = {},
|
| 93 |
+
): Promise<void> {
|
| 94 |
+
await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS);
|
| 95 |
+
scheduleHeadlessForceExit(proc, getExitCode, options.graceMs);
|
| 96 |
+
}
|
apps/kimi-code/src/cli/options.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type UIMode = 'shell' | 'print';
|
| 2 |
+
export type PromptOutputFormat = 'text' | 'stream-json';
|
| 3 |
+
|
| 4 |
+
/** Environment variable that sets the default `-p` output format (flag wins). */
|
| 5 |
+
export const OUTPUT_FORMAT_ENV = 'KIMI_MODEL_OUTPUT_FORMAT';
|
| 6 |
+
|
| 7 |
+
const OUTPUT_FORMATS = ['text', 'stream-json'] as const;
|
| 8 |
+
|
| 9 |
+
function isOutputFormat(value: string): value is PromptOutputFormat {
|
| 10 |
+
return (OUTPUT_FORMATS as readonly string[]).includes(value);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Resolve the effective `-p` output format.
|
| 15 |
+
*
|
| 16 |
+
* Precedence: explicit `--output-format` flag → `KIMI_MODEL_OUTPUT_FORMAT` env
|
| 17 |
+
* (prompt mode only) → `text`. The env var is ignored outside prompt mode so an
|
| 18 |
+
* ambient value never affects interactive `kimi`. An invalid env value fails
|
| 19 |
+
* fast via `OptionConflictError`.
|
| 20 |
+
*/
|
| 21 |
+
export function resolveOutputFormat(
|
| 22 |
+
opts: Pick<CLIOptions, 'prompt' | 'outputFormat'>,
|
| 23 |
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
| 24 |
+
): PromptOutputFormat {
|
| 25 |
+
if (opts.outputFormat !== undefined) return opts.outputFormat;
|
| 26 |
+
if (opts.prompt === undefined) return 'text';
|
| 27 |
+
const raw = (env[OUTPUT_FORMAT_ENV] ?? '').trim();
|
| 28 |
+
if (raw.length === 0) return 'text';
|
| 29 |
+
if (!isOutputFormat(raw)) {
|
| 30 |
+
throw new OptionConflictError(
|
| 31 |
+
`Invalid ${OUTPUT_FORMAT_ENV} value "${raw}". Expected one of: text, stream-json.`,
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
return raw;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export interface CLIOptions {
|
| 38 |
+
session: string | undefined;
|
| 39 |
+
continue: boolean;
|
| 40 |
+
yolo: boolean;
|
| 41 |
+
auto: boolean;
|
| 42 |
+
plan: boolean;
|
| 43 |
+
model: string | undefined;
|
| 44 |
+
outputFormat: PromptOutputFormat | undefined;
|
| 45 |
+
prompt: string | undefined;
|
| 46 |
+
skillsDirs: string[];
|
| 47 |
+
agent: string | undefined;
|
| 48 |
+
agentFiles: string[];
|
| 49 |
+
addDirs?: string[];
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export interface ValidatedOptions {
|
| 53 |
+
options: CLIOptions;
|
| 54 |
+
uiMode: UIMode;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export class OptionConflictError extends Error {
|
| 58 |
+
constructor(message: string) {
|
| 59 |
+
super(message);
|
| 60 |
+
this.name = 'OptionConflictError';
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export function validateOptions(
|
| 65 |
+
opts: CLIOptions,
|
| 66 |
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
| 67 |
+
): ValidatedOptions {
|
| 68 |
+
const prompt = opts.prompt;
|
| 69 |
+
const promptMode = prompt !== undefined;
|
| 70 |
+
if (promptMode && prompt.trim().length === 0) {
|
| 71 |
+
throw new OptionConflictError('Prompt cannot be empty.');
|
| 72 |
+
}
|
| 73 |
+
if (opts.model !== undefined && opts.model.trim().length === 0) {
|
| 74 |
+
throw new OptionConflictError('Model cannot be empty.');
|
| 75 |
+
}
|
| 76 |
+
if (!promptMode && opts.outputFormat !== undefined) {
|
| 77 |
+
throw new OptionConflictError('Output format is only supported in prompt mode.');
|
| 78 |
+
}
|
| 79 |
+
if (promptMode && opts.yolo) {
|
| 80 |
+
throw new OptionConflictError('Cannot combine --prompt with --yolo.');
|
| 81 |
+
}
|
| 82 |
+
if (promptMode && opts.auto) {
|
| 83 |
+
throw new OptionConflictError('Cannot combine --prompt with --auto.');
|
| 84 |
+
}
|
| 85 |
+
if (promptMode && opts.plan) {
|
| 86 |
+
throw new OptionConflictError('Cannot combine --prompt with --plan.');
|
| 87 |
+
}
|
| 88 |
+
if (opts.agent !== undefined && opts.agent.trim().length === 0) {
|
| 89 |
+
throw new OptionConflictError('Agent cannot be empty.');
|
| 90 |
+
}
|
| 91 |
+
if (opts.agentFiles.length > 1) {
|
| 92 |
+
throw new OptionConflictError('--agent-file may only be specified once.');
|
| 93 |
+
}
|
| 94 |
+
if (opts.agentFiles.some((file) => file.trim().length === 0)) {
|
| 95 |
+
throw new OptionConflictError('Agent file path cannot be empty.');
|
| 96 |
+
}
|
| 97 |
+
if (opts.agent !== undefined && opts.agentFiles.length > 0) {
|
| 98 |
+
throw new OptionConflictError('Cannot combine --agent with --agent-file.');
|
| 99 |
+
}
|
| 100 |
+
if (
|
| 101 |
+
(opts.agent !== undefined || opts.agentFiles.length > 0) &&
|
| 102 |
+
(opts.session !== undefined || opts.continue)
|
| 103 |
+
) {
|
| 104 |
+
throw new OptionConflictError(
|
| 105 |
+
'Cannot combine --agent/--agent-file with --session/--continue: the agent is bound at session creation and the bound agent is restored automatically on resume.',
|
| 106 |
+
);
|
| 107 |
+
}
|
| 108 |
+
if (promptMode && opts.session === '') {
|
| 109 |
+
throw new OptionConflictError('Cannot use --session without an id in prompt mode.');
|
| 110 |
+
}
|
| 111 |
+
if (opts.continue && opts.session !== undefined) {
|
| 112 |
+
throw new OptionConflictError('Cannot combine --continue, --session.');
|
| 113 |
+
}
|
| 114 |
+
if (opts.yolo && opts.auto) {
|
| 115 |
+
throw new OptionConflictError('Cannot combine --yolo with --auto.');
|
| 116 |
+
}
|
| 117 |
+
// Validate `KIMI_MODEL_OUTPUT_FORMAT` eagerly in prompt mode so a typo fails
|
| 118 |
+
// fast through the friendly `error:` path instead of mid-run.
|
| 119 |
+
if (promptMode) resolveOutputFormat(opts, env);
|
| 120 |
+
return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
|
| 121 |
+
}
|
apps/kimi-code/src/cli/prompt-render.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Output rendering for `kimi -p` (print mode) — shared by the v1 driver
|
| 3 |
+
* (`run-prompt.ts`) and the native v2 runner (`v2/run-v2-print.ts`).
|
| 4 |
+
*
|
| 5 |
+
* Both engines feed the same writer classes: v1 via the SDK `Event` stream, v2
|
| 6 |
+
* via the main agent's native `IEventBus` (whose `DomainEvent` payloads are
|
| 7 |
+
* already v1-protocol-shaped). Keeping the writers here lets v2 reuse them
|
| 8 |
+
* without re-implementing rendering, while v1's `runPromptTurn` keeps its own
|
| 9 |
+
* event-filtering / completion flow intact.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import type { PromptOutputFormat } from './options';
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Structural hook-result shape the renderer reads. Both the v1 SDK
|
| 16 |
+
* `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it,
|
| 17 |
+
* so the renderer stays engine-agnostic without depending on either event
|
| 18 |
+
* definition.
|
| 19 |
+
*/
|
| 20 |
+
interface HookResultEventLike {
|
| 21 |
+
readonly hookEvent: string;
|
| 22 |
+
readonly content: string;
|
| 23 |
+
readonly blocked?: boolean;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Structural retry shape the renderer reads. Mirrors the v1 SDK
|
| 28 |
+
* `turn.step.retrying` event fields the stream-json meta line surfaces. Both
|
| 29 |
+
* drivers forward retries to `writeRetrying`: v1 from its SDK event stream,
|
| 30 |
+
* v2 from the native `turn.step.retrying` `DomainEvent` (same field names),
|
| 31 |
+
* after discarding the failed attempt's partial output.
|
| 32 |
+
*/
|
| 33 |
+
interface RetryingEventLike {
|
| 34 |
+
readonly failedAttempt: number;
|
| 35 |
+
readonly nextAttempt: number;
|
| 36 |
+
readonly maxAttempts: number;
|
| 37 |
+
readonly delayMs: number;
|
| 38 |
+
readonly errorName: string;
|
| 39 |
+
readonly errorMessage: string;
|
| 40 |
+
readonly statusCode?: number;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface PromptOutput {
|
| 44 |
+
readonly columns?: number | undefined;
|
| 45 |
+
write(chunk: string): boolean;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const PROMPT_BLOCK_BULLET = '• ';
|
| 49 |
+
const PROMPT_BLOCK_INDENT = ' ';
|
| 50 |
+
|
| 51 |
+
export interface PromptTurnWriter {
|
| 52 |
+
writeAssistantDelta(delta: string): void;
|
| 53 |
+
writeHookResult(event: HookResultEventLike): void;
|
| 54 |
+
writeThinkingDelta(delta: string): void;
|
| 55 |
+
writeToolCall(toolCallId: string, name: string, args: unknown): void;
|
| 56 |
+
writeToolCallDelta(
|
| 57 |
+
toolCallId: string,
|
| 58 |
+
name: string | undefined,
|
| 59 |
+
argumentsPart: string | undefined,
|
| 60 |
+
): void;
|
| 61 |
+
writeToolResult(toolCallId: string, output: unknown): void;
|
| 62 |
+
writeRetrying(event: RetryingEventLike): void;
|
| 63 |
+
flushAssistant(): void;
|
| 64 |
+
discardAssistant(): void;
|
| 65 |
+
finish(): void;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
interface PromptJsonToolCall {
|
| 69 |
+
type: 'function';
|
| 70 |
+
id: string;
|
| 71 |
+
function: {
|
| 72 |
+
name: string;
|
| 73 |
+
arguments: string;
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
interface PromptJsonAssistantMessage {
|
| 78 |
+
role: 'assistant';
|
| 79 |
+
content?: string;
|
| 80 |
+
tool_calls?: PromptJsonToolCall[];
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
interface PromptJsonToolMessage {
|
| 84 |
+
role: 'tool';
|
| 85 |
+
tool_call_id: string;
|
| 86 |
+
content: string;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
interface PromptJsonRetryMetaMessage {
|
| 90 |
+
role: 'meta';
|
| 91 |
+
type: 'turn.step.retrying';
|
| 92 |
+
failed_attempt: number;
|
| 93 |
+
next_attempt: number;
|
| 94 |
+
max_attempts: number;
|
| 95 |
+
delay_ms: number;
|
| 96 |
+
error_name: string;
|
| 97 |
+
error_message: string;
|
| 98 |
+
status_code?: number;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export class PromptTranscriptWriter implements PromptTurnWriter {
|
| 102 |
+
private readonly assistantWriter: PromptBlockWriter;
|
| 103 |
+
private readonly thinkingWriter: PromptBlockWriter;
|
| 104 |
+
|
| 105 |
+
constructor(stdout: PromptOutput, stderr: PromptOutput) {
|
| 106 |
+
this.assistantWriter = new PromptBlockWriter(stdout);
|
| 107 |
+
this.thinkingWriter = new PromptBlockWriter(stderr);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
writeAssistantDelta(delta: string): void {
|
| 111 |
+
this.thinkingWriter.finish();
|
| 112 |
+
this.assistantWriter.write(delta);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
writeHookResult(event: HookResultEventLike): void {
|
| 116 |
+
this.thinkingWriter.finish();
|
| 117 |
+
this.assistantWriter.finish();
|
| 118 |
+
this.assistantWriter.write(formatHookResultPlain(event));
|
| 119 |
+
this.assistantWriter.finish();
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
writeThinkingDelta(delta: string): void {
|
| 123 |
+
this.thinkingWriter.write(delta);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
writeToolCall(): void {}
|
| 127 |
+
|
| 128 |
+
writeToolCallDelta(): void {}
|
| 129 |
+
|
| 130 |
+
writeToolResult(): void {}
|
| 131 |
+
|
| 132 |
+
// Text `-p` keeps retries silent: only the failed attempt's partial assistant
|
| 133 |
+
// text is discarded (handled by the caller). No human-readable retry line is
|
| 134 |
+
// emitted, matching the prior behavior.
|
| 135 |
+
writeRetrying(): void {}
|
| 136 |
+
|
| 137 |
+
flushAssistant(): void {
|
| 138 |
+
this.assistantWriter.finish();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
discardAssistant(): void {}
|
| 142 |
+
|
| 143 |
+
finish(): void {
|
| 144 |
+
this.thinkingWriter.finish();
|
| 145 |
+
this.assistantWriter.finish();
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export class PromptJsonWriter implements PromptTurnWriter {
|
| 150 |
+
private assistantText = '';
|
| 151 |
+
private readonly toolCalls: PromptJsonToolCall[] = [];
|
| 152 |
+
|
| 153 |
+
constructor(private readonly stdout: PromptOutput) {}
|
| 154 |
+
|
| 155 |
+
writeAssistantDelta(delta: string): void {
|
| 156 |
+
this.assistantText += delta;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
writeHookResult(event: HookResultEventLike): void {
|
| 160 |
+
this.flushAssistant();
|
| 161 |
+
this.writeJsonLine({
|
| 162 |
+
role: 'assistant',
|
| 163 |
+
content: formatHookResultPlain(event),
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
writeThinkingDelta(): void {}
|
| 168 |
+
|
| 169 |
+
writeToolCall(toolCallId: string, name: string, args: unknown): void {
|
| 170 |
+
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
|
| 171 |
+
if (existing !== undefined) {
|
| 172 |
+
existing.function.name = name;
|
| 173 |
+
existing.function.arguments = stringifyJsonValue(args);
|
| 174 |
+
return;
|
| 175 |
+
}
|
| 176 |
+
this.toolCalls.push({
|
| 177 |
+
type: 'function',
|
| 178 |
+
id: toolCallId,
|
| 179 |
+
function: {
|
| 180 |
+
name,
|
| 181 |
+
arguments: stringifyJsonValue(args),
|
| 182 |
+
},
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
writeToolCallDelta(
|
| 187 |
+
toolCallId: string,
|
| 188 |
+
name: string | undefined,
|
| 189 |
+
argumentsPart: string | undefined,
|
| 190 |
+
): void {
|
| 191 |
+
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
|
| 192 |
+
if (name !== undefined) {
|
| 193 |
+
toolCall.function.name = name;
|
| 194 |
+
}
|
| 195 |
+
if (argumentsPart !== undefined) {
|
| 196 |
+
toolCall.function.arguments += argumentsPart;
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
writeToolResult(toolCallId: string, output: unknown): void {
|
| 201 |
+
this.flushAssistant();
|
| 202 |
+
this.writeJsonLine({
|
| 203 |
+
role: 'tool',
|
| 204 |
+
tool_call_id: toolCallId,
|
| 205 |
+
content: stringifyToolOutput(output),
|
| 206 |
+
});
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
writeRetrying(event: RetryingEventLike): void {
|
| 210 |
+
// Emit a machine-readable meta line so stream-json consumers can observe
|
| 211 |
+
// provider retries. The failed attempt's partial assistant text was already
|
| 212 |
+
// discarded by the caller, so no half-formed assistant message leaks.
|
| 213 |
+
const message: PromptJsonRetryMetaMessage = {
|
| 214 |
+
role: 'meta',
|
| 215 |
+
type: 'turn.step.retrying',
|
| 216 |
+
failed_attempt: event.failedAttempt,
|
| 217 |
+
next_attempt: event.nextAttempt,
|
| 218 |
+
max_attempts: event.maxAttempts,
|
| 219 |
+
delay_ms: event.delayMs,
|
| 220 |
+
error_name: event.errorName,
|
| 221 |
+
error_message: event.errorMessage,
|
| 222 |
+
status_code: event.statusCode,
|
| 223 |
+
};
|
| 224 |
+
this.writeJsonLine(message);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
flushAssistant(): void {
|
| 228 |
+
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
|
| 229 |
+
const message: PromptJsonAssistantMessage = {
|
| 230 |
+
role: 'assistant',
|
| 231 |
+
content: this.assistantText.length > 0 ? this.assistantText : undefined,
|
| 232 |
+
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
|
| 233 |
+
};
|
| 234 |
+
this.writeJsonLine(message);
|
| 235 |
+
this.discardAssistant();
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
discardAssistant(): void {
|
| 239 |
+
this.assistantText = '';
|
| 240 |
+
this.toolCalls.length = 0;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
finish(): void {
|
| 244 |
+
this.flushAssistant();
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
|
| 248 |
+
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
|
| 249 |
+
if (existing !== undefined) return existing;
|
| 250 |
+
const toolCall: PromptJsonToolCall = {
|
| 251 |
+
type: 'function',
|
| 252 |
+
id: toolCallId,
|
| 253 |
+
function: {
|
| 254 |
+
name,
|
| 255 |
+
arguments: '',
|
| 256 |
+
},
|
| 257 |
+
};
|
| 258 |
+
this.toolCalls.push(toolCall);
|
| 259 |
+
return toolCall;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
private writeJsonLine(
|
| 263 |
+
message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage,
|
| 264 |
+
): void {
|
| 265 |
+
this.stdout.write(`${JSON.stringify(message)}\n`);
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
class PromptBlockWriter {
|
| 270 |
+
private started = false;
|
| 271 |
+
private atLineStart = false;
|
| 272 |
+
private lineWidth = 0;
|
| 273 |
+
private readonly wrapWidth: number | undefined;
|
| 274 |
+
|
| 275 |
+
constructor(private readonly output: PromptOutput) {
|
| 276 |
+
this.wrapWidth =
|
| 277 |
+
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
|
| 278 |
+
? output.columns
|
| 279 |
+
: undefined;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
write(chunk: string): void {
|
| 283 |
+
if (chunk.length === 0) return;
|
| 284 |
+
let rendered = this.start();
|
| 285 |
+
for (const char of chunk) {
|
| 286 |
+
if (this.atLineStart && char !== '\n') {
|
| 287 |
+
rendered += PROMPT_BLOCK_INDENT;
|
| 288 |
+
this.atLineStart = false;
|
| 289 |
+
this.lineWidth = PROMPT_BLOCK_INDENT.length;
|
| 290 |
+
}
|
| 291 |
+
const charWidth = visibleCharWidth(char);
|
| 292 |
+
if (
|
| 293 |
+
this.wrapWidth !== undefined &&
|
| 294 |
+
!this.atLineStart &&
|
| 295 |
+
char !== '\n' &&
|
| 296 |
+
this.lineWidth + charWidth > this.wrapWidth
|
| 297 |
+
) {
|
| 298 |
+
rendered += `\n${PROMPT_BLOCK_INDENT}`;
|
| 299 |
+
this.lineWidth = PROMPT_BLOCK_INDENT.length;
|
| 300 |
+
}
|
| 301 |
+
rendered += char;
|
| 302 |
+
if (char === '\n') {
|
| 303 |
+
this.atLineStart = true;
|
| 304 |
+
this.lineWidth = 0;
|
| 305 |
+
} else {
|
| 306 |
+
this.lineWidth += charWidth;
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
this.output.write(rendered);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
finish(): void {
|
| 313 |
+
if (!this.started) return;
|
| 314 |
+
this.output.write(this.atLineStart ? '\n' : '\n\n');
|
| 315 |
+
this.started = false;
|
| 316 |
+
this.atLineStart = false;
|
| 317 |
+
this.lineWidth = 0;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
private start(): string {
|
| 321 |
+
if (this.started) return '';
|
| 322 |
+
this.started = true;
|
| 323 |
+
this.atLineStart = false;
|
| 324 |
+
this.lineWidth = PROMPT_BLOCK_BULLET.length;
|
| 325 |
+
return PROMPT_BLOCK_BULLET;
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
function visibleCharWidth(char: string): number {
|
| 330 |
+
return char === '\t' ? 4 : 1;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function formatHookResultPlain(event: HookResultEventLike): string {
|
| 334 |
+
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
function formatHookResultTitle(event: HookResultEventLike): string {
|
| 338 |
+
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
function formatHookResultBody(event: HookResultEventLike): string {
|
| 342 |
+
const content = event.content.trim();
|
| 343 |
+
return content.length === 0 ? '(empty)' : content;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
function stringifyJsonValue(value: unknown): string {
|
| 347 |
+
if (typeof value === 'string') return value;
|
| 348 |
+
const json = JSON.stringify(value);
|
| 349 |
+
return json ?? '';
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function stringifyToolOutput(output: unknown): string {
|
| 353 |
+
if (typeof output === 'string') return output;
|
| 354 |
+
const json = JSON.stringify(output);
|
| 355 |
+
return json ?? String(output);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
interface PromptJsonResumeMetaMessage {
|
| 359 |
+
role: 'meta';
|
| 360 |
+
type: 'session.resume_hint';
|
| 361 |
+
session_id: string;
|
| 362 |
+
command: string;
|
| 363 |
+
content: string;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
interface PromptJsonVersionMetaMessage {
|
| 367 |
+
role: 'meta';
|
| 368 |
+
type: 'system.version';
|
| 369 |
+
version: string;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
export function writeExperimentalVersion(
|
| 373 |
+
version: string,
|
| 374 |
+
outputFormat: PromptOutputFormat,
|
| 375 |
+
stdout: PromptOutput,
|
| 376 |
+
stderr: PromptOutput,
|
| 377 |
+
): void {
|
| 378 |
+
if (outputFormat === 'stream-json') {
|
| 379 |
+
const message: PromptJsonVersionMetaMessage = {
|
| 380 |
+
role: 'meta',
|
| 381 |
+
type: 'system.version',
|
| 382 |
+
version,
|
| 383 |
+
};
|
| 384 |
+
stdout.write(`${JSON.stringify(message)}\n`);
|
| 385 |
+
return;
|
| 386 |
+
}
|
| 387 |
+
stderr.write(`kimi version ${version}\n`);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
export function writeResumeHint(
|
| 391 |
+
sessionId: string,
|
| 392 |
+
outputFormat: PromptOutputFormat,
|
| 393 |
+
stdout: PromptOutput,
|
| 394 |
+
stderr: PromptOutput,
|
| 395 |
+
): void {
|
| 396 |
+
const command = `kimi -r ${sessionId}`;
|
| 397 |
+
const content = `To resume this session: ${command}`;
|
| 398 |
+
if (outputFormat === 'stream-json') {
|
| 399 |
+
const message: PromptJsonResumeMetaMessage = {
|
| 400 |
+
role: 'meta',
|
| 401 |
+
type: 'session.resume_hint',
|
| 402 |
+
session_id: sessionId,
|
| 403 |
+
command,
|
| 404 |
+
content,
|
| 405 |
+
};
|
| 406 |
+
stdout.write(`${JSON.stringify(message)}\n`);
|
| 407 |
+
return;
|
| 408 |
+
}
|
| 409 |
+
stderr.write(`${content}\n`);
|
| 410 |
+
}
|
apps/kimi-code/src/cli/prompt-session.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
ConfigDiagnostics,
|
| 3 |
+
CreateSessionOptions,
|
| 4 |
+
KimiAuthFacade,
|
| 5 |
+
KimiConfig,
|
| 6 |
+
ListSessionsOptions,
|
| 7 |
+
ResumeSessionInput,
|
| 8 |
+
Session,
|
| 9 |
+
SessionSummary,
|
| 10 |
+
TelemetryProperties,
|
| 11 |
+
} from '@moonshot-ai/kimi-code-sdk';
|
| 12 |
+
|
| 13 |
+
export interface PromptHarness {
|
| 14 |
+
readonly homeDir: string;
|
| 15 |
+
readonly auth: KimiAuthFacade;
|
| 16 |
+
|
| 17 |
+
track(event: string, properties?: TelemetryProperties): void;
|
| 18 |
+
|
| 19 |
+
ensureConfigFile(): Promise<void>;
|
| 20 |
+
getConfig(): Promise<Pick<KimiConfig, 'defaultModel' | 'telemetry'>>;
|
| 21 |
+
getConfigDiagnostics(): Promise<ConfigDiagnostics>;
|
| 22 |
+
listSessions(options: ListSessionsOptions): Promise<readonly SessionSummary[]>;
|
| 23 |
+
createSession(options: CreateSessionOptions): Promise<Session>;
|
| 24 |
+
resumeSession(input: ResumeSessionInput): Promise<Session>;
|
| 25 |
+
close(): Promise<void>;
|
| 26 |
+
}
|
apps/kimi-code/src/cli/run-prompt.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CLIOptions } from './options';
|
| 2 |
+
|
| 3 |
+
export async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
|
| 4 |
+
let timedOut = false;
|
| 5 |
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
| 6 |
+
const guarded = promise.catch((error: unknown) => {
|
| 7 |
+
if (timedOut) return;
|
| 8 |
+
throw error;
|
| 9 |
+
});
|
| 10 |
+
const timedOutSignal = new Promise<void>((resolve) => {
|
| 11 |
+
timer = setTimeout(() => {
|
| 12 |
+
timedOut = true;
|
| 13 |
+
resolve();
|
| 14 |
+
}, timeoutMs);
|
| 15 |
+
});
|
| 16 |
+
try {
|
| 17 |
+
await Promise.race([guarded, timedOutSignal]);
|
| 18 |
+
} finally {
|
| 19 |
+
if (timer !== undefined) clearTimeout(timer);
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
interface PromptOutput {
|
| 24 |
+
readonly columns?: number | undefined;
|
| 25 |
+
write(chunk: string): boolean;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export interface PromptRunIO {
|
| 29 |
+
readonly stdout?: PromptOutput;
|
| 30 |
+
readonly stderr?: PromptOutput;
|
| 31 |
+
readonly process?: PromptProcess;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface PromptProcess {
|
| 35 |
+
once(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
|
| 36 |
+
off(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
|
| 37 |
+
exit(code?: number): never | void;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export async function runPrompt(
|
| 41 |
+
opts: CLIOptions,
|
| 42 |
+
version: string,
|
| 43 |
+
io: PromptRunIO = {},
|
| 44 |
+
): Promise<void> {
|
| 45 |
+
const { runV2Print } = await import('./v2/run-v2-print');
|
| 46 |
+
await runV2Print(opts, version, io);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
|
| 50 |
+
const model = configuredModel(...models);
|
| 51 |
+
if (model === undefined) {
|
| 52 |
+
throw new Error(
|
| 53 |
+
'No model configured. Run `kimi` and use /login to sign in, then retry; or set default_model in config.toml.',
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
return model;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
|
| 60 |
+
return models.find((model) => model !== undefined && model.trim().length > 0);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function installPromptTerminationCleanup(
|
| 64 |
+
promptProcess: PromptProcess,
|
| 65 |
+
cleanup: () => Promise<void>,
|
| 66 |
+
): () => void {
|
| 67 |
+
let terminating = false;
|
| 68 |
+
const exitAfterCleanup = async (signal: NodeJS.Signals): Promise<void> => {
|
| 69 |
+
if (terminating) return;
|
| 70 |
+
terminating = true;
|
| 71 |
+
try {
|
| 72 |
+
await cleanup();
|
| 73 |
+
} finally {
|
| 74 |
+
promptProcess.exit(signalExitCode(signal));
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
const onSigint = () => exitAfterCleanup('SIGINT');
|
| 78 |
+
const onSigterm = () => exitAfterCleanup('SIGTERM');
|
| 79 |
+
const onSighup = () => exitAfterCleanup('SIGHUP');
|
| 80 |
+
promptProcess.once('SIGINT', onSigint);
|
| 81 |
+
promptProcess.once('SIGTERM', onSigterm);
|
| 82 |
+
promptProcess.once('SIGHUP', onSighup);
|
| 83 |
+
return () => {
|
| 84 |
+
promptProcess.off('SIGINT', onSigint);
|
| 85 |
+
promptProcess.off('SIGTERM', onSigterm);
|
| 86 |
+
promptProcess.off('SIGHUP', onSighup);
|
| 87 |
+
};
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export function signalExitCode(signal: NodeJS.Signals): number {
|
| 91 |
+
if (signal === 'SIGINT') return 130;
|
| 92 |
+
if (signal === 'SIGHUP') return 129;
|
| 93 |
+
return 143;
|
| 94 |
+
}
|
apps/kimi-code/src/cli/run-shell.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { execFileSync, spawnSync } from 'node:child_process';
|
| 2 |
+
import { homedir } from 'node:os';
|
| 3 |
+
|
| 4 |
+
import {
|
| 5 |
+
createKimiHarness,
|
| 6 |
+
flushDiagnosticLogsSync,
|
| 7 |
+
log,
|
| 8 |
+
type KimiHarness,
|
| 9 |
+
type KimiHarnessOptions,
|
| 10 |
+
type TelemetryClient,
|
| 11 |
+
} from '@moonshot-ai/kimi-code-sdk';
|
| 12 |
+
import {
|
| 13 |
+
setCrashPhase,
|
| 14 |
+
setTelemetryContext,
|
| 15 |
+
shutdownTelemetry,
|
| 16 |
+
track,
|
| 17 |
+
withTelemetryContext,
|
| 18 |
+
} from '@moonshot-ai/kimi-telemetry';
|
| 19 |
+
|
| 20 |
+
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, TUI_HOST_UI_CAPABILITIES } from '#/constant/app';
|
| 21 |
+
import { detectPendingMigration, resolveLegacySourceHome, sameLegacyPath } from '#/migration/index';
|
| 22 |
+
import type { TuiConfig } from '#/tui/config';
|
| 23 |
+
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
|
| 24 |
+
import { CHROME_GUTTER } from '#/tui/constant/rendering';
|
| 25 |
+
import { KimiTUI } from '#/tui/index';
|
| 26 |
+
import { startupTrace } from '#/utils/startup-trace';
|
| 27 |
+
import { currentTheme, getColorPalette } from '#/tui/theme';
|
| 28 |
+
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
|
| 29 |
+
import { restoreTerminalModes } from '#/utils/terminal-restore';
|
| 30 |
+
import { resolveCommandPath } from '#/utils/process/resolve-command';
|
| 31 |
+
|
| 32 |
+
import type { CLIOptions } from './options';
|
| 33 |
+
import { resolveAgentProfileSelection } from './agent-selection';
|
| 34 |
+
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
|
| 35 |
+
import { createKimiCodeHostIdentity } from './version';
|
| 36 |
+
|
| 37 |
+
export async function runShell(
|
| 38 |
+
opts: CLIOptions,
|
| 39 |
+
version: string,
|
| 40 |
+
runOptions: { readonly migrateOnly?: boolean } = {},
|
| 41 |
+
): Promise<void> {
|
| 42 |
+
const startedAt = Date.now();
|
| 43 |
+
const configStartedAt = startedAt;
|
| 44 |
+
let tuiConfig: TuiConfig;
|
| 45 |
+
let configWarning: string | undefined;
|
| 46 |
+
try {
|
| 47 |
+
tuiConfig = await loadTuiConfig();
|
| 48 |
+
} catch (error) {
|
| 49 |
+
if (!(error instanceof TuiConfigParseError)) throw error;
|
| 50 |
+
tuiConfig = error.fallback;
|
| 51 |
+
configWarning = error.message;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Initialise the global Theme singleton before pi-tui grabs stdin.
|
| 55 |
+
const palette = await getColorPalette(tuiConfig.theme);
|
| 56 |
+
currentTheme.setPalette(palette);
|
| 57 |
+
|
| 58 |
+
const workDir = process.cwd();
|
| 59 |
+
const telemetryBootstrap = createCliTelemetryBootstrap();
|
| 60 |
+
const telemetryClient: TelemetryClient = {
|
| 61 |
+
track,
|
| 62 |
+
withContext: withTelemetryContext,
|
| 63 |
+
setContext: setTelemetryContext,
|
| 64 |
+
};
|
| 65 |
+
const harnessOptions: KimiHarnessOptions = {
|
| 66 |
+
homeDir: telemetryBootstrap.homeDir,
|
| 67 |
+
identity: createKimiCodeHostIdentity(version),
|
| 68 |
+
skillDirs: opts.skillsDirs,
|
| 69 |
+
// The TUI renders the mid-turn update panel; declaring it here is what
|
| 70 |
+
// makes the engine offer NotifyUser to this process and to no other host.
|
| 71 |
+
uiCapabilities: TUI_HOST_UI_CAPABILITIES,
|
| 72 |
+
telemetry: telemetryClient,
|
| 73 |
+
onOAuthRefresh: (outcome) => {
|
| 74 |
+
if (outcome.success) {
|
| 75 |
+
track('oauth_refresh', { outcome: 'success' });
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
track('oauth_refresh', {
|
| 79 |
+
outcome: 'error',
|
| 80 |
+
reason: outcome.reason,
|
| 81 |
+
});
|
| 82 |
+
},
|
| 83 |
+
sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false },
|
| 84 |
+
};
|
| 85 |
+
const harness = createKimiHarness(harnessOptions);
|
| 86 |
+
startupTrace('harness:created');
|
| 87 |
+
log.info('kimi-code starting', {
|
| 88 |
+
version,
|
| 89 |
+
uiMode: CLI_UI_MODE,
|
| 90 |
+
nodeVersion: process.version,
|
| 91 |
+
platform: `${process.platform}/${process.arch}`,
|
| 92 |
+
workDir,
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
await harness.ensureConfigFile();
|
| 96 |
+
const legacySource = resolveLegacySourceHome(process.env, homedir(), process.cwd());
|
| 97 |
+
const sourceIsTarget = sameLegacyPath(legacySource.sourceHome, harness.homeDir);
|
| 98 |
+
if (sourceIsTarget) {
|
| 99 |
+
process.stderr.write(
|
| 100 |
+
` 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`,
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
const migrationPlan = sourceIsTarget
|
| 104 |
+
? null
|
| 105 |
+
: await detectPendingMigration({
|
| 106 |
+
sourceHome: legacySource.sourceHome,
|
| 107 |
+
skillsSourceHome: legacySource.skillsSourceHome,
|
| 108 |
+
targetHome: harness.homeDir,
|
| 109 |
+
ignoreMarker: runOptions.migrateOnly,
|
| 110 |
+
});
|
| 111 |
+
if (runOptions.migrateOnly === true && migrationPlan === null) {
|
| 112 |
+
if (!sourceIsTarget) {
|
| 113 |
+
process.stdout.write(` Nothing to migrate from ${legacySource.sourceHome}.\n`);
|
| 114 |
+
}
|
| 115 |
+
await harness.close();
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
const config = await harness.getConfig();
|
| 119 |
+
startupTrace('config:loaded');
|
| 120 |
+
// Config diagnostics (deprecated keys, invalid sections, ...) are surfaced
|
| 121 |
+
// by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` —
|
| 122 |
+
// folded into the dim startup notice they were too easy to miss.
|
| 123 |
+
const configMs = Date.now() - configStartedAt;
|
| 124 |
+
// Resolve --agent/--agent-file once for the startup session; validateOptions
|
| 125 |
+
// has already rejected them alongside --session/--continue.
|
| 126 |
+
const agentProfile = await resolveAgentProfileSelection(opts, workDir);
|
| 127 |
+
const tui = new KimiTUI(harness, {
|
| 128 |
+
cliOptions: opts,
|
| 129 |
+
agentProfile,
|
| 130 |
+
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
| 131 |
+
tuiConfig,
|
| 132 |
+
version,
|
| 133 |
+
workDir,
|
| 134 |
+
startupNotice: configWarning,
|
| 135 |
+
migrationPlan,
|
| 136 |
+
migrateOnly: runOptions.migrateOnly,
|
| 137 |
+
telemetryDisabled: config.telemetry === false,
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
initializeCliTelemetry({
|
| 141 |
+
harness,
|
| 142 |
+
bootstrap: telemetryBootstrap,
|
| 143 |
+
config,
|
| 144 |
+
version,
|
| 145 |
+
uiMode: CLI_UI_MODE,
|
| 146 |
+
});
|
| 147 |
+
setCrashPhase('runtime');
|
| 148 |
+
|
| 149 |
+
const trackLifecycleForSession = (
|
| 150 |
+
sessionId: string,
|
| 151 |
+
event: string,
|
| 152 |
+
properties?: Parameters<KimiHarness['track']>[1],
|
| 153 |
+
) => {
|
| 154 |
+
if (sessionId.length === 0) {
|
| 155 |
+
harness.track(event, properties);
|
| 156 |
+
return;
|
| 157 |
+
}
|
| 158 |
+
withTelemetryContext({ sessionId }).track(event, properties);
|
| 159 |
+
};
|
| 160 |
+
const trackLifecycle = (event: string, properties?: Parameters<KimiHarness['track']>[1]) => {
|
| 161 |
+
trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
let savedStty: string | undefined;
|
| 165 |
+
// stty runs before tui.start() reaches the workspace trust gate, so it must
|
| 166 |
+
// never be resolved by name through PATH: a `.` or empty PATH segment would
|
| 167 |
+
// let an untrusted checkout plant an `stty` executable and run it pre-trust.
|
| 168 |
+
// resolveCommandPath returns an absolute path and refuses hits inside the
|
| 169 |
+
// cwd; when it cannot resolve stty, skip the save/restore entirely — it is
|
| 170 |
+
// best-effort terminal hygiene, not required for startup.
|
| 171 |
+
// stty is also POSIX-only, so skip it on Windows instead of relying on the
|
| 172 |
+
// catch below.
|
| 173 |
+
const sttyPath = process.platform === 'win32' ? undefined : resolveCommandPath('stty');
|
| 174 |
+
if (sttyPath !== undefined) {
|
| 175 |
+
try {
|
| 176 |
+
// stty operates on the terminal behind stdin, so stdin must be the TTY —
|
| 177 |
+
// piping /dev/null (ignore) makes stty fail with "not a tty".
|
| 178 |
+
const saved = execFileSync(sttyPath, ['-g'], {
|
| 179 |
+
encoding: 'utf8',
|
| 180 |
+
stdio: ['inherit', 'pipe', 'ignore'],
|
| 181 |
+
});
|
| 182 |
+
savedStty = saved.trim();
|
| 183 |
+
execFileSync(sttyPath, ['-ixon'], { stdio: ['inherit', 'ignore', 'ignore'] });
|
| 184 |
+
} catch {
|
| 185 |
+
/* ignore */
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
const restoreStty = (): void => {
|
| 189 |
+
if (sttyPath === undefined || savedStty === undefined) return;
|
| 190 |
+
const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
|
| 191 |
+
if (args.length === 0) return;
|
| 192 |
+
spawnSync(sttyPath, args, { stdio: ['inherit', 'ignore', 'ignore'] });
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
// If we crash without going through KimiTUI.stop(), the terminal is left in
|
| 196 |
+
// raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore
|
| 197 |
+
// both before exiting so the user's shell is usable afterwards.
|
| 198 |
+
const emergencyExit = (exitCode: number): void => {
|
| 199 |
+
// The crash log above is only enqueued into the async sink; flush it
|
| 200 |
+
// synchronously or the `process.exit()` below would drop the one line that
|
| 201 |
+
// explains why we crashed. Best-effort: an exit path must never throw.
|
| 202 |
+
try {
|
| 203 |
+
flushDiagnosticLogsSync();
|
| 204 |
+
} catch {
|
| 205 |
+
/* ignore */
|
| 206 |
+
}
|
| 207 |
+
restoreTerminalModes();
|
| 208 |
+
restoreStty();
|
| 209 |
+
process.exit(exitCode);
|
| 210 |
+
};
|
| 211 |
+
const onUncaughtException = (error: unknown): void => {
|
| 212 |
+
try {
|
| 213 |
+
log.error('uncaughtException, restoring terminal and exiting', { error: String(error) });
|
| 214 |
+
} catch {
|
| 215 |
+
/* ignore */
|
| 216 |
+
}
|
| 217 |
+
emergencyExit(1);
|
| 218 |
+
};
|
| 219 |
+
const onUnhandledRejection = (reason: unknown): void => {
|
| 220 |
+
try {
|
| 221 |
+
log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) });
|
| 222 |
+
} catch {
|
| 223 |
+
/* ignore */
|
| 224 |
+
}
|
| 225 |
+
emergencyExit(1);
|
| 226 |
+
};
|
| 227 |
+
process.on('uncaughtException', onUncaughtException);
|
| 228 |
+
process.on('unhandledRejection', onUnhandledRejection);
|
| 229 |
+
// Remove the crash handlers once the TUI exits cleanly so repeated runShell()
|
| 230 |
+
// calls in the same process (e.g. tests) don't accumulate process listeners.
|
| 231 |
+
const removeCrashHandlers = (): void => {
|
| 232 |
+
process.off('uncaughtException', onUncaughtException);
|
| 233 |
+
process.off('unhandledRejection', onUnhandledRejection);
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
tui.onExit = async (exitCode = 0) => {
|
| 237 |
+
const sessionId = tui.getCurrentSessionId();
|
| 238 |
+
const hasContent = tui.hasSessionContent();
|
| 239 |
+
setCrashPhase('shutdown');
|
| 240 |
+
trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode });
|
| 241 |
+
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
| 242 |
+
const gutter = ' '.repeat(CHROME_GUTTER);
|
| 243 |
+
process.stdout.write(`${gutter}Bye!\n`);
|
| 244 |
+
const hints: string[] = [];
|
| 245 |
+
if (sessionId !== '' && hasContent) {
|
| 246 |
+
hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`);
|
| 247 |
+
}
|
| 248 |
+
if (tui.exitOpenUrl !== undefined) {
|
| 249 |
+
hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`);
|
| 250 |
+
}
|
| 251 |
+
if (hints.length > 0) {
|
| 252 |
+
process.stderr.write(`\n${hints.join('\n')}\n`);
|
| 253 |
+
}
|
| 254 |
+
removeCrashHandlers();
|
| 255 |
+
restoreStty();
|
| 256 |
+
if (tui.exitForegroundTask !== undefined) {
|
| 257 |
+
// `/web` starting a new server: the TUI has shut down cleanly; hand the
|
| 258 |
+
// terminal to the foreground server instead of exiting. The task runs
|
| 259 |
+
// until the server stops (Ctrl+C), then this process exits.
|
| 260 |
+
await tui.exitForegroundTask(exitCode);
|
| 261 |
+
return;
|
| 262 |
+
}
|
| 263 |
+
process.exit(exitCode);
|
| 264 |
+
};
|
| 265 |
+
try {
|
| 266 |
+
const initStartedAt = Date.now();
|
| 267 |
+
startupTrace('tui.start:begin');
|
| 268 |
+
await tui.start();
|
| 269 |
+
startupTrace('tui.start:end');
|
| 270 |
+
const initMs = Date.now() - initStartedAt;
|
| 271 |
+
const startupSessionId = tui.getCurrentSessionId();
|
| 272 |
+
const mcpMs = await tui.getStartupMcpMs();
|
| 273 |
+
trackLifecycleForSession(startupSessionId, 'startup_perf', {
|
| 274 |
+
duration_ms: Date.now() - startedAt,
|
| 275 |
+
config_ms: configMs,
|
| 276 |
+
init_ms: initMs,
|
| 277 |
+
mcp_ms: mcpMs,
|
| 278 |
+
tui_mode: tui.state.ui.mode,
|
| 279 |
+
});
|
| 280 |
+
} catch (error) {
|
| 281 |
+
removeCrashHandlers();
|
| 282 |
+
setCrashPhase('shutdown');
|
| 283 |
+
trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode });
|
| 284 |
+
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
| 285 |
+
await harness.close();
|
| 286 |
+
throw error;
|
| 287 |
+
}
|
| 288 |
+
}
|
apps/kimi-code/src/cli/startup-error.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { KIMI_ERROR_INFO, isKimiError } from '@moonshot-ai/kimi-code-sdk';
|
| 2 |
+
import { chalkStderr } from 'chalk';
|
| 3 |
+
|
| 4 |
+
import { STARTUP_ERROR_COLOR } from '#/constant/startup-error';
|
| 5 |
+
|
| 6 |
+
export interface StartupErrorFormatOptions {
|
| 7 |
+
readonly errorStyle?: (text: string) => string;
|
| 8 |
+
readonly operation?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function formatUnknownErrorMessage(error: unknown): string {
|
| 12 |
+
return error instanceof Error ? error.message : String(error);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function formatStartupError(
|
| 16 |
+
error: unknown,
|
| 17 |
+
options: StartupErrorFormatOptions = {},
|
| 18 |
+
): string {
|
| 19 |
+
const errorStyle = options.errorStyle ?? chalkStderr.hex(STARTUP_ERROR_COLOR);
|
| 20 |
+
|
| 21 |
+
if (!isKimiError(error)) {
|
| 22 |
+
const operation = options.operation ?? 'start shell';
|
| 23 |
+
return `${errorStyle(`error: failed to ${operation}: ${formatUnknownErrorMessage(error)}`)}\n`;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const info = KIMI_ERROR_INFO[error.code];
|
| 27 |
+
const lines = [
|
| 28 |
+
errorStyle(`error: ${info.title}`),
|
| 29 |
+
'',
|
| 30 |
+
errorStyle('message:'),
|
| 31 |
+
errorStyle(error.message),
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
return `${lines.join('\n')}\n`;
|
| 35 |
+
}
|
apps/kimi-code/src/cli/sub/acp.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `kimi acp` sub-command.
|
| 3 |
+
*
|
| 4 |
+
* Starts the Agent Client Protocol (ACP) server backed directly by the
|
| 5 |
+
* DI × Scope agent engine (`agent-core-v2`) over stdio, so ACP-compatible
|
| 6 |
+
* clients can drive a kimi-code session.
|
| 7 |
+
*
|
| 8 |
+
* Wire-up:
|
| 9 |
+
* - `--login` pivots into the shared device-code login flow (the entry point
|
| 10 |
+
* ACP clients hit via the first-class `AuthMethodTerminal` path, re-invoking
|
| 11 |
+
* the agent binary with the advertised `args:['--login']`).
|
| 12 |
+
* - `KIMI_CODE_HOME` (if set) is forwarded into `authMethods[0].env` so the
|
| 13 |
+
* login subprocess writes its token under the same data root the server
|
| 14 |
+
* reads from, and `process.argv[1]` is advertised as the legacy
|
| 15 |
+
* `_meta['terminal-auth'].command` fallback.
|
| 16 |
+
*
|
| 17 |
+
* `@moonshot-ai/acp-server` (and its `agent-core-v2` engine) is loaded via a
|
| 18 |
+
* lazy dynamic import so parsing the CLI does not initialize the ACP engine —
|
| 19 |
+
* mirroring the `kimi server run` v2 routing in `#/cli/sub/server/run.ts`.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
import type { Command } from 'commander';
|
| 23 |
+
|
| 24 |
+
import { getVersion } from '#/cli/version';
|
| 25 |
+
import { KIMI_CODE_HOME_ENV } from '#/constant/app';
|
| 26 |
+
import { getDataDir } from '#/utils/paths';
|
| 27 |
+
|
| 28 |
+
import { parseRegionFlag, runLoginFlow } from './login-flow';
|
| 29 |
+
|
| 30 |
+
export function registerAcpCommand(parent: Command): void {
|
| 31 |
+
parent
|
| 32 |
+
.command('acp')
|
| 33 |
+
.description('Run kimi-code as an Agent Client Protocol (ACP) server over stdio.')
|
| 34 |
+
.option(
|
| 35 |
+
'--login',
|
| 36 |
+
'Run the device-code login flow then exit (entry point for ACP terminal-auth).',
|
| 37 |
+
false,
|
| 38 |
+
)
|
| 39 |
+
.option('--region <region>', 'Login region used together with --login: "mainland-cn" (kimi.com) or "global" (kimi.ai).')
|
| 40 |
+
.action(async (opts: { login?: boolean; region?: string }) => {
|
| 41 |
+
if (opts.login === true) {
|
| 42 |
+
await runLoginFlow({
|
| 43 |
+
region: opts.region === undefined ? undefined : parseRegionFlag(opts.region),
|
| 44 |
+
});
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
// Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the
|
| 48 |
+
// login subprocess clients spawn for terminal-auth writes its token
|
| 49 |
+
// under the same data root the ACP server reads from.
|
| 50 |
+
const sandboxHome = process.env[KIMI_CODE_HOME_ENV];
|
| 51 |
+
const terminalAuthEnv =
|
| 52 |
+
sandboxHome !== undefined && sandboxHome.length > 0
|
| 53 |
+
? { [KIMI_CODE_HOME_ENV]: sandboxHome }
|
| 54 |
+
: undefined;
|
| 55 |
+
// Legacy `_meta.terminal-auth` fallback for clients that don't yet
|
| 56 |
+
// honor the first-class `type:'terminal'`. `command` is the absolute
|
| 57 |
+
// path to this very binary so the client can spawn it for login.
|
| 58 |
+
const legacyCommand = process.argv[1];
|
| 59 |
+
try {
|
| 60 |
+
const { runAcpServer } = await import('@moonshot-ai/acp-server');
|
| 61 |
+
await runAcpServer({
|
| 62 |
+
homeDir: getDataDir(),
|
| 63 |
+
agentInfo: { name: 'Kimi Code CLI', version: getVersion() },
|
| 64 |
+
...(terminalAuthEnv ? { terminalAuthEnv } : {}),
|
| 65 |
+
...(legacyCommand !== undefined && legacyCommand.length > 0
|
| 66 |
+
? { terminalAuthLegacyCommand: legacyCommand }
|
| 67 |
+
: {}),
|
| 68 |
+
});
|
| 69 |
+
process.exit(0);
|
| 70 |
+
} catch (error) {
|
| 71 |
+
process.stderr.write(`acp server: fatal error: ${String(error)}\n`);
|
| 72 |
+
process.exit(1);
|
| 73 |
+
}
|
| 74 |
+
});
|
| 75 |
+
}
|
apps/kimi-code/src/cli/sub/doctor.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { existsSync } from 'node:fs';
|
| 2 |
+
import { readFile } from 'node:fs/promises';
|
| 3 |
+
import { isAbsolute, resolve } from 'node:path';
|
| 4 |
+
|
| 5 |
+
import { resolveConfigPath, type KimiConfigValidationIssue } from '@moonshot-ai/kimi-code-sdk';
|
| 6 |
+
import type { Command } from 'commander';
|
| 7 |
+
import { z } from 'zod';
|
| 8 |
+
|
| 9 |
+
import { getTuiConfigPath, parseTuiConfig } from '#/tui/config';
|
| 10 |
+
|
| 11 |
+
interface WritableLike {
|
| 12 |
+
write(chunk: string): boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
type MaybePromise<T> = T | Promise<T>;
|
| 16 |
+
|
| 17 |
+
export interface DoctorDeps {
|
| 18 |
+
readonly cwd: () => string;
|
| 19 |
+
readonly defaultConfigPath: () => MaybePromise<string>;
|
| 20 |
+
readonly defaultTuiConfigPath: () => string;
|
| 21 |
+
readonly stdout: WritableLike;
|
| 22 |
+
readonly stderr: WritableLike;
|
| 23 |
+
readonly exit: (code: number) => never;
|
| 24 |
+
readonly fileExists?: (path: string) => boolean;
|
| 25 |
+
readonly readTextFile?: (path: string) => Promise<string>;
|
| 26 |
+
readonly validateConfigToml?: (text: string, path: string) => MaybePromise<string | void>;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface DoctorOptions {
|
| 30 |
+
readonly target?: 'config' | 'tui';
|
| 31 |
+
readonly path?: string;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface CheckSpec {
|
| 35 |
+
readonly label: 'config.toml' | 'tui.toml';
|
| 36 |
+
readonly path: string;
|
| 37 |
+
readonly explicit: boolean;
|
| 38 |
+
/** Throws on invalid content; may return a non-fatal warning message. */
|
| 39 |
+
readonly parse: (text: string, path: string) => MaybePromise<string | void>;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface CheckResult {
|
| 43 |
+
readonly label: CheckSpec['label'];
|
| 44 |
+
readonly path: string;
|
| 45 |
+
readonly status: 'OK' | 'SKIP' | 'ERROR';
|
| 46 |
+
readonly message?: string;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
interface ResolvedDoctorDeps {
|
| 50 |
+
readonly cwd: () => string;
|
| 51 |
+
readonly defaultConfigPath: () => MaybePromise<string>;
|
| 52 |
+
readonly defaultTuiConfigPath: () => string;
|
| 53 |
+
readonly stdout: WritableLike;
|
| 54 |
+
readonly stderr: WritableLike;
|
| 55 |
+
readonly exit: (code: number) => never;
|
| 56 |
+
readonly fileExists: (path: string) => boolean;
|
| 57 |
+
readonly readTextFile: (path: string) => Promise<string>;
|
| 58 |
+
readonly validateConfigToml: (text: string, path: string) => MaybePromise<string | void>;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise<number> {
|
| 62 |
+
const resolved = resolveDeps(deps);
|
| 63 |
+
const cwd = resolved.cwd();
|
| 64 |
+
const specs = await buildCheckSpecs(resolved, options, cwd);
|
| 65 |
+
const results = await Promise.all(specs.map((spec) => checkTomlFile(resolved, spec)));
|
| 66 |
+
|
| 67 |
+
const issueCount = results.filter((result) => result.status === 'ERROR').length;
|
| 68 |
+
const text = issueCount === 0 ? formatSuccess(results) : formatFailure(results, issueCount);
|
| 69 |
+
if (issueCount === 0) {
|
| 70 |
+
resolved.stdout.write(text);
|
| 71 |
+
} else {
|
| 72 |
+
resolved.stderr.write(text);
|
| 73 |
+
}
|
| 74 |
+
return issueCount === 0 ? 0 : 1;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export function registerDoctorCommand(parent: Command, deps?: Partial<DoctorDeps>): void {
|
| 78 |
+
const doctor = parent
|
| 79 |
+
.command('doctor')
|
| 80 |
+
.description('Validate Kimi Code configuration files.')
|
| 81 |
+
.action(async () => {
|
| 82 |
+
await runDoctorCommand(deps, {});
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
doctor
|
| 86 |
+
.command('config')
|
| 87 |
+
.description('Validate config.toml.')
|
| 88 |
+
.argument('[path]', 'Validate this file as config.toml instead of the default path.')
|
| 89 |
+
.action(async (path: string | undefined) => {
|
| 90 |
+
await runDoctorCommand(deps, { target: 'config', path });
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
doctor
|
| 94 |
+
.command('tui')
|
| 95 |
+
.description('Validate tui.toml.')
|
| 96 |
+
.argument('[path]', 'Validate this file as tui.toml instead of the default path.')
|
| 97 |
+
.action(async (path: string | undefined) => {
|
| 98 |
+
await runDoctorCommand(deps, { target: 'tui', path });
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
async function runDoctorCommand(
|
| 103 |
+
deps: Partial<DoctorDeps> | undefined,
|
| 104 |
+
options: DoctorOptions,
|
| 105 |
+
): Promise<void> {
|
| 106 |
+
const resolved = resolveDeps(deps);
|
| 107 |
+
const code = await handleDoctor(resolved, options);
|
| 108 |
+
if (code !== 0) resolved.exit(code);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function resolveDeps(deps: Partial<DoctorDeps> | DoctorDeps | undefined): ResolvedDoctorDeps {
|
| 112 |
+
return {
|
| 113 |
+
cwd: deps?.cwd ?? (() => process.cwd()),
|
| 114 |
+
defaultConfigPath: deps?.defaultConfigPath ?? (() => resolveConfigPath({})),
|
| 115 |
+
defaultTuiConfigPath: deps?.defaultTuiConfigPath ?? getTuiConfigPath,
|
| 116 |
+
stdout: deps?.stdout ?? process.stdout,
|
| 117 |
+
stderr: deps?.stderr ?? process.stderr,
|
| 118 |
+
exit: deps?.exit ?? ((code) => process.exit(code)),
|
| 119 |
+
fileExists: deps?.fileExists ?? existsSync,
|
| 120 |
+
readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')),
|
| 121 |
+
validateConfigToml:
|
| 122 |
+
deps?.validateConfigToml ??
|
| 123 |
+
(async (text, filePath) => {
|
| 124 |
+
const { validateConfigTomlV2 } = await import('../v2/validate-config');
|
| 125 |
+
return validateConfigTomlV2(text, filePath);
|
| 126 |
+
}),
|
| 127 |
+
};
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
async function buildCheckSpecs(
|
| 131 |
+
deps: ResolvedDoctorDeps,
|
| 132 |
+
options: DoctorOptions,
|
| 133 |
+
cwd: string,
|
| 134 |
+
): Promise<CheckSpec[]> {
|
| 135 |
+
if (options.target === 'config') {
|
| 136 |
+
return [
|
| 137 |
+
makeConfigSpec(
|
| 138 |
+
await resolveConfigTargetPath(deps, options.path, cwd),
|
| 139 |
+
options.path !== undefined,
|
| 140 |
+
deps,
|
| 141 |
+
),
|
| 142 |
+
];
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (options.target === 'tui') {
|
| 146 |
+
return [
|
| 147 |
+
makeTuiSpec(
|
| 148 |
+
resolveTuiTargetPath(deps, options.path, cwd),
|
| 149 |
+
options.path !== undefined,
|
| 150 |
+
),
|
| 151 |
+
];
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return [
|
| 155 |
+
makeConfigSpec(await deps.defaultConfigPath(), false, deps),
|
| 156 |
+
makeTuiSpec(deps.defaultTuiConfigPath(), false),
|
| 157 |
+
];
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function makeConfigSpec(
|
| 161 |
+
path: string,
|
| 162 |
+
explicit: boolean,
|
| 163 |
+
deps: ResolvedDoctorDeps,
|
| 164 |
+
): CheckSpec {
|
| 165 |
+
return {
|
| 166 |
+
label: 'config.toml',
|
| 167 |
+
path,
|
| 168 |
+
explicit,
|
| 169 |
+
parse: (text, filePath) => {
|
| 170 |
+
return deps.validateConfigToml(text, filePath);
|
| 171 |
+
},
|
| 172 |
+
};
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function makeTuiSpec(path: string, explicit: boolean): CheckSpec {
|
| 176 |
+
return {
|
| 177 |
+
label: 'tui.toml',
|
| 178 |
+
path,
|
| 179 |
+
explicit,
|
| 180 |
+
parse: (text) => {
|
| 181 |
+
parseTuiConfig(text);
|
| 182 |
+
},
|
| 183 |
+
};
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise<CheckResult> {
|
| 187 |
+
if (!deps.fileExists(spec.path)) {
|
| 188 |
+
return {
|
| 189 |
+
label: spec.label,
|
| 190 |
+
path: spec.path,
|
| 191 |
+
status: spec.explicit ? 'ERROR' : 'SKIP',
|
| 192 |
+
message: spec.explicit
|
| 193 |
+
? 'File does not exist.'
|
| 194 |
+
: 'File does not exist; built-in defaults will apply.',
|
| 195 |
+
};
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
const text = await deps.readTextFile(spec.path);
|
| 200 |
+
const warning = await spec.parse(text, spec.path);
|
| 201 |
+
return { label: spec.label, path: spec.path, status: 'OK', message: warning ?? undefined };
|
| 202 |
+
} catch (error) {
|
| 203 |
+
return {
|
| 204 |
+
label: spec.label,
|
| 205 |
+
path: spec.path,
|
| 206 |
+
status: 'ERROR',
|
| 207 |
+
message: formatErrorMessage(error, spec.path),
|
| 208 |
+
};
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
async function resolveConfigTargetPath(
|
| 213 |
+
deps: ResolvedDoctorDeps,
|
| 214 |
+
input: string | undefined,
|
| 215 |
+
cwd: string,
|
| 216 |
+
): Promise<string> {
|
| 217 |
+
return input === undefined ? deps.defaultConfigPath() : resolveInputPath(input, cwd);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function resolveTuiTargetPath(
|
| 221 |
+
deps: ResolvedDoctorDeps,
|
| 222 |
+
input: string | undefined,
|
| 223 |
+
cwd: string,
|
| 224 |
+
): string {
|
| 225 |
+
return input === undefined ? deps.defaultTuiConfigPath() : resolveInputPath(input, cwd);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function resolveInputPath(input: string, cwd: string): string {
|
| 229 |
+
return isAbsolute(input) ? input : resolve(cwd, input);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function formatSuccess(results: readonly CheckResult[]): string {
|
| 233 |
+
return [
|
| 234 |
+
'Kimi doctor',
|
| 235 |
+
'',
|
| 236 |
+
...formatResults(results),
|
| 237 |
+
'',
|
| 238 |
+
'All checked config files are valid.',
|
| 239 |
+
'',
|
| 240 |
+
].join('\n');
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function formatFailure(results: readonly CheckResult[], issueCount: number): string {
|
| 244 |
+
return [
|
| 245 |
+
`Kimi doctor found ${String(issueCount)} ${issueCount === 1 ? 'issue' : 'issues'}.`,
|
| 246 |
+
'',
|
| 247 |
+
...formatResults(results),
|
| 248 |
+
'',
|
| 249 |
+
].join('\n');
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function formatResults(results: readonly CheckResult[]): string[] {
|
| 253 |
+
const lines: string[] = [];
|
| 254 |
+
for (const result of results) {
|
| 255 |
+
lines.push(`${result.status} ${result.label.padEnd(12)} ${result.path}`);
|
| 256 |
+
if (result.message !== undefined) {
|
| 257 |
+
for (const line of result.message.split('\n')) {
|
| 258 |
+
lines.push(` ${line}`);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
return lines;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function formatErrorMessage(error: unknown, filePath: string): string {
|
| 266 |
+
const validationIssues = findValidationIssues(error);
|
| 267 |
+
if (validationIssues !== undefined) {
|
| 268 |
+
return [
|
| 269 |
+
`Invalid configuration in ${filePath}.`,
|
| 270 |
+
'Validation issues:',
|
| 271 |
+
...validationIssues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`),
|
| 272 |
+
].join('\n');
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const zodError = findZodError(error);
|
| 276 |
+
if (zodError !== undefined) {
|
| 277 |
+
return [
|
| 278 |
+
`Invalid configuration in ${filePath}.`,
|
| 279 |
+
'Validation issues:',
|
| 280 |
+
...zodError.issues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`),
|
| 281 |
+
].join('\n');
|
| 282 |
+
}
|
| 283 |
+
return error instanceof Error ? error.message : String(error);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function findValidationIssues(error: unknown): readonly KimiConfigValidationIssue[] | undefined {
|
| 287 |
+
if (!(error instanceof Error)) return undefined;
|
| 288 |
+
const details = 'details' in error ? error.details : undefined;
|
| 289 |
+
if (!isRecord(details)) return undefined;
|
| 290 |
+
const validationIssues = details['validationIssues'];
|
| 291 |
+
return isValidationIssueArray(validationIssues) ? validationIssues : undefined;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function isValidationIssueArray(value: unknown): value is readonly KimiConfigValidationIssue[] {
|
| 295 |
+
return Array.isArray(value) && value.every(isValidationIssue);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function isValidationIssue(value: unknown): value is KimiConfigValidationIssue {
|
| 299 |
+
if (!isRecord(value) || typeof value['message'] !== 'string') return false;
|
| 300 |
+
const path = value['path'];
|
| 301 |
+
return (
|
| 302 |
+
Array.isArray(path) &&
|
| 303 |
+
path.every((segment) => typeof segment === 'string' || typeof segment === 'number')
|
| 304 |
+
);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
| 308 |
+
return typeof value === 'object' && value !== null;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
function findZodError(error: unknown): z.ZodError | undefined {
|
| 312 |
+
if (error instanceof z.ZodError) return error;
|
| 313 |
+
if (error instanceof Error && error.cause instanceof z.ZodError) return error.cause;
|
| 314 |
+
return undefined;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function formatIssuePath(path: readonly PropertyKey[]): string {
|
| 318 |
+
if (path.length === 0) return '<root>';
|
| 319 |
+
|
| 320 |
+
let out = '';
|
| 321 |
+
for (const segment of path) {
|
| 322 |
+
if (typeof segment === 'number') {
|
| 323 |
+
out += `[${String(segment)}]`;
|
| 324 |
+
} else if (out.length === 0) {
|
| 325 |
+
out = camelToSnake(String(segment));
|
| 326 |
+
} else {
|
| 327 |
+
out += `.${camelToSnake(String(segment))}`;
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
return out;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function camelToSnake(value: string): string {
|
| 334 |
+
return value.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
| 335 |
+
}
|
apps/kimi-code/src/cli/sub/export.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `kimi export` sub-command.
|
| 3 |
+
*
|
| 4 |
+
* CLI glue only: session lookup, previous-session confirmation, and output.
|
| 5 |
+
* The actual ZIP/manifest export is owned by the SDK.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { createInterface } from 'node:readline/promises';
|
| 9 |
+
|
| 10 |
+
import {
|
| 11 |
+
setTelemetryContext,
|
| 12 |
+
shutdownTelemetry,
|
| 13 |
+
track,
|
| 14 |
+
withTelemetryContext,
|
| 15 |
+
} from '@moonshot-ai/kimi-telemetry';
|
| 16 |
+
import {
|
| 17 |
+
createKimiHarness,
|
| 18 |
+
type ExportSessionInput,
|
| 19 |
+
type ExportSessionResult,
|
| 20 |
+
type KimiHarness,
|
| 21 |
+
type SessionSummary,
|
| 22 |
+
type ShellEnvironment,
|
| 23 |
+
type TelemetryClient,
|
| 24 |
+
} from '@moonshot-ai/kimi-code-sdk';
|
| 25 |
+
import type { Command } from 'commander';
|
| 26 |
+
|
| 27 |
+
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
|
| 28 |
+
import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry';
|
| 29 |
+
import { detectInstallSource } from '#/cli/update/source';
|
| 30 |
+
import { createKimiCodeHostIdentity } from '#/cli/version';
|
| 31 |
+
import { detectShellEnvironment } from '#/utils/process/shell-env';
|
| 32 |
+
|
| 33 |
+
interface WritableLike {
|
| 34 |
+
write(chunk: string): boolean;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export interface PreviousSessionSummary {
|
| 38 |
+
readonly workDir: string;
|
| 39 |
+
readonly sessionId: string;
|
| 40 |
+
readonly sessionDir: string;
|
| 41 |
+
readonly title?: string | undefined;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export interface ExportDeps {
|
| 45 |
+
readonly listSessions: (workDir: string) => Promise<readonly SessionSummary[]>;
|
| 46 |
+
readonly exportSession: (input: ExportSessionInput) => Promise<ExportSessionResult>;
|
| 47 |
+
readonly confirmPreviousSession: (summary: PreviousSessionSummary) => Promise<boolean>;
|
| 48 |
+
readonly getInstallSource: () => Promise<string>;
|
| 49 |
+
readonly getShellEnv: () => ShellEnvironment;
|
| 50 |
+
readonly version: string;
|
| 51 |
+
readonly cwd: () => string;
|
| 52 |
+
readonly stdout: WritableLike;
|
| 53 |
+
readonly stderr: WritableLike;
|
| 54 |
+
readonly exit: (code: number) => never;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export interface ExportOptions {
|
| 58 |
+
readonly yes: boolean;
|
| 59 |
+
readonly includeGlobalLog: boolean;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export async function handleExport(
|
| 63 |
+
deps: ExportDeps,
|
| 64 |
+
sessionId: string | undefined,
|
| 65 |
+
output: string | undefined,
|
| 66 |
+
opts: ExportOptions,
|
| 67 |
+
): Promise<void> {
|
| 68 |
+
const requestedId = normalizeOptionalSessionId(sessionId);
|
| 69 |
+
const previousSummary = requestedId === undefined ? await findPreviousSession(deps) : undefined;
|
| 70 |
+
|
| 71 |
+
let resolvedId: string;
|
| 72 |
+
if (requestedId !== undefined) {
|
| 73 |
+
resolvedId = requestedId;
|
| 74 |
+
} else {
|
| 75 |
+
if (previousSummary === undefined) {
|
| 76 |
+
deps.stderr.write('No previous session found to export.\n');
|
| 77 |
+
deps.exit(1);
|
| 78 |
+
}
|
| 79 |
+
resolvedId = previousSummary.id;
|
| 80 |
+
if (!opts.yes) {
|
| 81 |
+
const confirmed = await deps.confirmPreviousSession(toPreviousSessionSummary(previousSummary));
|
| 82 |
+
if (!confirmed) {
|
| 83 |
+
deps.stdout.write('Export cancelled.\n');
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
const installSource = await deps.getInstallSource();
|
| 91 |
+
const shellEnv = deps.getShellEnv();
|
| 92 |
+
const result = await deps.exportSession({
|
| 93 |
+
id: resolvedId,
|
| 94 |
+
version: deps.version,
|
| 95 |
+
installSource,
|
| 96 |
+
shellEnv,
|
| 97 |
+
...(output === undefined ? {} : { outputPath: output }),
|
| 98 |
+
...(opts.includeGlobalLog ? { includeGlobalLog: true } : {}),
|
| 99 |
+
});
|
| 100 |
+
deps.stdout.write(`${result.zipPath}\n`);
|
| 101 |
+
} catch (error) {
|
| 102 |
+
deps.stderr.write(`${errorMessage(error)}\n`);
|
| 103 |
+
deps.exit(1);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export function registerExportCommand(parent: Command, deps?: Partial<ExportDeps>): void {
|
| 108 |
+
parent
|
| 109 |
+
.command('export')
|
| 110 |
+
.description('Export a session as a ZIP archive.')
|
| 111 |
+
.option('-o, --output <path>', 'Output ZIP path.')
|
| 112 |
+
.option('-y, --yes', 'Skip previous-session confirmation.')
|
| 113 |
+
.option(
|
| 114 |
+
'--no-include-global-log',
|
| 115 |
+
'Skip bundling the active global diagnostic log (~/.kimi-code/logs/kimi-code.log, not rotated .1 files). By default the global log is included.',
|
| 116 |
+
)
|
| 117 |
+
.argument('[sessionId]', 'Session id to export. Defaults to the most recent session.')
|
| 118 |
+
.action(
|
| 119 |
+
async (
|
| 120 |
+
sessionId: string | undefined,
|
| 121 |
+
options: { output?: string; yes?: boolean; includeGlobalLog?: boolean },
|
| 122 |
+
) => {
|
| 123 |
+
const resolved = createDefaultExportDeps(deps);
|
| 124 |
+
try {
|
| 125 |
+
await handleExport(resolved, sessionId, options.output, {
|
| 126 |
+
yes: options.yes === true,
|
| 127 |
+
includeGlobalLog: options.includeGlobalLog !== false,
|
| 128 |
+
});
|
| 129 |
+
} finally {
|
| 130 |
+
await resolved.close();
|
| 131 |
+
}
|
| 132 |
+
},
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function createDefaultExportDeps(overrides: Partial<ExportDeps> = {}): ExportDeps & {
|
| 137 |
+
readonly close: () => Promise<void>;
|
| 138 |
+
} {
|
| 139 |
+
let harness: KimiHarness | undefined;
|
| 140 |
+
let telemetryBootstrap: ReturnType<typeof createCliTelemetryBootstrap> | undefined;
|
| 141 |
+
let telemetryInitialized = false;
|
| 142 |
+
let telemetryShutdown = false;
|
| 143 |
+
const identity = createKimiCodeHostIdentity();
|
| 144 |
+
const telemetryClient: TelemetryClient = {
|
| 145 |
+
track,
|
| 146 |
+
withContext: withTelemetryContext,
|
| 147 |
+
setContext: setTelemetryContext,
|
| 148 |
+
};
|
| 149 |
+
const getTelemetryBootstrap = (): ReturnType<typeof createCliTelemetryBootstrap> => {
|
| 150 |
+
telemetryBootstrap ??= createCliTelemetryBootstrap();
|
| 151 |
+
return telemetryBootstrap;
|
| 152 |
+
};
|
| 153 |
+
const getHarness = (): KimiHarness => {
|
| 154 |
+
const currentTelemetryBootstrap = getTelemetryBootstrap();
|
| 155 |
+
harness ??= createKimiHarness({
|
| 156 |
+
homeDir: currentTelemetryBootstrap.homeDir,
|
| 157 |
+
identity,
|
| 158 |
+
telemetry: telemetryClient,
|
| 159 |
+
});
|
| 160 |
+
return harness;
|
| 161 |
+
};
|
| 162 |
+
const initializeDefaultTelemetry = async (): Promise<void> => {
|
| 163 |
+
if (telemetryInitialized) return;
|
| 164 |
+
const currentTelemetryBootstrap = getTelemetryBootstrap();
|
| 165 |
+
const currentHarness = getHarness();
|
| 166 |
+
await currentHarness.ensureConfigFile();
|
| 167 |
+
const config = await currentHarness.getConfig();
|
| 168 |
+
initializeCliTelemetry({
|
| 169 |
+
harness: currentHarness,
|
| 170 |
+
bootstrap: currentTelemetryBootstrap,
|
| 171 |
+
config,
|
| 172 |
+
version: identity.version,
|
| 173 |
+
uiMode: CLI_UI_MODE,
|
| 174 |
+
});
|
| 175 |
+
telemetryInitialized = true;
|
| 176 |
+
};
|
| 177 |
+
const shutdownDefaultTelemetry = async (): Promise<void> => {
|
| 178 |
+
if (!telemetryInitialized || telemetryShutdown) return;
|
| 179 |
+
telemetryShutdown = true;
|
| 180 |
+
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
| 181 |
+
};
|
| 182 |
+
return {
|
| 183 |
+
listSessions:
|
| 184 |
+
overrides.listSessions ??
|
| 185 |
+
((workDir: string) =>
|
| 186 |
+
getHarness().listSessions({
|
| 187 |
+
workDir,
|
| 188 |
+
})),
|
| 189 |
+
exportSession:
|
| 190 |
+
overrides.exportSession ??
|
| 191 |
+
(async (input: ExportSessionInput) => {
|
| 192 |
+
await initializeDefaultTelemetry();
|
| 193 |
+
try {
|
| 194 |
+
return await getHarness().exportSession(input);
|
| 195 |
+
} finally {
|
| 196 |
+
await shutdownDefaultTelemetry();
|
| 197 |
+
}
|
| 198 |
+
}),
|
| 199 |
+
version: overrides.version ?? identity.version,
|
| 200 |
+
getInstallSource: overrides.getInstallSource ?? (() => detectInstallSource()),
|
| 201 |
+
getShellEnv: overrides.getShellEnv ?? detectShellEnvironment,
|
| 202 |
+
confirmPreviousSession: overrides.confirmPreviousSession ?? confirmPreviousSession,
|
| 203 |
+
cwd: overrides.cwd ?? (() => process.cwd()),
|
| 204 |
+
stdout: overrides.stdout ?? process.stdout,
|
| 205 |
+
stderr: overrides.stderr ?? process.stderr,
|
| 206 |
+
exit: overrides.exit ?? ((code: number) => process.exit(code)),
|
| 207 |
+
// The v2 harness boots an engine whose watchers hold the event loop open;
|
| 208 |
+
// close it so a one-shot command can exit. No-op when the run never needed
|
| 209 |
+
// the harness.
|
| 210 |
+
close: async () => {
|
| 211 |
+
await harness?.close();
|
| 212 |
+
},
|
| 213 |
+
};
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
async function findPreviousSession(deps: Pick<ExportDeps, 'cwd' | 'listSessions'>): Promise<
|
| 217 |
+
SessionSummary | undefined
|
| 218 |
+
> {
|
| 219 |
+
const sessions = await deps.listSessions(deps.cwd());
|
| 220 |
+
return sessions[0];
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function toPreviousSessionSummary(summary: SessionSummary): PreviousSessionSummary {
|
| 224 |
+
return {
|
| 225 |
+
workDir: summary.workDir,
|
| 226 |
+
sessionId: summary.id,
|
| 227 |
+
sessionDir: summary.sessionDir,
|
| 228 |
+
...(summary.title === undefined ? {} : { title: summary.title }),
|
| 229 |
+
};
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined {
|
| 233 |
+
const trimmed = sessionId?.trim();
|
| 234 |
+
return trimmed === undefined || trimmed === '' ? undefined : trimmed;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
async function confirmPreviousSession(summary: PreviousSessionSummary): Promise<boolean> {
|
| 238 |
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
| 239 |
+
try {
|
| 240 |
+
const title = summary.title === undefined ? summary.sessionId : `${summary.title} (${summary.sessionId})`;
|
| 241 |
+
const answer = await rl.question(`Export previous session "${title}"? [Y/n] `);
|
| 242 |
+
const trimmed = answer.trim().toLowerCase();
|
| 243 |
+
return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
|
| 244 |
+
} finally {
|
| 245 |
+
rl.close();
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function errorMessage(error: unknown): string {
|
| 250 |
+
return error instanceof Error ? error.message : String(error);
|
| 251 |
+
}
|
apps/kimi-code/src/cli/sub/login.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `kimi login` — drive the OAuth device-code flow non-interactively.
|
| 3 |
+
* The `authMethods.terminal-auth.args=['login']` (legacy `_meta` path)
|
| 4 |
+
* advertised by the ACP server points clients at this entry point. The
|
| 5 |
+
* first-class ACP `args=['--login']` path enters the same flow via
|
| 6 |
+
* `kimi acp --login`.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import type { Command } from 'commander';
|
| 10 |
+
|
| 11 |
+
import { parseRegionFlag, runLoginFlow } from './login-flow';
|
| 12 |
+
|
| 13 |
+
export function registerLoginCommand(parent: Command): void {
|
| 14 |
+
parent
|
| 15 |
+
.command('login')
|
| 16 |
+
.description('Authenticate with Kimi Code CLI via the device-code flow.')
|
| 17 |
+
.option(
|
| 18 |
+
'--region <region>',
|
| 19 |
+
'Login region: "mainland-cn" (kimi.com) or "global" (kimi.ai).',
|
| 20 |
+
)
|
| 21 |
+
.action(async (opts: { region?: string }) => {
|
| 22 |
+
await runLoginFlow({
|
| 23 |
+
region: opts.region === undefined ? undefined : parseRegionFlag(opts.region),
|
| 24 |
+
});
|
| 25 |
+
});
|
| 26 |
+
}
|
apps/kimi-code/src/cli/sub/plugin-run-node.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { realpath } from 'node:fs/promises';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import { pathToFileURL } from 'node:url';
|
| 4 |
+
|
| 5 |
+
export async function runPluginNodeEntry(entry: string, args: readonly string[]): Promise<void> {
|
| 6 |
+
const pluginRoot = process.env['KIMI_PLUGIN_ROOT'];
|
| 7 |
+
if (pluginRoot === undefined || pluginRoot.trim().length === 0) {
|
| 8 |
+
throw new Error('KIMI_PLUGIN_ROOT is required to run a plugin node entry.');
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const [rootReal, entryReal] = await Promise.all([
|
| 12 |
+
realpath(pluginRoot),
|
| 13 |
+
realpath(entry),
|
| 14 |
+
]);
|
| 15 |
+
if (!isWithin(entryReal, rootReal)) {
|
| 16 |
+
throw new Error(`Plugin node entry must be inside KIMI_PLUGIN_ROOT: ${entry}`);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
process.argv = [process.argv[0] ?? process.execPath, entryReal, ...args];
|
| 20 |
+
await import(pathToFileURL(entryReal).href);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function isWithin(candidate: string, root: string): boolean {
|
| 24 |
+
const relative = path.relative(root, candidate);
|
| 25 |
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
| 26 |
+
}
|
apps/kimi-code/src/cli/sub/update-download.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hidden `kimi __update_download <version>` sub-command: the self-spawned
|
| 3 |
+
* worker behind native staged updates. Preflight spawns it detached in the
|
| 4 |
+
* background (and the `upgrade` command in the foreground); it downloads,
|
| 5 |
+
* verifies and stages the binary next to the running exe. The swap into
|
| 6 |
+
* place happens on the next startup (see `cli/update/native-swap.ts`).
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { log } from '@moonshot-ai/kimi-code-sdk';
|
| 10 |
+
|
| 11 |
+
import {
|
| 12 |
+
readUpdateInstallLockVersion,
|
| 13 |
+
tryAcquireUpdateInstallLock,
|
| 14 |
+
type UpdateInstallLockHandle,
|
| 15 |
+
} from '#/cli/update/install-lock';
|
| 16 |
+
import {
|
| 17 |
+
hashFileSha256,
|
| 18 |
+
promoteStagedUpdateToManual,
|
| 19 |
+
readStagedNativeUpdate,
|
| 20 |
+
stagedExePath,
|
| 21 |
+
stageNativeUpdate,
|
| 22 |
+
} from '#/cli/update/native-stage';
|
| 23 |
+
import { detectNativeInstall } from '#/cli/update/source';
|
| 24 |
+
|
| 25 |
+
const LOCK_HELD_POLL_INTERVAL_MS = 2_000;
|
| 26 |
+
|
| 27 |
+
type StagedUpdateWait =
|
| 28 |
+
| { readonly status: 'staged' }
|
| 29 |
+
| { readonly status: 'takeover'; readonly lock: UpdateInstallLockHandle | null };
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Another worker holds the install lock for the SAME version. Returning right
|
| 33 |
+
* away would report a success that has not happened yet — the in-flight
|
| 34 |
+
* download may still fail — so wait for it: 'staged' once its staged update is
|
| 35 |
+
* verified on disk; 'takeover' once the lock becomes acquirable, with the lock
|
| 36 |
+
* already held for the caller. The lock goes stale the moment its holder dies
|
| 37 |
+
* (see install-lock), so a killed downloader cannot strand a foreground
|
| 38 |
+
* `kimi upgrade` in this loop.
|
| 39 |
+
*
|
| 40 |
+
* Adoption applies the same integrity bar as stageNativeUpdate's
|
| 41 |
+
* already-staged path: the recorded size proves nothing, and the holder may
|
| 42 |
+
* still be RE-STAGING a same-size-corrupted payload (its metadata is only
|
| 43 |
+
* replaced when the new generation publishes). A recorded stage whose payload
|
| 44 |
+
* fails the checksum is treated as not-yet-staged — the lock poll below takes
|
| 45 |
+
* over once the holder finishes without repairing it.
|
| 46 |
+
*
|
| 47 |
+
* A manual (explicit-upgrade) waiter adopts only after CONFIRMING the manual
|
| 48 |
+
* marker landed on the stage — a concurrent startup swap may be claiming and
|
| 49 |
+
* restoring the metadata right now, and reporting adoption for a promotion
|
| 50 |
+
* that never persisted would strand the update under the env opt-out.
|
| 51 |
+
*/
|
| 52 |
+
async function waitForStagedUpdate(
|
| 53 |
+
version: string,
|
| 54 |
+
exePath: string,
|
| 55 |
+
manual: boolean,
|
| 56 |
+
): Promise<StagedUpdateWait> {
|
| 57 |
+
for (;;) {
|
| 58 |
+
const staged = await readStagedNativeUpdate(exePath);
|
| 59 |
+
const digest =
|
| 60 |
+
staged !== null && staged.version === version
|
| 61 |
+
? await hashFileSha256(stagedExePath(exePath, staged))
|
| 62 |
+
: null;
|
| 63 |
+
if (staged !== null && digest === staged.sha256) {
|
| 64 |
+
if (!manual || (await promoteStagedUpdateToManual(exePath, staged))) {
|
| 65 |
+
return { status: 'staged' };
|
| 66 |
+
}
|
| 67 |
+
// The stage is being claimed/restored by a concurrent swap — the next
|
| 68 |
+
// poll either promotes the restored stage or takes over once it is
|
| 69 |
+
// gone.
|
| 70 |
+
} else {
|
| 71 |
+
// Poll the acquisition itself: while the holder lives its lock stays
|
| 72 |
+
// fresh and this returns null without side effects; when the holder
|
| 73 |
+
// finishes (or dies) without staging a VERIFIED payload, the takeover
|
| 74 |
+
// happens right here.
|
| 75 |
+
const lock = await tryAcquireUpdateInstallLock({ version });
|
| 76 |
+
if (lock !== null) return { status: 'takeover', lock };
|
| 77 |
+
}
|
| 78 |
+
await new Promise((resolve) => {
|
| 79 |
+
setTimeout(resolve, LOCK_HELD_POLL_INTERVAL_MS);
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export async function runUpdateDownloadCommand(
|
| 85 |
+
version: string,
|
| 86 |
+
manual: boolean = false,
|
| 87 |
+
): Promise<number> {
|
| 88 |
+
if (!detectNativeInstall()) {
|
| 89 |
+
process.stderr.write('error: update download is only available in the native build\n');
|
| 90 |
+
return 1;
|
| 91 |
+
}
|
| 92 |
+
const out = process.stdout;
|
| 93 |
+
let lock = await tryAcquireUpdateInstallLock({ version });
|
| 94 |
+
if (lock === null) {
|
| 95 |
+
const holderVersion = await readUpdateInstallLockVersion();
|
| 96 |
+
if (holderVersion === version) {
|
| 97 |
+
// Another worker is already downloading this exact version: wait for it
|
| 98 |
+
// and adopt its verified result instead of exiting on a maybe.
|
| 99 |
+
out.write(
|
| 100 |
+
`A download of Kimi Code ${version} is already in progress; waiting for it to finish…\n`,
|
| 101 |
+
);
|
| 102 |
+
const wait = await waitForStagedUpdate(version, process.execPath, manual);
|
| 103 |
+
if (wait.status === 'staged') {
|
| 104 |
+
out.write(`Kimi Code ${version} is downloaded; it applies on the next start.\n`);
|
| 105 |
+
return 0;
|
| 106 |
+
}
|
| 107 |
+
// The holder finished without staging (failed or died): take over. The
|
| 108 |
+
// lock may already be held by another winner of the takeover race —
|
| 109 |
+
// the null check below reports that as held.
|
| 110 |
+
lock = wait.lock;
|
| 111 |
+
} else if (holderVersion === undefined) {
|
| 112 |
+
// The lock was released between the two reads — retry the acquire once.
|
| 113 |
+
lock = await tryAcquireUpdateInstallLock({ version });
|
| 114 |
+
}
|
| 115 |
+
if (lock === null) {
|
| 116 |
+
process.stderr.write(
|
| 117 |
+
`error: another update (${holderVersion ?? 'unknown version'}) is already downloading\n`,
|
| 118 |
+
);
|
| 119 |
+
return 1;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
const label = `Downloading Kimi Code ${version} (${process.platform}-${process.arch})…`;
|
| 123 |
+
const onProgress = createDownloadProgress(out, label);
|
| 124 |
+
try {
|
| 125 |
+
const result = await stageNativeUpdate({
|
| 126 |
+
version,
|
| 127 |
+
exePath: process.execPath,
|
| 128 |
+
onProgress,
|
| 129 |
+
manual,
|
| 130 |
+
});
|
| 131 |
+
if (out.isTTY) out.write('\n');
|
| 132 |
+
if (result.status === 'already-staged') {
|
| 133 |
+
out.write(`Kimi Code ${version} is already downloaded; it applies on the next start.\n`);
|
| 134 |
+
}
|
| 135 |
+
return 0;
|
| 136 |
+
} catch (error) {
|
| 137 |
+
if (out.isTTY) out.write('\n');
|
| 138 |
+
const message = error instanceof Error ? error.message : String(error);
|
| 139 |
+
process.stderr.write(`error: failed to download update ${version}: ${message}\n`);
|
| 140 |
+
log.warn('native update download failed', { version, error: message });
|
| 141 |
+
return 1;
|
| 142 |
+
} finally {
|
| 143 |
+
await lock.release().catch(() => {});
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const PROGRESS_FRAME_INTERVAL_MS = 100;
|
| 148 |
+
const PROGRESS_LINE_INTERVAL_BYTES = 32 * 1024 * 1024;
|
| 149 |
+
|
| 150 |
+
function formatDownloadProgress(label: string, downloaded: number, total: number | null): string {
|
| 151 |
+
const mb = Math.floor(downloaded / (1024 * 1024));
|
| 152 |
+
if (total === null || total <= 0) return `${label} ${mb} MB`;
|
| 153 |
+
const totalMb = Math.max(1, Math.round(total / (1024 * 1024)));
|
| 154 |
+
const percent = Math.min(100, Math.floor((downloaded / total) * 100));
|
| 155 |
+
return `${label} ${percent}% (${mb}/${totalMb} MB)`;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Download progress renderer for the (foreground) downloader: a single
|
| 160 |
+
* in-place line on a TTY (`\r` + clear-line, throttled to 10 fps, final frame
|
| 161 |
+
* always rendered), or one line per 32 MB when piped to a file. The caller
|
| 162 |
+
* owns the trailing newline.
|
| 163 |
+
*/
|
| 164 |
+
export function createDownloadProgress(
|
| 165 |
+
out: NodeJS.WriteStream,
|
| 166 |
+
label: string,
|
| 167 |
+
): (downloadedBytes: number, totalBytes: number | null) => void {
|
| 168 |
+
const isTTY = out.isTTY;
|
| 169 |
+
let lastFrameAt = 0;
|
| 170 |
+
let lastLineAt = 0;
|
| 171 |
+
if (!isTTY) out.write(`${label}\n`);
|
| 172 |
+
return (downloaded, total) => {
|
| 173 |
+
const done = total !== null && downloaded >= total;
|
| 174 |
+
if (isTTY) {
|
| 175 |
+
const now = Date.now();
|
| 176 |
+
if (!done && now - lastFrameAt < PROGRESS_FRAME_INTERVAL_MS) return;
|
| 177 |
+
lastFrameAt = now;
|
| 178 |
+
out.write(`\r\u001B[K${formatDownloadProgress(label, downloaded, total)}`);
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
if (!done && downloaded - lastLineAt < PROGRESS_LINE_INTERVAL_BYTES) return;
|
| 182 |
+
lastLineAt = downloaded;
|
| 183 |
+
out.write(`${formatDownloadProgress(label, downloaded, total)}\n`);
|
| 184 |
+
};
|
| 185 |
+
}
|
apps/kimi-code/src/cli/sub/vis.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `kimi vis` sub-command.
|
| 3 |
+
*
|
| 4 |
+
* CLI glue only: resolves the kimi home, starts the in-process session
|
| 5 |
+
* visualizer server (auto-picking a free port by default), prints the URL,
|
| 6 |
+
* optionally opens the browser (with an optional session deep-link), then
|
| 7 |
+
* waits for Ctrl-C and shuts the server down. The visualizer server itself
|
| 8 |
+
* lives in `@moonshot-ai/vis-server`.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import type { Command } from 'commander';
|
| 12 |
+
|
| 13 |
+
import { createCliTelemetryBootstrap } from '#/cli/telemetry';
|
| 14 |
+
import { openUrl } from '#/utils/open-url';
|
| 15 |
+
|
| 16 |
+
interface WritableLike {
|
| 17 |
+
write(chunk: string): boolean;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export interface StartedVisServer {
|
| 21 |
+
readonly port: number;
|
| 22 |
+
readonly host: string;
|
| 23 |
+
readonly url: string;
|
| 24 |
+
readonly close: () => Promise<void>;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface StartVisServerArgs {
|
| 28 |
+
readonly homeDir: string;
|
| 29 |
+
readonly port: number;
|
| 30 |
+
readonly host?: string;
|
| 31 |
+
readonly webAsset?: { gzipped: Uint8Array };
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface VisDeps {
|
| 35 |
+
readonly getHomeDir: () => string;
|
| 36 |
+
readonly startVisServer: (opts: StartVisServerArgs) => Promise<StartedVisServer>;
|
| 37 |
+
readonly openUrl: (url: string) => Promise<void>;
|
| 38 |
+
readonly waitForShutdown: () => Promise<void>;
|
| 39 |
+
readonly stdout: WritableLike;
|
| 40 |
+
readonly stderr: WritableLike;
|
| 41 |
+
readonly exit: (code: number) => never;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export interface VisOptions {
|
| 45 |
+
readonly open: boolean;
|
| 46 |
+
readonly port?: number;
|
| 47 |
+
readonly host?: string;
|
| 48 |
+
readonly sessionId?: string;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void> {
|
| 52 |
+
const homeDir = deps.getHomeDir();
|
| 53 |
+
|
| 54 |
+
// Lazily load the embedded single-file SPA so normal `kimi` startup never
|
| 55 |
+
// pays for it. The module is generated at build time (prebuild). When running
|
| 56 |
+
// from source without a build — e.g. tests — the generated value module is
|
| 57 |
+
// absent and the dynamic import throws; in that case the server falls back to
|
| 58 |
+
// its own static `public/` directory.
|
| 59 |
+
let webAsset: { gzipped: Uint8Array } | undefined;
|
| 60 |
+
try {
|
| 61 |
+
const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset');
|
| 62 |
+
if (VIS_WEB_GZIP_B64.length > 0) {
|
| 63 |
+
webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) };
|
| 64 |
+
}
|
| 65 |
+
} catch {
|
| 66 |
+
// Embedded asset not generated in this context — fall back to filesystem.
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
let server: StartedVisServer;
|
| 70 |
+
try {
|
| 71 |
+
server = await deps.startVisServer({
|
| 72 |
+
homeDir,
|
| 73 |
+
port: opts.port ?? 0,
|
| 74 |
+
...(opts.host === undefined ? {} : { host: opts.host }),
|
| 75 |
+
...(webAsset === undefined ? {} : { webAsset }),
|
| 76 |
+
});
|
| 77 |
+
} catch (error) {
|
| 78 |
+
const msg = error instanceof Error ? error.message : String(error);
|
| 79 |
+
deps.stderr.write(`Failed to start kimi vis: ${msg}\n`);
|
| 80 |
+
return deps.exit(1);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const target =
|
| 84 |
+
opts.sessionId === undefined
|
| 85 |
+
? server.url
|
| 86 |
+
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;
|
| 87 |
+
|
| 88 |
+
deps.stdout.write(`kimi vis is running at ${server.url}\n`);
|
| 89 |
+
deps.stdout.write('Press Ctrl-C to stop.\n');
|
| 90 |
+
|
| 91 |
+
if (opts.open) {
|
| 92 |
+
try {
|
| 93 |
+
await deps.openUrl(target);
|
| 94 |
+
} catch {
|
| 95 |
+
deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
await deps.waitForShutdown();
|
| 100 |
+
await server.close();
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export function registerVisCommand(parent: Command, overrides?: Partial<VisDeps>): void {
|
| 104 |
+
parent
|
| 105 |
+
.command('vis')
|
| 106 |
+
.description('Launch the session visualizer in your browser.')
|
| 107 |
+
.option('--port <number>', 'Port to bind. Default: auto-pick a free port.')
|
| 108 |
+
.option('--host <host>', 'Host to bind. Default: 127.0.0.1.')
|
| 109 |
+
.option('--no-open', 'Do not open the browser automatically.')
|
| 110 |
+
.argument('[sessionId]', 'Open directly to this session.')
|
| 111 |
+
.action(
|
| 112 |
+
async (
|
| 113 |
+
sessionId: string | undefined,
|
| 114 |
+
options: { port?: string; host?: string; open?: boolean },
|
| 115 |
+
) => {
|
| 116 |
+
const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10);
|
| 117 |
+
await handleVis(createDefaultVisDeps(overrides), {
|
| 118 |
+
open: options.open !== false,
|
| 119 |
+
...(port === undefined || Number.isNaN(port) ? {} : { port }),
|
| 120 |
+
...(options.host === undefined ? {} : { host: options.host }),
|
| 121 |
+
...(sessionId === undefined ? {} : { sessionId }),
|
| 122 |
+
});
|
| 123 |
+
},
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function createDefaultVisDeps(overrides: Partial<VisDeps> = {}): VisDeps {
|
| 128 |
+
return {
|
| 129 |
+
getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir),
|
| 130 |
+
startVisServer:
|
| 131 |
+
overrides.startVisServer ??
|
| 132 |
+
(async (opts) => {
|
| 133 |
+
// Dynamic import keeps the vis server (and Hono) out of the hot path.
|
| 134 |
+
const { startVisServer } = await import('@moonshot-ai/vis-server/start');
|
| 135 |
+
return startVisServer(opts);
|
| 136 |
+
}),
|
| 137 |
+
// `openUrl` is a synchronous fire-and-forget; adapt it to the async dep.
|
| 138 |
+
openUrl:
|
| 139 |
+
overrides.openUrl ??
|
| 140 |
+
(async (url: string) => {
|
| 141 |
+
openUrl(url);
|
| 142 |
+
}),
|
| 143 |
+
waitForShutdown: overrides.waitForShutdown ?? waitForSigint,
|
| 144 |
+
stdout: overrides.stdout ?? process.stdout,
|
| 145 |
+
stderr: overrides.stderr ?? process.stderr,
|
| 146 |
+
exit: overrides.exit ?? ((code: number) => process.exit(code)),
|
| 147 |
+
};
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function waitForSigint(): Promise<void> {
|
| 151 |
+
return new Promise<void>((resolve) => {
|
| 152 |
+
const onSig = (): void => {
|
| 153 |
+
process.off('SIGINT', onSig);
|
| 154 |
+
resolve();
|
| 155 |
+
};
|
| 156 |
+
process.on('SIGINT', onSig);
|
| 157 |
+
});
|
| 158 |
+
}
|
apps/kimi-code/src/cli/telemetry.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
|
| 2 |
+
import {
|
| 3 |
+
KimiAuthFacade,
|
| 4 |
+
loadRuntimeConfigSafe,
|
| 5 |
+
log,
|
| 6 |
+
resolveConfigPath,
|
| 7 |
+
resolveKimiHome,
|
| 8 |
+
type KimiConfig,
|
| 9 |
+
type TelemetryClient,
|
| 10 |
+
} from '@moonshot-ai/kimi-code-sdk';
|
| 11 |
+
|
| 12 |
+
import type { PromptHarness } from './prompt-session';
|
| 13 |
+
import {
|
| 14 |
+
initializeTelemetry,
|
| 15 |
+
setTelemetryContext,
|
| 16 |
+
track,
|
| 17 |
+
withTelemetryContext,
|
| 18 |
+
} from '@moonshot-ai/kimi-telemetry';
|
| 19 |
+
|
| 20 |
+
import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app';
|
| 21 |
+
import { currentKimiProfile } from '#/utils/region';
|
| 22 |
+
|
| 23 |
+
import { createKimiCodeHostIdentity } from './version';
|
| 24 |
+
|
| 25 |
+
export interface CliTelemetryBootstrap {
|
| 26 |
+
readonly homeDir: string;
|
| 27 |
+
readonly deviceId: string;
|
| 28 |
+
readonly firstLaunch: boolean;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export interface InitializeCliTelemetryOptions {
|
| 32 |
+
readonly harness: PromptHarness;
|
| 33 |
+
readonly bootstrap: CliTelemetryBootstrap;
|
| 34 |
+
readonly config: Pick<KimiConfig, 'defaultModel' | 'telemetry'>;
|
| 35 |
+
readonly version: string;
|
| 36 |
+
readonly uiMode: string;
|
| 37 |
+
readonly model?: string;
|
| 38 |
+
readonly sessionId?: string;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function createCliTelemetryBootstrap(): CliTelemetryBootstrap {
|
| 42 |
+
let firstLaunch = false;
|
| 43 |
+
const homeDir = resolveKimiHome();
|
| 44 |
+
const deviceId = createKimiDeviceId(homeDir, {
|
| 45 |
+
onFirstLaunch: () => {
|
| 46 |
+
firstLaunch = true;
|
| 47 |
+
},
|
| 48 |
+
});
|
| 49 |
+
return { homeDir, deviceId, firstLaunch };
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): void {
|
| 53 |
+
initializeTelemetry({
|
| 54 |
+
homeDir: options.harness.homeDir,
|
| 55 |
+
deviceId: options.bootstrap.deviceId,
|
| 56 |
+
enabled: options.config.telemetry !== false,
|
| 57 |
+
appName: CLI_USER_AGENT_PRODUCT,
|
| 58 |
+
version: options.version,
|
| 59 |
+
uiMode: options.uiMode,
|
| 60 |
+
model: options.model ?? options.config.defaultModel,
|
| 61 |
+
sessionId: options.sessionId,
|
| 62 |
+
endpoint: () => currentKimiProfile().telemetryEndpoint,
|
| 63 |
+
getAccessToken: async () =>
|
| 64 |
+
(await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
|
| 65 |
+
onUnexpectedError: (error) => log.warn('telemetry property dropped', { error: String(error) }),
|
| 66 |
+
});
|
| 67 |
+
if (options.bootstrap.firstLaunch) {
|
| 68 |
+
options.harness.track('first_launch');
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export interface InitializeServerTelemetryOptions {
|
| 73 |
+
readonly version: string;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Bootstrap telemetry for the `kimi web` host.
|
| 78 |
+
*
|
| 79 |
+
* Mirrors {@link initializeCliTelemetry}: mints the device id, reads config to
|
| 80 |
+
* honor the `telemetry` toggle and pick up the default model, and attaches the
|
| 81 |
+
* sink with `ui_mode = "web"`.
|
| 82 |
+
*
|
| 83 |
+
* The returned client wraps the `@moonshot-ai/kimi-telemetry` module
|
| 84 |
+
* functions, so the module-level `track` / `withTelemetryContext` (used to
|
| 85 |
+
* fire the startup event) share the same underlying client + sink.
|
| 86 |
+
*/
|
| 87 |
+
export function initializeServerTelemetry(
|
| 88 |
+
options: InitializeServerTelemetryOptions,
|
| 89 |
+
): TelemetryClient {
|
| 90 |
+
const bootstrap = createCliTelemetryBootstrap();
|
| 91 |
+
const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir });
|
| 92 |
+
const config = readServerTelemetryConfig(configPath);
|
| 93 |
+
const auth = new KimiAuthFacade({
|
| 94 |
+
homeDir: bootstrap.homeDir,
|
| 95 |
+
configPath,
|
| 96 |
+
identity: createKimiCodeHostIdentity(options.version),
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
initializeTelemetry({
|
| 100 |
+
homeDir: bootstrap.homeDir,
|
| 101 |
+
deviceId: bootstrap.deviceId,
|
| 102 |
+
enabled: config.telemetry !== false,
|
| 103 |
+
appName: CLI_USER_AGENT_PRODUCT,
|
| 104 |
+
version: options.version,
|
| 105 |
+
uiMode: WEB_UI_MODE,
|
| 106 |
+
model: config.defaultModel,
|
| 107 |
+
endpoint: () => currentKimiProfile().telemetryEndpoint,
|
| 108 |
+
getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
|
| 109 |
+
onUnexpectedError: (error) => log.warn('telemetry property dropped', { error: String(error) }),
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
track,
|
| 114 |
+
withContext: withTelemetryContext,
|
| 115 |
+
setContext: setTelemetryContext,
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function readServerTelemetryConfig(
|
| 120 |
+
configPath: string,
|
| 121 |
+
): Pick<KimiConfig, 'telemetry' | 'defaultModel'> {
|
| 122 |
+
try {
|
| 123 |
+
const { config, fileError } = loadRuntimeConfigSafe(configPath);
|
| 124 |
+
// A broken config fails the server on its own inside KimiCore; for
|
| 125 |
+
// telemetry just degrade to "enabled, no model" so we never block startup.
|
| 126 |
+
if (fileError !== undefined) return {};
|
| 127 |
+
return config;
|
| 128 |
+
} catch {
|
| 129 |
+
return {};
|
| 130 |
+
}
|
| 131 |
+
}
|
apps/kimi-code/src/cli/update/cache.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
|
| 3 |
+
import { getUpdateStateFile } from '#/utils/paths';
|
| 4 |
+
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
|
| 5 |
+
|
| 6 |
+
import { UpdateManifestSchema } from './cdn';
|
| 7 |
+
import { emptyUpdateCache, type UpdateCache } from './types';
|
| 8 |
+
|
| 9 |
+
// Stays `.strict()` (we own this file), but a malformed manifest is treated
|
| 10 |
+
// as no manifest so one bad optional field does not discard the whole cache.
|
| 11 |
+
const UpdateCacheSchema = z
|
| 12 |
+
.object({
|
| 13 |
+
source: z.literal('cdn'),
|
| 14 |
+
checkedAt: z.string().min(1).nullable(),
|
| 15 |
+
latest: z.string().min(1).nullable(),
|
| 16 |
+
manifest: z.preprocess((value) => {
|
| 17 |
+
const parsed = UpdateManifestSchema.nullable().safeParse(value === undefined ? null : value);
|
| 18 |
+
return parsed.success ? parsed.data : null;
|
| 19 |
+
}, z.union([UpdateManifestSchema, z.null()])),
|
| 20 |
+
})
|
| 21 |
+
.strict();
|
| 22 |
+
|
| 23 |
+
export async function readUpdateCache(
|
| 24 |
+
filePath: string = getUpdateStateFile(),
|
| 25 |
+
): Promise<UpdateCache> {
|
| 26 |
+
try {
|
| 27 |
+
return await readJsonFile(filePath, UpdateCacheSchema, emptyUpdateCache());
|
| 28 |
+
} catch {
|
| 29 |
+
return emptyUpdateCache();
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export async function writeUpdateCache(
|
| 34 |
+
value: UpdateCache,
|
| 35 |
+
filePath: string = getUpdateStateFile(),
|
| 36 |
+
): Promise<void> {
|
| 37 |
+
await writeJsonFile(filePath, UpdateCacheSchema, value);
|
| 38 |
+
}
|
apps/kimi-code/src/cli/update/cdn.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { valid } from 'semver';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
|
| 4 |
+
import { kimiCodeCdnLatestJsonUrl, kimiCodeCdnLatestUrl } from '#/constant/app';
|
| 5 |
+
|
| 6 |
+
import type { UpdateManifest } from './types';
|
| 7 |
+
|
| 8 |
+
const CDN_FETCH_TIMEOUT_MS = 3_000;
|
| 9 |
+
|
| 10 |
+
const RolloutBatchSchema = z.object({
|
| 11 |
+
percent: z.number().int().min(0).max(100),
|
| 12 |
+
delaySeconds: z.number().int().min(0),
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown
|
| 17 |
+
* fields are ignored so future manifest additions never break shipped
|
| 18 |
+
* clients (the plain-text `/latest` taught us that hard-failing on
|
| 19 |
+
* unexpected content bricks the update path forever).
|
| 20 |
+
*/
|
| 21 |
+
export const UpdateManifestSchema = z.object({
|
| 22 |
+
version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
|
| 23 |
+
publishedAt: z
|
| 24 |
+
.string()
|
| 25 |
+
.refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }),
|
| 26 |
+
rollout: z.array(RolloutBatchSchema).readonly().default([]),
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
export interface FetchLatestResult {
|
| 30 |
+
/** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */
|
| 31 |
+
readonly latest: string;
|
| 32 |
+
/** Null when the JSON manifest was unavailable and we fell back to plain text. */
|
| 33 |
+
readonly manifest: UpdateManifest | null;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function fetchWithTimeout(
|
| 37 |
+
fetchImpl: typeof fetch,
|
| 38 |
+
input: string,
|
| 39 |
+
timeoutMs: number,
|
| 40 |
+
): Promise<Response> {
|
| 41 |
+
const controller = new AbortController();
|
| 42 |
+
const timeout = setTimeout(() => {
|
| 43 |
+
controller.abort();
|
| 44 |
+
}, timeoutMs);
|
| 45 |
+
try {
|
| 46 |
+
return await fetchImpl(input, { signal: controller.signal });
|
| 47 |
+
} finally {
|
| 48 |
+
clearTimeout(timeout);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Fetch the latest published Kimi Code version from the CDN.
|
| 54 |
+
*
|
| 55 |
+
* **Throws** on any failure (network error, non-2xx, empty body, non-semver
|
| 56 |
+
* text). Callers must catch — `refreshUpdateCache` deliberately lets the
|
| 57 |
+
* error propagate so the existing cache stays intact instead of being
|
| 58 |
+
* overwritten with a null `latest` on a transient blip.
|
| 59 |
+
*
|
| 60 |
+
* `fetchImpl` is injectable for tests; defaults to the global `fetch`.
|
| 61 |
+
*/
|
| 62 |
+
export async function fetchLatestVersionFromCdn(
|
| 63 |
+
fetchImpl: typeof fetch = fetch,
|
| 64 |
+
timeoutMs: number = CDN_FETCH_TIMEOUT_MS,
|
| 65 |
+
): Promise<string> {
|
| 66 |
+
const response = await fetchWithTimeout(fetchImpl, kimiCodeCdnLatestUrl(), timeoutMs);
|
| 67 |
+
if (!response.ok) {
|
| 68 |
+
throw new Error(`CDN /latest returned HTTP ${response.status}`);
|
| 69 |
+
}
|
| 70 |
+
const raw = (await response.text()).trim();
|
| 71 |
+
if (valid(raw) === null) {
|
| 72 |
+
throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`);
|
| 73 |
+
}
|
| 74 |
+
return raw;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async function fetchUpdateManifestFromCdn(
|
| 78 |
+
fetchImpl: typeof fetch,
|
| 79 |
+
timeoutMs: number,
|
| 80 |
+
): Promise<UpdateManifest> {
|
| 81 |
+
const response = await fetchWithTimeout(fetchImpl, kimiCodeCdnLatestJsonUrl(), timeoutMs);
|
| 82 |
+
if (!response.ok) {
|
| 83 |
+
throw new Error(`CDN /latest.json returned HTTP ${response.status}`);
|
| 84 |
+
}
|
| 85 |
+
return UpdateManifestSchema.parse(JSON.parse(await response.text()));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Fetch the rollout manifest, falling back to the plain-text `/latest` when
|
| 90 |
+
* `latest.json` is unavailable or malformed. The fallback removes any
|
| 91 |
+
* deployment-order coupling between client releases and the CDN file, and a
|
| 92 |
+
* null manifest means "fully rolled out" — exactly the pre-rollout behavior.
|
| 93 |
+
*
|
| 94 |
+
* **Throws** only when both sources fail; callers must catch (see above).
|
| 95 |
+
*/
|
| 96 |
+
export async function fetchLatestFromCdn(
|
| 97 |
+
fetchImpl: typeof fetch = fetch,
|
| 98 |
+
timeoutMs: number = CDN_FETCH_TIMEOUT_MS,
|
| 99 |
+
): Promise<FetchLatestResult> {
|
| 100 |
+
const manifest = await fetchUpdateManifestFromCdn(fetchImpl, timeoutMs).catch(() => null);
|
| 101 |
+
if (manifest !== null) {
|
| 102 |
+
return { latest: manifest.version, manifest };
|
| 103 |
+
}
|
| 104 |
+
const latest = await fetchLatestVersionFromCdn(fetchImpl, timeoutMs);
|
| 105 |
+
return { latest, manifest: null };
|
| 106 |
+
}
|
apps/kimi-code/src/cli/update/install-lock.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdir, readFile, stat, unlink } from 'node:fs/promises';
|
| 2 |
+
import { dirname } from 'node:path';
|
| 3 |
+
|
| 4 |
+
import { getUpdateInstallLockFile } from '#/utils/paths';
|
| 5 |
+
import { createFileIfAbsent } from '#/utils/persistence';
|
| 6 |
+
|
| 7 |
+
const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000;
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* A takeover's critical section is a few syscalls (microseconds), so a
|
| 11 |
+
* takeover lock older than this is crash residue and may be swept freely.
|
| 12 |
+
*/
|
| 13 |
+
const TAKEOVER_LOCK_STALE_MS = 60_000;
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* On filesystems without hard links the lock is published by an exclusive
|
| 17 |
+
* create + write (see createFileIfAbsent), which IS observable between create
|
| 18 |
+
* and write. A young unparseable lock is almost always that publish window,
|
| 19 |
+
* not corruption — only an unparseable lock older than this is swept as
|
| 20 |
+
* crash residue.
|
| 21 |
+
*/
|
| 22 |
+
const LOCK_PUBLISH_GRACE_MS = 60_000;
|
| 23 |
+
|
| 24 |
+
export interface UpdateInstallLockRequest {
|
| 25 |
+
readonly version: string;
|
| 26 |
+
readonly now?: Date;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface UpdateInstallLockHandle {
|
| 30 |
+
readonly filePath: string;
|
| 31 |
+
/** The exact contents this handle published — its ownership identity. */
|
| 32 |
+
readonly content: string;
|
| 33 |
+
release(): Promise<void>;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function isNotFound(error: unknown): boolean {
|
| 37 |
+
return (
|
| 38 |
+
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT'
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function isAlreadyExists(error: unknown): boolean {
|
| 43 |
+
return (
|
| 44 |
+
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST'
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Liveness probe for the lock holder. Signal 0 delivers nothing; ESRCH means
|
| 50 |
+
* the process is gone, EPERM means it exists but may not be signalled — which
|
| 51 |
+
* still counts as alive.
|
| 52 |
+
*/
|
| 53 |
+
function isProcessAlive(pid: number): boolean {
|
| 54 |
+
try {
|
| 55 |
+
process.kill(pid, 0);
|
| 56 |
+
return true;
|
| 57 |
+
} catch (error) {
|
| 58 |
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
interface LockInspection {
|
| 63 |
+
readonly content: string;
|
| 64 |
+
readonly mtimeMs: number;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/** Read the lock file's content and mtime; null when it is gone/unreadable. */
|
| 68 |
+
async function inspectLockFile(filePath: string): Promise<LockInspection | null> {
|
| 69 |
+
const content = await readFile(filePath, 'utf-8').catch(() => null);
|
| 70 |
+
if (content === null) return null;
|
| 71 |
+
const info = await stat(filePath).catch(() => null);
|
| 72 |
+
if (info === null) return null;
|
| 73 |
+
return { content, mtimeMs: info.mtimeMs };
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Staleness check over the lock file's CONTENTS. Shapeless content counts as
|
| 78 |
+
* stale (crash residue). Unparseable content is also crash residue — but only
|
| 79 |
+
* once it is older than the publish grace: on filesystems without hard links
|
| 80 |
+
* a fallback publish is observable mid-write (see LOCK_PUBLISH_GRACE_MS), and
|
| 81 |
+
* sweeping that window would break exclusivity. A holder that is gone can
|
| 82 |
+
* never release its lock (a killed process skips its finally) nor make
|
| 83 |
+
* progress — stale at ANY age; the atomic publish guarantees the pid was
|
| 84 |
+
* written complete by a then-live process, so a dead pid means the holder
|
| 85 |
+
* died afterwards. Past the age threshold a LIVE holder still survives: a
|
| 86 |
+
* native download is idle-bounded but intentionally not duration-bounded, so
|
| 87 |
+
* a slow link legitimately exceeds it. (A pid reused by an unrelated process
|
| 88 |
+
* can pin the lock until that process exits — a delayed update, never a
|
| 89 |
+
* corrupt one.)
|
| 90 |
+
*/
|
| 91 |
+
function isStaleLock(inspection: LockInspection, now: Date): boolean {
|
| 92 |
+
let parsed: unknown;
|
| 93 |
+
try {
|
| 94 |
+
parsed = JSON.parse(inspection.content);
|
| 95 |
+
} catch {
|
| 96 |
+
return now.getTime() - inspection.mtimeMs > LOCK_PUBLISH_GRACE_MS;
|
| 97 |
+
}
|
| 98 |
+
if (typeof parsed !== 'object' || parsed === null) return true;
|
| 99 |
+
const lock = parsed as { readonly startedAt?: unknown; readonly pid?: unknown };
|
| 100 |
+
if (typeof lock.startedAt !== 'string') return true;
|
| 101 |
+
const startedAt = Date.parse(lock.startedAt);
|
| 102 |
+
if (!Number.isFinite(startedAt)) return true;
|
| 103 |
+
if (typeof lock.pid === 'number' && !isProcessAlive(lock.pid)) return true;
|
| 104 |
+
if (now.getTime() - startedAt <= UPDATE_INSTALL_LOCK_STALE_MS) return false;
|
| 105 |
+
return typeof lock.pid !== 'number';
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async function createLockFile(
|
| 109 |
+
filePath: string,
|
| 110 |
+
request: UpdateInstallLockRequest,
|
| 111 |
+
): Promise<UpdateInstallLockHandle | null> {
|
| 112 |
+
const now = request.now ?? new Date();
|
| 113 |
+
const content = `${JSON.stringify({
|
| 114 |
+
version: request.version,
|
| 115 |
+
pid: process.pid,
|
| 116 |
+
startedAt: now.toISOString(),
|
| 117 |
+
}, null, 2)}\n`;
|
| 118 |
+
// Publish atomically and only into a still-free path (EEXIST propagates to
|
| 119 |
+
// the caller's inspection flow). The lock file is never observable empty
|
| 120 |
+
// on filesystems with hard links; elsewhere the exclusive-create fallback
|
| 121 |
+
// leaves a brief publish window, which the inspection side covers with
|
| 122 |
+
// LOCK_PUBLISH_GRACE_MS.
|
| 123 |
+
await createFileIfAbsent(filePath, content);
|
| 124 |
+
// A racing stale-takeover may have removed our just-published lock and
|
| 125 |
+
// published its own; only the survivor may proceed.
|
| 126 |
+
const published = await readFile(filePath, 'utf-8').catch(() => null);
|
| 127 |
+
if (published !== content) return null;
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
filePath,
|
| 131 |
+
content,
|
| 132 |
+
release: async (): Promise<void> => {
|
| 133 |
+
// Release only the lock instance we own: a stale takeover may have
|
| 134 |
+
// replaced the file since we published it.
|
| 135 |
+
const current = await readFile(filePath, 'utf-8').catch(() => null);
|
| 136 |
+
if (current !== content) return;
|
| 137 |
+
await unlink(filePath).catch((error: unknown) => {
|
| 138 |
+
if (!isNotFound(error)) throw error;
|
| 139 |
+
});
|
| 140 |
+
},
|
| 141 |
+
};
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
export async function tryAcquireUpdateInstallLock(
|
| 145 |
+
request: UpdateInstallLockRequest,
|
| 146 |
+
filePath: string = getUpdateInstallLockFile(),
|
| 147 |
+
): Promise<UpdateInstallLockHandle | null> {
|
| 148 |
+
await mkdir(dirname(filePath), { recursive: true });
|
| 149 |
+
try {
|
| 150 |
+
return await createLockFile(filePath, request);
|
| 151 |
+
} catch (error) {
|
| 152 |
+
if (!isAlreadyExists(error)) throw error;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// A lock file exists. Inspect it once to decide whether it is stale.
|
| 156 |
+
const inspected = await inspectLockFile(filePath);
|
| 157 |
+
if (inspected !== null && !isStaleLock(inspected, request.now ?? new Date())) {
|
| 158 |
+
return null;
|
| 159 |
+
}
|
| 160 |
+
if (inspected === null) {
|
| 161 |
+
// Vanished between create and read — retry the create once.
|
| 162 |
+
try {
|
| 163 |
+
return await createLockFile(filePath, request);
|
| 164 |
+
} catch (error) {
|
| 165 |
+
if (isAlreadyExists(error)) return null;
|
| 166 |
+
throw error;
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// Stale lock. A pathname-level delete can never be conditioned on the file
|
| 171 |
+
// still being the inspected instance, so delete+publish MUST NOT run
|
| 172 |
+
// concurrently: serialize takeovers through a secondary create-if-absent
|
| 173 |
+
// lock and re-validate staleness inside that section.
|
| 174 |
+
const takeoverPath = `${filePath}.takeover`;
|
| 175 |
+
if (!(await acquireTakeoverLock(takeoverPath))) return null;
|
| 176 |
+
try {
|
| 177 |
+
const current = await inspectLockFile(filePath);
|
| 178 |
+
if (current !== null && !isStaleLock(current, request.now ?? new Date())) {
|
| 179 |
+
// A fresh lock appeared while we waited for the takeover section.
|
| 180 |
+
return null;
|
| 181 |
+
}
|
| 182 |
+
if (current !== null) {
|
| 183 |
+
await unlink(filePath).catch(() => {});
|
| 184 |
+
}
|
| 185 |
+
try {
|
| 186 |
+
// A fast-path creator may still win the briefly-free path — its lock is
|
| 187 |
+
// legitimate (the path really was free), we simply lose.
|
| 188 |
+
return await createLockFile(filePath, request);
|
| 189 |
+
} catch (error) {
|
| 190 |
+
if (isAlreadyExists(error)) return null;
|
| 191 |
+
throw error;
|
| 192 |
+
}
|
| 193 |
+
} finally {
|
| 194 |
+
await unlink(takeoverPath).catch(() => {});
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/**
|
| 199 |
+
* The takeover lock serializes stale-lock recovery. create-if-absent via the
|
| 200 |
+
* shared primitive (hard link, or an exclusive create where unsupported); an
|
| 201 |
+
* ancient holder is crash residue (a live section lasts microseconds) and is
|
| 202 |
+
* swept, then retried once.
|
| 203 |
+
*/
|
| 204 |
+
async function acquireTakeoverLock(takeoverPath: string): Promise<boolean> {
|
| 205 |
+
if (await publishTakeoverMarker(takeoverPath)) return true;
|
| 206 |
+
const info = await stat(takeoverPath).catch(() => null);
|
| 207 |
+
if (info !== null && Date.now() - info.mtimeMs <= TAKEOVER_LOCK_STALE_MS) return false;
|
| 208 |
+
await unlink(takeoverPath).catch(() => {});
|
| 209 |
+
return publishTakeoverMarker(takeoverPath);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/** Create-if-absent publish of a small lock marker file. */
|
| 213 |
+
async function publishTakeoverMarker(target: string): Promise<boolean> {
|
| 214 |
+
// Unique marker content doubles as the ownership identity below.
|
| 215 |
+
const marker = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
| 216 |
+
try {
|
| 217 |
+
await createFileIfAbsent(target, marker);
|
| 218 |
+
} catch (error) {
|
| 219 |
+
if (isAlreadyExists(error)) return false;
|
| 220 |
+
throw error;
|
| 221 |
+
}
|
| 222 |
+
// The stale-marker sweep races this publish: it may unlink our fresh marker
|
| 223 |
+
// and publish its own. Verify ownership so only the survivor of that race
|
| 224 |
+
// proceeds. (A delete landing after this read is the irreducible residual
|
| 225 |
+
// of pathname-only locking — there is no conditional-delete syscall; its
|
| 226 |
+
// worst case is a duplicated download cycle, never a corrupt install,
|
| 227 |
+
// because swap claims guard the executable independently.)
|
| 228 |
+
const published = await readFile(target, 'utf-8').catch(() => null);
|
| 229 |
+
return published === marker;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/**
|
| 233 |
+
* Return the version recorded in the held lock file, or undefined when the
|
| 234 |
+
* lock is gone or unreadable. Lets a downloader that failed to acquire the
|
| 235 |
+
* lock distinguish "another instance is staging the SAME version" (its
|
| 236 |
+
* outcome is ours — report success) from "a different version is in flight"
|
| 237 |
+
* (must not be reported as success to a foreground `kimi upgrade`).
|
| 238 |
+
*/
|
| 239 |
+
export async function readUpdateInstallLockVersion(
|
| 240 |
+
filePath: string = getUpdateInstallLockFile(),
|
| 241 |
+
): Promise<string | undefined> {
|
| 242 |
+
let raw: string;
|
| 243 |
+
try {
|
| 244 |
+
raw = await readFile(filePath, 'utf-8');
|
| 245 |
+
} catch {
|
| 246 |
+
return undefined;
|
| 247 |
+
}
|
| 248 |
+
try {
|
| 249 |
+
const version: unknown = (JSON.parse(raw) as { version?: unknown }).version;
|
| 250 |
+
return typeof version === 'string' && version.length > 0 ? version : undefined;
|
| 251 |
+
} catch {
|
| 252 |
+
return undefined;
|
| 253 |
+
}
|
| 254 |
+
}
|
apps/kimi-code/src/cli/update/install-state.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
|
| 3 |
+
import { getUpdateInstallStateFile } from '#/utils/paths';
|
| 4 |
+
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
|
| 5 |
+
|
| 6 |
+
import { emptyUpdateInstallState, type InstallSource, type UpdateInstallState } from './types';
|
| 7 |
+
|
| 8 |
+
const InstallSourceSchema: z.ZodType<InstallSource> = z.enum([
|
| 9 |
+
'npm-global',
|
| 10 |
+
'pnpm-global',
|
| 11 |
+
'yarn-global',
|
| 12 |
+
'bun-global',
|
| 13 |
+
'homebrew',
|
| 14 |
+
'native',
|
| 15 |
+
'unsupported',
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
const UpdateInstallStateSchema: z.ZodType<UpdateInstallState> = z
|
| 19 |
+
.object({
|
| 20 |
+
active: z
|
| 21 |
+
.object({
|
| 22 |
+
version: z.string().min(1),
|
| 23 |
+
source: InstallSourceSchema,
|
| 24 |
+
startedAt: z.string().min(1),
|
| 25 |
+
})
|
| 26 |
+
.strict()
|
| 27 |
+
.nullable(),
|
| 28 |
+
lastFailure: z
|
| 29 |
+
.object({
|
| 30 |
+
version: z.string().min(1),
|
| 31 |
+
failedAt: z.string().min(1),
|
| 32 |
+
attempts: z.number().int().min(1),
|
| 33 |
+
})
|
| 34 |
+
.strict()
|
| 35 |
+
.nullable(),
|
| 36 |
+
lastSuccess: z
|
| 37 |
+
.object({
|
| 38 |
+
version: z.string().min(1),
|
| 39 |
+
installedAt: z.string().min(1),
|
| 40 |
+
notifiedAt: z.string().min(1).nullable(),
|
| 41 |
+
})
|
| 42 |
+
.strict()
|
| 43 |
+
.nullable(),
|
| 44 |
+
})
|
| 45 |
+
.strict();
|
| 46 |
+
|
| 47 |
+
export { emptyUpdateInstallState };
|
| 48 |
+
|
| 49 |
+
export async function readUpdateInstallState(
|
| 50 |
+
filePath: string = getUpdateInstallStateFile(),
|
| 51 |
+
): Promise<UpdateInstallState> {
|
| 52 |
+
try {
|
| 53 |
+
return await readJsonFile(filePath, UpdateInstallStateSchema, emptyUpdateInstallState());
|
| 54 |
+
} catch {
|
| 55 |
+
return emptyUpdateInstallState();
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function writeUpdateInstallState(
|
| 60 |
+
value: UpdateInstallState,
|
| 61 |
+
filePath: string = getUpdateInstallStateFile(),
|
| 62 |
+
): Promise<void> {
|
| 63 |
+
await writeJsonFile(filePath, UpdateInstallStateSchema, value);
|
| 64 |
+
}
|
apps/kimi-code/src/cli/update/native-manifest.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Per-release native artifact manifest (`/binaries/<version>/manifest.json`).
|
| 3 |
+
*
|
| 4 |
+
* Published alongside the release and consumed by the install scripts; the
|
| 5 |
+
* staged updater reuses the same file so checksums and file names have a
|
| 6 |
+
* single source of truth. Entries point at the bare platform binary
|
| 7 |
+
* (`kimi-code-<target>[.exe]`), not an archive; an entry may additionally
|
| 8 |
+
* carry `zstd` (or the legacy `compressed` field), pointing at the
|
| 9 |
+
* zstd-compressed variant of that binary.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { valid } from 'semver';
|
| 13 |
+
import { z } from 'zod';
|
| 14 |
+
|
| 15 |
+
import { kimiCodeCdnBinariesBase } from '#/constant/app';
|
| 16 |
+
|
| 17 |
+
const MANIFEST_FETCH_TIMEOUT_MS = 10_000;
|
| 18 |
+
|
| 19 |
+
const PlatformEntrySchema = z.object({
|
| 20 |
+
filename: z.string().min(1),
|
| 21 |
+
checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
|
| 22 |
+
zstd: z
|
| 23 |
+
.object({
|
| 24 |
+
file: z.string().min(1),
|
| 25 |
+
sha256: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
|
| 26 |
+
})
|
| 27 |
+
.optional(),
|
| 28 |
+
compressed: z
|
| 29 |
+
.object({
|
| 30 |
+
filename: z.string().min(1),
|
| 31 |
+
checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
|
| 32 |
+
})
|
| 33 |
+
.optional(),
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Deliberately NOT `.strict()` — unknown fields are ignored so future
|
| 38 |
+
* manifest additions never break shipped clients (same contract philosophy
|
| 39 |
+
* as the rollout manifest in `cdn.ts`).
|
| 40 |
+
*/
|
| 41 |
+
export const NativeReleaseManifestSchema = z.object({
|
| 42 |
+
version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
|
| 43 |
+
platforms: z.record(z.string(), PlatformEntrySchema),
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
export type NativeReleaseManifest = z.infer<typeof NativeReleaseManifestSchema>;
|
| 47 |
+
export type NativePlatformEntry = z.infer<typeof PlatformEntrySchema>;
|
| 48 |
+
|
| 49 |
+
export function nativeManifestUrl(version: string): string {
|
| 50 |
+
return `${kimiCodeCdnBinariesBase()}/${version}/manifest.json`;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function nativeBinaryUrl(version: string, filename: string): string {
|
| 54 |
+
return `${kimiCodeCdnBinariesBase()}/${version}/${filename}`;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Fetch and parse the per-release manifest. **Throws** on any failure
|
| 59 |
+
* (network, non-2xx, malformed body, unknown version) — callers treat a
|
| 60 |
+
* throw as "staging failed" and record an install failure.
|
| 61 |
+
*
|
| 62 |
+
* `version` goes into the URL, so it must be a valid semver (it always is:
|
| 63 |
+
* upstream sources are the CDN `latest.json` / the `upgrade` command).
|
| 64 |
+
* `fetchImpl` is injectable for tests.
|
| 65 |
+
*/
|
| 66 |
+
export async function fetchNativeReleaseManifest(
|
| 67 |
+
version: string,
|
| 68 |
+
fetchImpl: typeof fetch = fetch,
|
| 69 |
+
): Promise<NativeReleaseManifest> {
|
| 70 |
+
if (valid(version) === null) {
|
| 71 |
+
throw new Error(`invalid semver for native manifest lookup: ${JSON.stringify(version)}`);
|
| 72 |
+
}
|
| 73 |
+
const controller = new AbortController();
|
| 74 |
+
const timeout = setTimeout(() => {
|
| 75 |
+
controller.abort();
|
| 76 |
+
}, MANIFEST_FETCH_TIMEOUT_MS);
|
| 77 |
+
// The timeout must stay armed until the BODY is fully consumed: a CDN or
|
| 78 |
+
// proxy can deliver headers within the limit and then stall mid-body, and
|
| 79 |
+
// resolving `fetch()` alone would clear the timer and hang the worker.
|
| 80 |
+
try {
|
| 81 |
+
const response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal });
|
| 82 |
+
if (!response.ok) {
|
| 83 |
+
throw new Error(`native manifest for ${version} returned HTTP ${response.status}`);
|
| 84 |
+
}
|
| 85 |
+
const manifest = NativeReleaseManifestSchema.parse(JSON.parse(await response.text()));
|
| 86 |
+
// A stale or mispublished endpoint can answer with ANOTHER release's
|
| 87 |
+
// manifest: its checksums would then be applied to this version's binary
|
| 88 |
+
// and every download would fail verification. Reject the mismatch here.
|
| 89 |
+
if (manifest.version !== version) {
|
| 90 |
+
throw new Error(`manifest for ${version} served content for ${manifest.version}`);
|
| 91 |
+
}
|
| 92 |
+
return manifest;
|
| 93 |
+
} finally {
|
| 94 |
+
clearTimeout(timeout);
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* Pick the entry for the running platform. The release pipeline keys
|
| 100 |
+
* platforms by `<node platform>-<node arch>` (win32-x64, darwin-arm64, …).
|
| 101 |
+
* **Throws** when the platform is missing — a silent skip would strand the
|
| 102 |
+
* update in a retry loop.
|
| 103 |
+
*/
|
| 104 |
+
export function selectPlatformEntry(
|
| 105 |
+
manifest: NativeReleaseManifest,
|
| 106 |
+
platform: NodeJS.Platform,
|
| 107 |
+
arch: string,
|
| 108 |
+
): NativePlatformEntry {
|
| 109 |
+
const target = `${platform}-${arch}`;
|
| 110 |
+
const entry = manifest.platforms[target];
|
| 111 |
+
if (entry === undefined) {
|
| 112 |
+
throw new Error(`platform ${target} not found in native manifest for ${manifest.version}`);
|
| 113 |
+
}
|
| 114 |
+
return entry;
|
| 115 |
+
}
|
apps/kimi-code/src/cli/update/native-stage.ts
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Native staged update: download + verify into `<exe dir>/.staging/`,
|
| 3 |
+
* without touching the running executable. The actual swap happens on the
|
| 4 |
+
* next startup (see `native-swap.ts`).
|
| 5 |
+
*
|
| 6 |
+
* The CDN serves the bare platform binary (e.g. `kimi-code-win32-x64.exe`),
|
| 7 |
+
* whose sha256 comes from the per-release manifest over HTTPS — a staged
|
| 8 |
+
* binary is byte-exact what the release pipeline produced.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { createHash } from 'node:crypto';
|
| 12 |
+
import { createReadStream } from 'node:fs';
|
| 13 |
+
import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink } from 'node:fs/promises';
|
| 14 |
+
import { basename, join } from 'node:path';
|
| 15 |
+
import { createZstdDecompress } from 'node:zlib';
|
| 16 |
+
|
| 17 |
+
import { valid } from 'semver';
|
| 18 |
+
import { z } from 'zod';
|
| 19 |
+
|
| 20 |
+
import { KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME } from '#/constant/app';
|
| 21 |
+
import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths';
|
| 22 |
+
import { writeJsonFile } from '#/utils/persistence';
|
| 23 |
+
|
| 24 |
+
import {
|
| 25 |
+
fetchNativeReleaseManifest,
|
| 26 |
+
nativeBinaryUrl,
|
| 27 |
+
selectPlatformEntry,
|
| 28 |
+
} from './native-manifest';
|
| 29 |
+
|
| 30 |
+
const StagedNativeUpdateSchema = z
|
| 31 |
+
.object({
|
| 32 |
+
version: z.string().min(1),
|
| 33 |
+
target: z.string().min(1),
|
| 34 |
+
/** Base name of the staged executable inside `.staging/`. */
|
| 35 |
+
exeFileName: z
|
| 36 |
+
.string()
|
| 37 |
+
.min(1)
|
| 38 |
+
.refine((value) => basename(value) === value, { error: 'must be a plain file name' }),
|
| 39 |
+
/** sha256 of the staged binary (the manifest's checksum). */
|
| 40 |
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
| 41 |
+
exeSize: z.number().int().min(1),
|
| 42 |
+
stagedAt: z.string().min(1),
|
| 43 |
+
/**
|
| 44 |
+
* True when the stage was produced by an explicit user-initiated
|
| 45 |
+
* `kimi upgrade` (vs the passive background downloader): manual stages
|
| 46 |
+
* still apply when automatic updates are opted out via env.
|
| 47 |
+
*/
|
| 48 |
+
manual: z.boolean().optional(),
|
| 49 |
+
})
|
| 50 |
+
.strict();
|
| 51 |
+
|
| 52 |
+
export type StagedNativeUpdate = z.infer<typeof StagedNativeUpdateSchema>;
|
| 53 |
+
|
| 54 |
+
export function stagedExeFileName(version: string, platform: NodeJS.Platform): string {
|
| 55 |
+
return platform === 'win32' ? `kimi-${version}.exe` : `kimi-${version}`;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** Uniquifies the published staged-exe name across concurrent in-process workers. */
|
| 59 |
+
let stageTempCounter = 0;
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* The name a stage is published under: the base name plus a unique per-worker
|
| 63 |
+
* infix (`kimi-<version>.<pid>.<epoch-ms>.<n>[.exe]`). Once published, a
|
| 64 |
+
* staged executable is NEVER replaced — a same-version re-download publishes
|
| 65 |
+
* a new generation and the atomic metadata write retargets the pointer — so
|
| 66 |
+
* the pathname a swap validates at claim time is stable: no concurrent
|
| 67 |
+
* publisher can exchange the bytes between validation and install.
|
| 68 |
+
*/
|
| 69 |
+
function uniqueStagedExeFileName(version: string, platform: NodeJS.Platform): string {
|
| 70 |
+
const infix = `.${process.pid}.${Date.now()}.${stageTempCounter}`;
|
| 71 |
+
stageTempCounter += 1;
|
| 72 |
+
return platform === 'win32' ? `kimi-${version}${infix}.exe` : `kimi-${version}${infix}`;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export function stagedExePath(exePath: string, staged: StagedNativeUpdate): string {
|
| 76 |
+
return join(getNativeStagingDir(exePath), staged.exeFileName);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/** Parse staged-update metadata from raw text; null when malformed. */
|
| 80 |
+
export function parseStagedNativeUpdate(raw: string): StagedNativeUpdate | null {
|
| 81 |
+
let json: unknown;
|
| 82 |
+
try {
|
| 83 |
+
json = JSON.parse(raw);
|
| 84 |
+
} catch {
|
| 85 |
+
return null;
|
| 86 |
+
}
|
| 87 |
+
const parsed = StagedNativeUpdateSchema.safeParse(json);
|
| 88 |
+
return parsed.success ? parsed.data : null;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Read the staged-update metadata, returning null when anything is off:
|
| 93 |
+
* missing/corrupt `staged.json`, or the staged exe went away / changed size.
|
| 94 |
+
* A null result makes callers behave as if no update was ever staged.
|
| 95 |
+
*/
|
| 96 |
+
export async function readStagedNativeUpdate(
|
| 97 |
+
exePath: string,
|
| 98 |
+
filePath: string = getNativeStagedStateFile(exePath),
|
| 99 |
+
): Promise<StagedNativeUpdate | null> {
|
| 100 |
+
let raw: string;
|
| 101 |
+
try {
|
| 102 |
+
raw = await readFile(filePath, 'utf-8');
|
| 103 |
+
} catch {
|
| 104 |
+
return null;
|
| 105 |
+
}
|
| 106 |
+
const staged = parseStagedNativeUpdate(raw);
|
| 107 |
+
if (staged === null) return null;
|
| 108 |
+
const info = await stat(stagedExePath(exePath, staged)).catch(() => null);
|
| 109 |
+
if (info === null || info.size !== staged.exeSize) return null;
|
| 110 |
+
return staged;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* Two staged records are the same generation when every field matches —
|
| 115 |
+
* ignoring only the `manual` marker that promotion flips. Used to make sure
|
| 116 |
+
* a read-modify-write still acts on the record it read.
|
| 117 |
+
*/
|
| 118 |
+
function isSameStagedRecord(a: StagedNativeUpdate, b: StagedNativeUpdate): boolean {
|
| 119 |
+
return (
|
| 120 |
+
a.version === b.version &&
|
| 121 |
+
a.target === b.target &&
|
| 122 |
+
a.exeFileName === b.exeFileName &&
|
| 123 |
+
a.sha256 === b.sha256 &&
|
| 124 |
+
a.exeSize === b.exeSize &&
|
| 125 |
+
a.stagedAt === b.stagedAt
|
| 126 |
+
);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* Mark the adopted staged update as manual, confirming the marker actually
|
| 131 |
+
* persisted. Used when an explicit `kimi upgrade` adopts a payload the
|
| 132 |
+
* passive downloader staged (already on disk, or still downloading): the
|
| 133 |
+
* marker lets the startup swap apply it even under the env opt-out.
|
| 134 |
+
*
|
| 135 |
+
* `expected` is the record the caller read and decided to adopt. The promote
|
| 136 |
+
* write only happens while the on-disk metadata still IS that record — a
|
| 137 |
+
* concurrent downloader may have published a different stage meanwhile, and
|
| 138 |
+
* overwriting its record would orphan a payload whose worker already
|
| 139 |
+
* reported success. (Pathname-only writes cannot compare-and-swap, so a
|
| 140 |
+
* residual publish-between-check-and-write window remains; the identity
|
| 141 |
+
* re-read narrows it to that gap.)
|
| 142 |
+
*
|
| 143 |
+
* Returns false when the record changed / is concurrently claimed by a
|
| 144 |
+
* startup swap (nothing to promote) or a confirming read never sees the
|
| 145 |
+
* promoted record — callers must NOT report adoption for a promotion that
|
| 146 |
+
* never landed. The write and the confirmation use the same atomic metadata
|
| 147 |
+
* path as staging; a swap that claims the PROMOTED file proceeds with the
|
| 148 |
+
* marker, which is the desired outcome anyway.
|
| 149 |
+
*/
|
| 150 |
+
export async function promoteStagedUpdateToManual(
|
| 151 |
+
exePath: string,
|
| 152 |
+
expected: StagedNativeUpdate,
|
| 153 |
+
): Promise<boolean> {
|
| 154 |
+
if (expected.manual === true) return true;
|
| 155 |
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
| 156 |
+
const staged = await readStagedNativeUpdate(exePath);
|
| 157 |
+
if (staged === null || !isSameStagedRecord(staged, expected)) return false;
|
| 158 |
+
// Another promoter already marked this exact record — our work is done.
|
| 159 |
+
if (staged.manual === true) return true;
|
| 160 |
+
await writeJsonFile(getNativeStagedStateFile(exePath), StagedNativeUpdateSchema, {
|
| 161 |
+
...staged,
|
| 162 |
+
manual: true,
|
| 163 |
+
});
|
| 164 |
+
// Confirm: a concurrent claim/restore cycle could leave unpromoted
|
| 165 |
+
// content behind (the restore never overwrites, so a confirmed marker
|
| 166 |
+
// cannot be displaced afterwards). The confirmation must see the
|
| 167 |
+
// promoted ADOPTION CANDIDATE itself, not just any manual record.
|
| 168 |
+
const confirmed = await readStagedNativeUpdate(exePath);
|
| 169 |
+
if (confirmed?.manual === true && isSameStagedRecord(confirmed, expected)) return true;
|
| 170 |
+
}
|
| 171 |
+
return false;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/** Stream a file's sha256 as hex; null when the file cannot be read. */
|
| 175 |
+
export async function hashFileSha256(filePath: string): Promise<string | null> {
|
| 176 |
+
try {
|
| 177 |
+
const hash = createHash('sha256');
|
| 178 |
+
for await (const chunk of createReadStream(filePath)) {
|
| 179 |
+
hash.update(chunk as Buffer);
|
| 180 |
+
}
|
| 181 |
+
return hash.digest('hex');
|
| 182 |
+
} catch {
|
| 183 |
+
return null;
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* Whether a `.staging/` entry is an updater-owned artifact: a staged
|
| 189 |
+
* executable (`kimi-<version>[.<pid>.<epoch-ms>.<n>][.exe]`) or a download
|
| 190 |
+
* intermediate (the same plus `.part`, optionally with a `.zst` infix).
|
| 191 |
+
* Ownership derives from the semver/file-name contract (prerelease and
|
| 192 |
+
* build metadata included), so foreign files in the directory are never
|
| 193 |
+
* matched.
|
| 194 |
+
*/
|
| 195 |
+
function isUpdaterOwnedStagingFile(entry: string): boolean {
|
| 196 |
+
if (!entry.startsWith('kimi-')) return false;
|
| 197 |
+
let name = entry.slice('kimi-'.length);
|
| 198 |
+
if (name.endsWith('.part')) name = name.slice(0, -'.part'.length);
|
| 199 |
+
if (name.endsWith('.zst')) name = name.slice(0, -'.zst'.length);
|
| 200 |
+
if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length);
|
| 201 |
+
// Published artifacts may carry a unique per-worker infix after the
|
| 202 |
+
// version (.<pid>.<epoch-ms>.<n>, or the older .<pid>.<n>) — try with and
|
| 203 |
+
// without stripping it (the infix is dot-numeric, which is ambiguous with
|
| 204 |
+
// prerelease suffixes, so every candidate is checked).
|
| 205 |
+
const candidates = [
|
| 206 |
+
name,
|
| 207 |
+
name.replace(/\.\d+\.\d+$/, ''),
|
| 208 |
+
name.replace(/\.\d+\.\d+\.\d+$/, ''),
|
| 209 |
+
];
|
| 210 |
+
return candidates.some((candidate) => valid(candidate) !== null);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/**
|
| 214 |
+
* An unreferenced artifact is only deleted once it is older than this. A
|
| 215 |
+
* concurrent worker's payload publishes BEFORE its metadata, so a freshly
|
| 216 |
+
* renamed staged exe can look like an orphan for a moment; publication takes
|
| 217 |
+
* milliseconds, so anything unreferenced AND old is definitively abandoned.
|
| 218 |
+
*/
|
| 219 |
+
const STAGING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* Remove files in `.staging/` that nothing references: interrupted downloads
|
| 223 |
+
* (`.part`), and staged exes whose `staged.json` never landed (downloader
|
| 224 |
+
* killed between the two writes) — each such orphan is ~180 MB and would
|
| 225 |
+
* otherwise accumulate forever. The exe referenced by the CURRENT
|
| 226 |
+
* `staged.json` is preserved (a superseded record is only replaced by the
|
| 227 |
+
* final atomic write, so its payload is still the applicable update while
|
| 228 |
+
* this run downloads), and so are swap claim files (`staged.json.swap-*`)
|
| 229 |
+
* with the exes they reference: another instance may be mid-swap.
|
| 230 |
+
*/
|
| 231 |
+
async function cleanupStagingOrphans(stagingDir: string): Promise<void> {
|
| 232 |
+
let entries: string[];
|
| 233 |
+
try {
|
| 234 |
+
entries = await readdir(stagingDir);
|
| 235 |
+
} catch {
|
| 236 |
+
return;
|
| 237 |
+
}
|
| 238 |
+
const keep = new Set<string>([KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME]);
|
| 239 |
+
for (const entry of entries) {
|
| 240 |
+
// The current record and every swap claim pin the exe they reference.
|
| 241 |
+
if (
|
| 242 |
+
entry !== KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME &&
|
| 243 |
+
!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)
|
| 244 |
+
) {
|
| 245 |
+
continue;
|
| 246 |
+
}
|
| 247 |
+
keep.add(entry);
|
| 248 |
+
const raw = await readFile(join(stagingDir, entry), 'utf-8').catch(() => null);
|
| 249 |
+
if (raw === null) continue;
|
| 250 |
+
try {
|
| 251 |
+
const exeFileName: unknown = (JSON.parse(raw) as { exeFileName?: unknown }).exeFileName;
|
| 252 |
+
if (typeof exeFileName === 'string' && exeFileName.length > 0) {
|
| 253 |
+
// basename(): the metadata contract is a plain file name — never let
|
| 254 |
+
// a hand-crafted path escape the staging dir.
|
| 255 |
+
keep.add(basename(exeFileName));
|
| 256 |
+
}
|
| 257 |
+
} catch {
|
| 258 |
+
// Unparseable record/claim: keep the file itself, touch nothing else.
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
for (const entry of entries) {
|
| 262 |
+
if (keep.has(entry)) continue;
|
| 263 |
+
// Only ever unlink updater-owned artifact names (files, never
|
| 264 |
+
// directories): the staging dir sits next to the exe and may contain
|
| 265 |
+
// data that is not ours.
|
| 266 |
+
if (!isUpdaterOwnedStagingFile(entry)) continue;
|
| 267 |
+
const full = join(stagingDir, entry);
|
| 268 |
+
const info = await stat(full).catch(() => null);
|
| 269 |
+
if (info === null) continue;
|
| 270 |
+
// Too young to be abandoned — a concurrent worker may be about to
|
| 271 |
+
// publish its metadata.
|
| 272 |
+
if (Date.now() - info.mtimeMs < STAGING_ORPHAN_GRACE_MS) continue;
|
| 273 |
+
await unlink(full).catch(() => {});
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
export interface StageNativeUpdateOptions {
|
| 278 |
+
readonly version: string;
|
| 279 |
+
/** Path of the installed executable the staged binary will later replace. */
|
| 280 |
+
readonly exePath: string;
|
| 281 |
+
readonly platform?: NodeJS.Platform;
|
| 282 |
+
readonly arch?: string;
|
| 283 |
+
readonly fetchImpl?: typeof fetch;
|
| 284 |
+
/** Download progress (bytes so far, Content-Length total when known). */
|
| 285 |
+
readonly onProgress?: (downloadedBytes: number, totalBytes: number | null) => void;
|
| 286 |
+
/** Test hook: override the download idle timeout (default 30 s). */
|
| 287 |
+
readonly idleTimeoutMs?: number;
|
| 288 |
+
/** True when the stage answers an explicit user-initiated `kimi upgrade`. */
|
| 289 |
+
readonly manual?: boolean;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
export type StageNativeUpdateStatus = 'already-staged' | 'staged';
|
| 293 |
+
|
| 294 |
+
export interface StageNativeUpdateResult {
|
| 295 |
+
readonly status: StageNativeUpdateStatus;
|
| 296 |
+
readonly staged: StagedNativeUpdate;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
/**
|
| 300 |
+
* Idle timeout for the binary stream: any 30 s without a arriving chunk
|
| 301 |
+
* aborts the download. Total duration is intentionally unbounded — slow
|
| 302 |
+
* networks may take as long as they need as long as bytes keep flowing.
|
| 303 |
+
*/
|
| 304 |
+
const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
|
| 305 |
+
|
| 306 |
+
async function downloadAndHash(
|
| 307 |
+
url: string,
|
| 308 |
+
partPath: string,
|
| 309 |
+
expectedSha256: string,
|
| 310 |
+
fetchImpl: typeof fetch,
|
| 311 |
+
onProgress?: (downloadedBytes: number, totalBytes: number | null) => void,
|
| 312 |
+
idleTimeoutMs: number = DOWNLOAD_IDLE_TIMEOUT_MS,
|
| 313 |
+
): Promise<number> {
|
| 314 |
+
const controller = new AbortController();
|
| 315 |
+
let idleTimeout: ReturnType<typeof setTimeout> | undefined;
|
| 316 |
+
const armIdleTimeout = (): void => {
|
| 317 |
+
if (idleTimeout !== undefined) clearTimeout(idleTimeout);
|
| 318 |
+
idleTimeout = setTimeout(() => {
|
| 319 |
+
controller.abort(new Error(`download stalled: no data for ${idleTimeoutMs}ms`));
|
| 320 |
+
}, idleTimeoutMs);
|
| 321 |
+
};
|
| 322 |
+
armIdleTimeout();
|
| 323 |
+
let response: Response;
|
| 324 |
+
try {
|
| 325 |
+
response = await fetchImpl(url, { signal: controller.signal });
|
| 326 |
+
} catch (error) {
|
| 327 |
+
clearTimeout(idleTimeout);
|
| 328 |
+
throw error;
|
| 329 |
+
}
|
| 330 |
+
if (!response.ok || response.body === null) {
|
| 331 |
+
clearTimeout(idleTimeout);
|
| 332 |
+
throw new Error(`native binary download returned HTTP ${response.status}`);
|
| 333 |
+
}
|
| 334 |
+
const contentLength = response.headers.get('content-length');
|
| 335 |
+
const total =
|
| 336 |
+
contentLength !== null && /^\d+$/.test(contentLength) ? Number(contentLength) : null;
|
| 337 |
+
const hash = createHash('sha256');
|
| 338 |
+
let size = 0;
|
| 339 |
+
const file = await open(partPath, 'w');
|
| 340 |
+
try {
|
| 341 |
+
for await (const chunk of response.body as AsyncIterable<Uint8Array>) {
|
| 342 |
+
armIdleTimeout();
|
| 343 |
+
hash.update(chunk);
|
| 344 |
+
size += chunk.length;
|
| 345 |
+
// FileHandle.write may persist FEWER bytes than requested (a short
|
| 346 |
+
// write, e.g. near disk exhaustion) while the hash and size above
|
| 347 |
+
// already account for the whole chunk — an unretried short write would
|
| 348 |
+
// publish a truncated binary under a valid checksum. Loop until the
|
| 349 |
+
// chunk is fully on disk.
|
| 350 |
+
let offset = 0;
|
| 351 |
+
while (offset < chunk.length) {
|
| 352 |
+
const { bytesWritten } = await file.write(chunk, offset);
|
| 353 |
+
if (bytesWritten === 0) {
|
| 354 |
+
throw new Error('failed to write the native binary to disk (disk full?)');
|
| 355 |
+
}
|
| 356 |
+
offset += bytesWritten;
|
| 357 |
+
}
|
| 358 |
+
onProgress?.(size, total);
|
| 359 |
+
}
|
| 360 |
+
} finally {
|
| 361 |
+
clearTimeout(idleTimeout);
|
| 362 |
+
await file.close();
|
| 363 |
+
}
|
| 364 |
+
const digest = hash.digest('hex');
|
| 365 |
+
if (digest !== expectedSha256) {
|
| 366 |
+
throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`);
|
| 367 |
+
}
|
| 368 |
+
return size;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
/**
|
| 372 |
+
* Inflate a downloaded `.zst` artifact into `destPath`, hashing the plain
|
| 373 |
+
* bytes as they stream through; **throws** when the result does not match
|
| 374 |
+
* the manifest's bare-binary checksum. Returns the decompressed size.
|
| 375 |
+
*/
|
| 376 |
+
async function decompressAndHash(
|
| 377 |
+
zstPath: string,
|
| 378 |
+
destPath: string,
|
| 379 |
+
expectedSha256: string,
|
| 380 |
+
): Promise<number> {
|
| 381 |
+
const hash = createHash('sha256');
|
| 382 |
+
let size = 0;
|
| 383 |
+
const file = await open(destPath, 'w');
|
| 384 |
+
try {
|
| 385 |
+
for await (const chunk of createReadStream(zstPath).pipe(createZstdDecompress())) {
|
| 386 |
+
hash.update(chunk as Buffer);
|
| 387 |
+
size += (chunk as Buffer).length;
|
| 388 |
+
// Same short-write loop as downloadAndHash: FileHandle.write may
|
| 389 |
+
// persist fewer bytes than requested, so loop until the chunk is
|
| 390 |
+
// fully on disk.
|
| 391 |
+
let offset = 0;
|
| 392 |
+
while (offset < (chunk as Buffer).length) {
|
| 393 |
+
const { bytesWritten } = await file.write(chunk as Buffer, offset);
|
| 394 |
+
if (bytesWritten === 0) {
|
| 395 |
+
throw new Error('failed to write the native binary to disk (disk full?)');
|
| 396 |
+
}
|
| 397 |
+
offset += bytesWritten;
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
} finally {
|
| 401 |
+
await file.close();
|
| 402 |
+
}
|
| 403 |
+
const digest = hash.digest('hex');
|
| 404 |
+
if (digest !== expectedSha256) {
|
| 405 |
+
throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`);
|
| 406 |
+
}
|
| 407 |
+
return size;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
/**
|
| 411 |
+
* Download + verify `version` next to the running executable.
|
| 412 |
+
*
|
| 413 |
+
* Short-circuits with `already-staged` when the same version is ready on
|
| 414 |
+
* disk (repeat `kimi upgrade`, or foreground/background overlap). **Throws**
|
| 415 |
+
* on any failure after cleaning up this version's leftovers — the caller
|
| 416 |
+
* records an install failure.
|
| 417 |
+
*/
|
| 418 |
+
export async function stageNativeUpdate(
|
| 419 |
+
options: StageNativeUpdateOptions,
|
| 420 |
+
): Promise<StageNativeUpdateResult> {
|
| 421 |
+
const platform = options.platform ?? process.platform;
|
| 422 |
+
const arch = options.arch ?? process.arch;
|
| 423 |
+
// Validate BEFORE anything derives a filesystem path from the version: the
|
| 424 |
+
// hidden download command takes it from argv, and a non-semver could carry
|
| 425 |
+
// path traversal into the cleanup paths below.
|
| 426 |
+
if (valid(options.version) === null) {
|
| 427 |
+
throw new Error(`invalid semver for native staging: ${JSON.stringify(options.version)}`);
|
| 428 |
+
}
|
| 429 |
+
const fetchImpl = options.fetchImpl ?? fetch;
|
| 430 |
+
const target = `${platform}-${arch}`;
|
| 431 |
+
// Unique per-worker publish name — see uniqueStagedExeFileName: a staged
|
| 432 |
+
// exe is never replaced once published, so the pathname a swap validates
|
| 433 |
+
// at claim time cannot be exchanged by a concurrent publisher.
|
| 434 |
+
const exeFileName = uniqueStagedExeFileName(options.version, platform);
|
| 435 |
+
|
| 436 |
+
const existing = await readStagedNativeUpdate(options.exePath);
|
| 437 |
+
if (existing !== null && existing.version === options.version) {
|
| 438 |
+
// readStagedNativeUpdate checks only the recorded size — a same-size
|
| 439 |
+
// corruption after the download (disk damage, a non-durable write)
|
| 440 |
+
// would still be adopted here and reported as success, only for the
|
| 441 |
+
// startup swap's claim-time re-verify to reject and discard it. Compare
|
| 442 |
+
// the actual digest before adopting; a mismatch falls through and
|
| 443 |
+
// re-stages from the CDN (published under a new generation name — the
|
| 444 |
+
// damaged exe is left for the age-gated orphan cleanup).
|
| 445 |
+
const digest = await hashFileSha256(stagedExePath(options.exePath, existing));
|
| 446 |
+
if (digest === existing.sha256) {
|
| 447 |
+
// An explicit upgrade adopts an auto-staged payload — but only report
|
| 448 |
+
// the adoption once the manual marker is confirmed persisted. A stage
|
| 449 |
+
// currently being claimed by a startup swap cannot be promoted here;
|
| 450 |
+
// fall through and stage afresh instead.
|
| 451 |
+
if (options.manual === true && existing.manual !== true) {
|
| 452 |
+
if (await promoteStagedUpdateToManual(options.exePath, existing)) {
|
| 453 |
+
return { status: 'already-staged', staged: { ...existing, manual: true } };
|
| 454 |
+
}
|
| 455 |
+
} else {
|
| 456 |
+
return { status: 'already-staged', staged: existing };
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// A different version was staged earlier and never swapped (skipped
|
| 462 |
+
// rollout, user stayed offline, …), or the same version's payload failed
|
| 463 |
+
// the integrity check above. The old record is LEFT IN PLACE until the
|
| 464 |
+
// atomic metadata write below replaces it: a pathname-level delete could
|
| 465 |
+
// remove a concurrent worker's freshly published record (orphaning a
|
| 466 |
+
// payload whose worker already reported success), and a swap claiming the
|
| 467 |
+
// old stage meanwhile applies a still-valid update. The old exe stays too
|
| 468 |
+
// — an unreferenced one is reaped by the age-gated orphan cleanup.
|
| 469 |
+
const stagingDir = getNativeStagingDir(options.exePath);
|
| 470 |
+
await mkdir(stagingDir, { recursive: true });
|
| 471 |
+
// Drop orphans from interrupted earlier runs before writing ours.
|
| 472 |
+
await cleanupStagingOrphans(stagingDir);
|
| 473 |
+
|
| 474 |
+
const staged: StagedNativeUpdate = {
|
| 475 |
+
version: options.version,
|
| 476 |
+
target,
|
| 477 |
+
exeFileName,
|
| 478 |
+
sha256: '',
|
| 479 |
+
exeSize: 0,
|
| 480 |
+
stagedAt: new Date().toISOString(),
|
| 481 |
+
manual: options.manual === true ? true : undefined,
|
| 482 |
+
};
|
| 483 |
+
|
| 484 |
+
// The .part intermediate is just the publish name plus the suffix — the
|
| 485 |
+
// name already carries this worker's unique infix, so concurrent workers
|
| 486 |
+
// never interleave writes into a shared path.
|
| 487 |
+
const partPath = join(stagingDir, `${exeFileName}.part`);
|
| 488 |
+
try {
|
| 489 |
+
const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl);
|
| 490 |
+
const entry = selectPlatformEntry(manifest, platform, arch);
|
| 491 |
+
const compressed = entry.zstd === undefined
|
| 492 |
+
? entry.compressed
|
| 493 |
+
: { filename: entry.zstd.file, checksum: entry.zstd.sha256 };
|
| 494 |
+
// Prefer the zstd-compressed artifact when the manifest carries one and
|
| 495 |
+
// the runtime can inflate it (~4x smaller than the bare binary). Any
|
| 496 |
+
// failure in the compressed path falls back to the bare download below.
|
| 497 |
+
let size: number | undefined;
|
| 498 |
+
if (compressed !== undefined && typeof createZstdDecompress === 'function') {
|
| 499 |
+
const zstPartPath = join(stagingDir, `${exeFileName}.zst.part`);
|
| 500 |
+
try {
|
| 501 |
+
await downloadAndHash(
|
| 502 |
+
nativeBinaryUrl(options.version, compressed.filename),
|
| 503 |
+
zstPartPath,
|
| 504 |
+
compressed.checksum,
|
| 505 |
+
fetchImpl,
|
| 506 |
+
options.onProgress,
|
| 507 |
+
options.idleTimeoutMs,
|
| 508 |
+
);
|
| 509 |
+
size = await decompressAndHash(zstPartPath, partPath, entry.checksum);
|
| 510 |
+
await rm(zstPartPath, { force: true });
|
| 511 |
+
} catch (error) {
|
| 512 |
+
console.warn(
|
| 513 |
+
`[update] compressed artifact unavailable, falling back to uncompressed download: ${error instanceof Error ? error.message : String(error)}`,
|
| 514 |
+
);
|
| 515 |
+
await rm(zstPartPath, { force: true }).catch(() => {});
|
| 516 |
+
await rm(partPath, { force: true }).catch(() => {});
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
size ??= await downloadAndHash(
|
| 520 |
+
nativeBinaryUrl(options.version, entry.filename),
|
| 521 |
+
partPath,
|
| 522 |
+
entry.checksum,
|
| 523 |
+
fetchImpl,
|
| 524 |
+
options.onProgress,
|
| 525 |
+
options.idleTimeoutMs,
|
| 526 |
+
);
|
| 527 |
+
// sha256 matched the manifest. Make the private .part file executable
|
| 528 |
+
// BEFORE publishing it: a concurrent swap may move the staged exe into
|
| 529 |
+
// the install path the instant it appears at its published name, so a
|
| 530 |
+
// post-publish chmod could land on a path that is already gone — leaving
|
| 531 |
+
// a non-executable installation behind.
|
| 532 |
+
await chmod(partPath, 0o755);
|
| 533 |
+
await rename(partPath, stagedExePath(options.exePath, staged));
|
| 534 |
+
|
| 535 |
+
staged.sha256 = entry.checksum;
|
| 536 |
+
staged.exeSize = size;
|
| 537 |
+
// Atomic write: staged.json only ever appears complete and consistent.
|
| 538 |
+
await writeJsonFile(
|
| 539 |
+
getNativeStagedStateFile(options.exePath),
|
| 540 |
+
StagedNativeUpdateSchema,
|
| 541 |
+
staged,
|
| 542 |
+
);
|
| 543 |
+
return { status: 'staged', staged };
|
| 544 |
+
} catch (error) {
|
| 545 |
+
// Remove only what THIS attempt privately owns: its unique .part file.
|
| 546 |
+
// If the failure landed after the publishing rename, this attempt's exe
|
| 547 |
+
// is already at its unique name with no metadata pointing at it — left
|
| 548 |
+
// in place (a just-published exe may belong to a concurrent metadata
|
| 549 |
+
// write) and reaped by the age-gated orphan cleanup.
|
| 550 |
+
await rm(partPath, { force: true }).catch(() => {});
|
| 551 |
+
// Best effort: drop the staging dir itself when empty (a concurrent
|
| 552 |
+
// worker's files keep it around — rmdir only removes empty dirs).
|
| 553 |
+
await rmdir(getNativeStagingDir(options.exePath)).catch(() => {});
|
| 554 |
+
throw error;
|
| 555 |
+
}
|
| 556 |
+
}
|
apps/kimi-code/src/cli/update/native-swap.ts
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Native staged swap, executed at the very top of startup.
|
| 3 |
+
*
|
| 4 |
+
* When a staged update is ready (`.staging/staged.json` next to the running
|
| 5 |
+
* exe), swap it in atomically and re-exec so the user session runs the new
|
| 6 |
+
* binary immediately. Everything here is best-effort: any failure leaves the
|
| 7 |
+
* current exe intact (rollback from `.bak`) and startup continues normally.
|
| 8 |
+
*
|
| 9 |
+
* Windows semantics make this safe: a running exe can be renamed but not
|
| 10 |
+
* overwritten, so the sequence is `rename exe→.bak` (the running process is
|
| 11 |
+
* unaffected), `rename staged→exe`, then delete `.bak` (best effort — a
|
| 12 |
+
* concurrent old instance keeps it locked until it exits). This is the same
|
| 13 |
+
* mechanism install.ps1 already relies on, and the Squirrel/NSIS-style
|
| 14 |
+
* "next launch performs the swap" pattern. Leftovers a swap cannot remove
|
| 15 |
+
* (its own `.bak` while still running, crash residue in `.staging/`) are
|
| 16 |
+
* swept best-effort on every launch.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
import { spawn } from 'node:child_process';
|
| 20 |
+
import { readdir, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises';
|
| 21 |
+
import { constants as osConstants } from 'node:os';
|
| 22 |
+
import { basename, dirname, join } from 'node:path';
|
| 23 |
+
|
| 24 |
+
import { gt } from 'semver';
|
| 25 |
+
|
| 26 |
+
import { log } from '@moonshot-ai/kimi-code-sdk';
|
| 27 |
+
|
| 28 |
+
import {
|
| 29 |
+
KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME,
|
| 30 |
+
KIMI_CODE_UPDATE_REEXEC_ENV,
|
| 31 |
+
} from '#/constant/app';
|
| 32 |
+
|
| 33 |
+
import { readUpdateInstallState, writeUpdateInstallState } from './install-state';
|
| 34 |
+
import {
|
| 35 |
+
hashFileSha256,
|
| 36 |
+
parseStagedNativeUpdate,
|
| 37 |
+
readStagedNativeUpdate,
|
| 38 |
+
stagedExePath,
|
| 39 |
+
type StagedNativeUpdate,
|
| 40 |
+
} from './native-stage';
|
| 41 |
+
import { isAutoUpdateDisabledByEnv, shouldAutoInstallUpdates } from './preflight';
|
| 42 |
+
import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths';
|
| 43 |
+
import { createFileIfAbsent } from '#/utils/persistence';
|
| 44 |
+
|
| 45 |
+
export interface NativeSwapDeps {
|
| 46 |
+
readonly exePath: string;
|
| 47 |
+
readonly argv: readonly string[];
|
| 48 |
+
readonly env: NodeJS.ProcessEnv;
|
| 49 |
+
readonly currentVersion: string;
|
| 50 |
+
readonly isNative: boolean;
|
| 51 |
+
readonly spawnImpl?: typeof spawn;
|
| 52 |
+
readonly exitImpl?: (code: number) => void;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface SpawnedChild {
|
| 56 |
+
once(event: 'error', listener: (error: Error) => void): void;
|
| 57 |
+
once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
|
| 58 |
+
once(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function isTruthy(value: string | undefined): boolean {
|
| 62 |
+
return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function isNotFound(error: unknown): boolean {
|
| 66 |
+
return (
|
| 67 |
+
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT'
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function isAlreadyExists(error: unknown): boolean {
|
| 72 |
+
return (
|
| 73 |
+
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST'
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* A `staged.json.swap-<pid>` claim file younger than this marks a swap in
|
| 79 |
+
* progress in another instance; older ones are crash residue. The bound
|
| 80 |
+
* comfortably exceeds the slowest swap (smoke-check timeout included).
|
| 81 |
+
*/
|
| 82 |
+
const SWAP_CLAIM_STALE_MS = 5 * 60 * 1000;
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* The swap's executable-renaming critical section is a few filesystem ops
|
| 86 |
+
* (well under a second), so a swap mutex older than this is crash residue.
|
| 87 |
+
*/
|
| 88 |
+
const SWAP_MUTEX_STALE_MS = 60_000;
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* A young unparseable `staged.json` may be an in-flight exclusive-create
|
| 92 |
+
* publish (observable mid-write on filesystems without hard links — see
|
| 93 |
+
* createFileIfAbsent), not corruption. The publish gap is microscopic, so
|
| 94 |
+
* only records younger than this get the benefit of the doubt.
|
| 95 |
+
*/
|
| 96 |
+
const STAGED_PUBLISH_GRACE_MS = 60_000;
|
| 97 |
+
|
| 98 |
+
// First launch of a fresh ~150 MB unsigned exe can sit in an antivirus scan;
|
| 99 |
+
// give Windows extra headroom so a slow scan is not misread as a broken binary.
|
| 100 |
+
const SMOKE_CHECK_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 15_000;
|
| 101 |
+
|
| 102 |
+
function logSwap(message: string, payload: Record<string, unknown>): void {
|
| 103 |
+
try {
|
| 104 |
+
log.info(`native update swap: ${message}`, payload);
|
| 105 |
+
} catch {
|
| 106 |
+
// Diagnostics must never affect startup.
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/** Record a swap failure so preflight stops re-staging the same bad version. */
|
| 111 |
+
async function recordSwapFailure(version: string): Promise<void> {
|
| 112 |
+
try {
|
| 113 |
+
const state = await readUpdateInstallState();
|
| 114 |
+
const attempts =
|
| 115 |
+
(state.lastFailure?.version === version ? state.lastFailure.attempts : 0) + 1;
|
| 116 |
+
await writeUpdateInstallState({
|
| 117 |
+
...state,
|
| 118 |
+
active: null,
|
| 119 |
+
lastFailure: { version, failedAt: new Date().toISOString(), attempts },
|
| 120 |
+
});
|
| 121 |
+
} catch {
|
| 122 |
+
// Never block startup on bookkeeping.
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Run `exe --version` as a smoke check: exit code 0 and the EXACT staged
|
| 128 |
+
* version as the output (commander prints `<version>\n`). A substring check
|
| 129 |
+
* would let a mispublished binary satisfy the wrong target (`1.2.30`
|
| 130 |
+
* contains `1.2.3`) — and the manifest checksum cannot catch that case when
|
| 131 |
+
* it also describes the wrong artifact.
|
| 132 |
+
*/
|
| 133 |
+
function smokeCheck(
|
| 134 |
+
exePath: string,
|
| 135 |
+
staged: StagedNativeUpdate,
|
| 136 |
+
spawnImpl: typeof spawn,
|
| 137 |
+
): Promise<boolean> {
|
| 138 |
+
return new Promise((resolve) => {
|
| 139 |
+
let stdout = '';
|
| 140 |
+
let settled = false;
|
| 141 |
+
const finish = (ok: boolean): void => {
|
| 142 |
+
if (settled) return;
|
| 143 |
+
settled = true;
|
| 144 |
+
resolve(ok);
|
| 145 |
+
};
|
| 146 |
+
let child: SpawnedChild & { readonly stdout?: NodeJS.ReadableStream | null; kill(): void };
|
| 147 |
+
try {
|
| 148 |
+
child = spawnImpl(exePath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) as unknown as typeof child;
|
| 149 |
+
} catch {
|
| 150 |
+
finish(false);
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
const timeout = setTimeout(() => {
|
| 154 |
+
try {
|
| 155 |
+
child.kill();
|
| 156 |
+
} catch {
|
| 157 |
+
// Already gone.
|
| 158 |
+
}
|
| 159 |
+
finish(false);
|
| 160 |
+
}, SMOKE_CHECK_TIMEOUT_MS);
|
| 161 |
+
child.stdout?.on('data', (chunk: Buffer) => {
|
| 162 |
+
stdout += chunk.toString('utf-8');
|
| 163 |
+
});
|
| 164 |
+
child.once('error', () => {
|
| 165 |
+
clearTimeout(timeout);
|
| 166 |
+
finish(false);
|
| 167 |
+
});
|
| 168 |
+
// 'close', not 'exit': stdio may still be flushing when 'exit' fires, and
|
| 169 |
+
// the check needs the complete version output.
|
| 170 |
+
child.once('close', (code) => {
|
| 171 |
+
clearTimeout(timeout);
|
| 172 |
+
finish(code === 0 && stdout.trim() === staged.version);
|
| 173 |
+
});
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
interface ClaimedStaged {
|
| 178 |
+
readonly staged: StagedNativeUpdate;
|
| 179 |
+
readonly claimedPath: string;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* Atomically claim the staged metadata file (rename is atomic on both NTFS
|
| 184 |
+
* and POSIX, so exactly one of several concurrently starting instances wins),
|
| 185 |
+
* THEN parse the claimed contents. Claim-first matters: a concurrent
|
| 186 |
+
* downloader may supersede `staged.json` at any moment, so validating before
|
| 187 |
+
* the rename could act on metadata this swap never claimed.
|
| 188 |
+
*
|
| 189 |
+
* Returns null when there is nothing staged, the file disappeared under us,
|
| 190 |
+
* or the claimed metadata failed consistency checks. A claimed record that is
|
| 191 |
+
* UNPARSEABLE but was young at claim time may be an in-flight
|
| 192 |
+
* exclusive-create publish (observable mid-write where hard links are
|
| 193 |
+
* unsupported): it is put back with the same inode so the writer completes
|
| 194 |
+
* it, never destroyed. Aged corrupt residue and well-formed records whose exe
|
| 195 |
+
* is gone/changed are deterministically dead and discarded.
|
| 196 |
+
*/
|
| 197 |
+
async function claimStagedUpdate(exePath: string): Promise<ClaimedStaged | null> {
|
| 198 |
+
const stateFile = getNativeStagedStateFile(exePath);
|
| 199 |
+
const claimedPath = `${stateFile}.swap-${process.pid}`;
|
| 200 |
+
// Capture the record's age BEFORE the stamp below rewrites it.
|
| 201 |
+
const before = await stat(stateFile).catch(() => null);
|
| 202 |
+
const youngAtClaim =
|
| 203 |
+
before === null || Date.now() - before.mtimeMs <= STAGED_PUBLISH_GRACE_MS;
|
| 204 |
+
try {
|
| 205 |
+
// The metadata's mtime can be arbitrarily old — the download may have
|
| 206 |
+
// finished hours before this launch. Stamp it BEFORE the rename so the
|
| 207 |
+
// claim is born fresh: a concurrent launch's sweep never observes a live
|
| 208 |
+
// claim that looks like crash residue (and would delete the staged exe
|
| 209 |
+
// plus this swap's rollback backup). Stamping the state file itself is
|
| 210 |
+
// harmless — nothing reads its mtime.
|
| 211 |
+
await utimes(stateFile, new Date(), new Date()).catch(() => {});
|
| 212 |
+
await rename(stateFile, claimedPath);
|
| 213 |
+
} catch {
|
| 214 |
+
return null;
|
| 215 |
+
}
|
| 216 |
+
// Parse exactly the metadata we claimed.
|
| 217 |
+
const staged = await readStagedNativeUpdate(exePath, claimedPath);
|
| 218 |
+
if (staged === null) {
|
| 219 |
+
const raw = await readFile(claimedPath, 'utf-8').catch(() => null);
|
| 220 |
+
const wellFormed = raw !== null && parseStagedNativeUpdate(raw) !== null;
|
| 221 |
+
if (!wellFormed && youngAtClaim) {
|
| 222 |
+
// Possible in-flight publish: put the SAME inode back so the writer's
|
| 223 |
+
// pending write completes it. rename can overwrite a concurrently
|
| 224 |
+
// published newer record — bounded to this parse-failure window, and
|
| 225 |
+
// the loser is a newer stage that simply re-downloads, never a corrupt
|
| 226 |
+
// install.
|
| 227 |
+
await rename(claimedPath, stateFile).catch(() => {});
|
| 228 |
+
return null;
|
| 229 |
+
}
|
| 230 |
+
await unlink(claimedPath).catch(() => {});
|
| 231 |
+
return null;
|
| 232 |
+
}
|
| 233 |
+
return { staged, claimedPath };
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/**
|
| 237 |
+
* Put a claimed stage's metadata back so a later launch can retry — but only
|
| 238 |
+
* into a still-free state-file path: a downloader may have published a NEWER
|
| 239 |
+
* stage meanwhile, and an unconditional restore would silently replace it.
|
| 240 |
+
* The publish is create-if-absent (hard link, or an exclusive create on
|
| 241 |
+
* filesystems without hard-link support), so the restore never overwrites.
|
| 242 |
+
*
|
| 243 |
+
* The claim file is removed only when the restore landed or the path was
|
| 244 |
+
* taken by a newer stage (ours is superseded either way). A transient
|
| 245 |
+
* failure (ENOSPC, EACCES, …) RETAINS the claim: discarding it would orphan
|
| 246 |
+
* the staged exe with no newer stage to show for it, and the stale-claim
|
| 247 |
+
* sweep retries the restore on a later launch.
|
| 248 |
+
*/
|
| 249 |
+
async function restoreClaimedUpdate(exePath: string, claimedPath: string): Promise<void> {
|
| 250 |
+
const content = await readFile(claimedPath, 'utf-8').catch(() => null);
|
| 251 |
+
if (content === null) {
|
| 252 |
+
// Nothing readable to restore — drop the residue.
|
| 253 |
+
await unlink(claimedPath).catch(() => {});
|
| 254 |
+
return;
|
| 255 |
+
}
|
| 256 |
+
try {
|
| 257 |
+
await createFileIfAbsent(getNativeStagedStateFile(exePath), content);
|
| 258 |
+
} catch (error) {
|
| 259 |
+
if (!isAlreadyExists(error)) return;
|
| 260 |
+
// EEXIST: a concurrently published newer stage won the path.
|
| 261 |
+
}
|
| 262 |
+
await unlink(claimedPath).catch(() => {});
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/**
|
| 266 |
+
* Discard a claimed stage: only the claimed metadata file is removed — never
|
| 267 |
+
* the staged exe. A same-version downloader may have just renamed its fresh
|
| 268 |
+
* payload onto that path (payloads publish before their metadata), and
|
| 269 |
+
* genuinely unreferenced exes are reaped by the downloader's own orphan
|
| 270 |
+
* cleanup before its next stage.
|
| 271 |
+
*/
|
| 272 |
+
async function discardClaimedUpdate(claimedPath: string): Promise<void> {
|
| 273 |
+
await unlink(claimedPath).catch(() => {});
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
async function rollback(bakPath: string, exePath: string): Promise<boolean> {
|
| 277 |
+
try {
|
| 278 |
+
await rename(bakPath, exePath);
|
| 279 |
+
return true;
|
| 280 |
+
} catch {
|
| 281 |
+
return false;
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
export interface SwapMutexHandle {
|
| 286 |
+
release(): Promise<void>;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
/**
|
| 290 |
+
* Serialize the swap's executable-renaming critical section across CLI
|
| 291 |
+
* processes. The fresh-claim sweep is only a directory SNAPSHOT: two
|
| 292 |
+
* processes can both pass it before either claims, then claim different
|
| 293 |
+
* stage generations and rename the same installed exe concurrently —
|
| 294 |
+
* deleting or replacing each other's `.bak` rollback source. The mutex is
|
| 295 |
+
* create-if-absent (via createFileIfAbsent); an aged holder is crash residue
|
| 296 |
+
* (the section lasts well under a second) and is swept, then retried once.
|
| 297 |
+
*/
|
| 298 |
+
async function acquireSwapMutex(stagingDir: string): Promise<SwapMutexHandle | null> {
|
| 299 |
+
const mutexPath = join(stagingDir, 'swap.lock');
|
| 300 |
+
const marker = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
| 301 |
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
| 302 |
+
try {
|
| 303 |
+
await createFileIfAbsent(mutexPath, marker);
|
| 304 |
+
} catch (error) {
|
| 305 |
+
if (!isAlreadyExists(error)) {
|
| 306 |
+
// Transient IO failure (ENOSPC, EACCES, …): defer the swap rather
|
| 307 |
+
// than abort it — the caller restores the claim for a later launch.
|
| 308 |
+
return null;
|
| 309 |
+
}
|
| 310 |
+
if (attempt === 1) return null;
|
| 311 |
+
// Held — or crash residue: only an AGED mutex may be swept.
|
| 312 |
+
const info = await stat(mutexPath).catch(() => null);
|
| 313 |
+
if (info !== null && Date.now() - info.mtimeMs <= SWAP_MUTEX_STALE_MS) return null;
|
| 314 |
+
await unlink(mutexPath).catch(() => {});
|
| 315 |
+
continue;
|
| 316 |
+
}
|
| 317 |
+
// The stale sweep races this publish; only the survivor proceeds (same
|
| 318 |
+
// irreducible residual as the install lock's takeover marker).
|
| 319 |
+
const published = await readFile(mutexPath, 'utf-8').catch(() => null);
|
| 320 |
+
if (published !== marker) return null;
|
| 321 |
+
return {
|
| 322 |
+
release: async (): Promise<void> => {
|
| 323 |
+
// Release only the mutex instance we own.
|
| 324 |
+
const current = await readFile(mutexPath, 'utf-8').catch(() => null);
|
| 325 |
+
if (current !== marker) return;
|
| 326 |
+
await unlink(mutexPath).catch(() => {});
|
| 327 |
+
},
|
| 328 |
+
};
|
| 329 |
+
}
|
| 330 |
+
return null;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/**
|
| 334 |
+
* Remove leftover `.bak` siblings of the exe from earlier swaps/installs.
|
| 335 |
+
* Only names the updater itself creates are removed: the exact `<exe>.bak`
|
| 336 |
+
* and the numeric PID fallback `<exe>.<pid>.bak` — anything else with the
|
| 337 |
+
* prefix (`kimi.config.bak`, …) belongs to the user. A `.bak` still mapped
|
| 338 |
+
* by a running old instance cannot be deleted on Windows — it is simply
|
| 339 |
+
* left for a later launch.
|
| 340 |
+
*/
|
| 341 |
+
async function cleanupBackups(exePath: string, keepPath?: string): Promise<void> {
|
| 342 |
+
const dir = dirname(exePath);
|
| 343 |
+
const base = basename(exePath);
|
| 344 |
+
let entries: string[];
|
| 345 |
+
try {
|
| 346 |
+
entries = await readdir(dir);
|
| 347 |
+
} catch {
|
| 348 |
+
return;
|
| 349 |
+
}
|
| 350 |
+
for (const entry of entries) {
|
| 351 |
+
if (!entry.startsWith(`${base}.`) || !entry.endsWith('.bak')) continue;
|
| 352 |
+
const middle = entry.slice(base.length + 1, -'.bak'.length);
|
| 353 |
+
if (middle !== '' && !/^\d+$/.test(middle)) continue;
|
| 354 |
+
const full = join(dir, entry);
|
| 355 |
+
if (full === keepPath) continue;
|
| 356 |
+
await unlink(full).catch(() => {});
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
/**
|
| 361 |
+
* Recover `staged.json.swap-<pid>` claim files left by instances that died
|
| 362 |
+
* mid-swap (or kept by a restore that hit a transient error). An AGED claim
|
| 363 |
+
* is restored back onto the state-file path — create-if-absent, so a newer
|
| 364 |
+
* published stage is never overwritten — and this very launch can then claim
|
| 365 |
+
* and retry the swap; the claim file is dropped once the record is restored
|
| 366 |
+
* or superseded, and retained on transient errors. The referenced exes are
|
| 367 |
+
* never touched here: they may belong to a freshly published stage, and
|
| 368 |
+
* genuinely unreferenced ones are reaped by the downloader's own orphan
|
| 369 |
+
* cleanup before its next stage. Returns true when a FRESH claim file was
|
| 370 |
+
* seen — i.e. another instance is swapping right now.
|
| 371 |
+
*/
|
| 372 |
+
async function cleanupStaleSwapClaims(exePath: string): Promise<boolean> {
|
| 373 |
+
const stagingDir = getNativeStagingDir(exePath);
|
| 374 |
+
let entries: string[];
|
| 375 |
+
try {
|
| 376 |
+
entries = await readdir(stagingDir);
|
| 377 |
+
} catch {
|
| 378 |
+
return false;
|
| 379 |
+
}
|
| 380 |
+
let swapInProgress = false;
|
| 381 |
+
for (const entry of entries) {
|
| 382 |
+
if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue;
|
| 383 |
+
const full = join(stagingDir, entry);
|
| 384 |
+
const info = await stat(full).catch(() => null);
|
| 385 |
+
if (info === null) continue;
|
| 386 |
+
if (Date.now() - info.mtimeMs < SWAP_CLAIM_STALE_MS) {
|
| 387 |
+
swapInProgress = true;
|
| 388 |
+
continue;
|
| 389 |
+
}
|
| 390 |
+
await restoreClaimedUpdate(exePath, full);
|
| 391 |
+
}
|
| 392 |
+
return swapInProgress;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
/**
|
| 396 |
+
* Best-effort startup hygiene for update leftovers, run on every native
|
| 397 |
+
* launch. The swap itself can never fully clean up after its own run — the
|
| 398 |
+
* old process still holds its renamed image (`.bak`) on Windows — so later
|
| 399 |
+
* launches sweep what the previous run could not.
|
| 400 |
+
*
|
| 401 |
+
* Returns true when another instance holds a fresh swap claim or swap mutex:
|
| 402 |
+
* every artifact is then left alone and the caller must not start a second
|
| 403 |
+
* swap.
|
| 404 |
+
*/
|
| 405 |
+
async function sweepStaleNativeUpdateArtifacts(exePath: string): Promise<boolean> {
|
| 406 |
+
try {
|
| 407 |
+
if (await cleanupStaleSwapClaims(exePath)) {
|
| 408 |
+
// Another instance is mid-swap: leave every artifact alone — the `.bak`
|
| 409 |
+
// next to the exe is its rollback source.
|
| 410 |
+
return true;
|
| 411 |
+
}
|
| 412 |
+
// A live swap critical section holds the mutex: same deference. (Only a
|
| 413 |
+
// snapshot, but the swap re-checks the mutex after claiming, so a
|
| 414 |
+
// freshly-started swap is never entered concurrently.)
|
| 415 |
+
const mutexInfo = await stat(join(getNativeStagingDir(exePath), 'swap.lock')).catch(
|
| 416 |
+
() => null,
|
| 417 |
+
);
|
| 418 |
+
if (mutexInfo !== null && Date.now() - mutexInfo.mtimeMs <= SWAP_MUTEX_STALE_MS) {
|
| 419 |
+
return true;
|
| 420 |
+
}
|
| 421 |
+
await cleanupBackups(exePath);
|
| 422 |
+
} catch {
|
| 423 |
+
// Hygiene must never affect startup.
|
| 424 |
+
}
|
| 425 |
+
return false;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
/**
|
| 429 |
+
* Re-exec the (newly swapped) exe with the original argv, forwarding its exit
|
| 430 |
+
* code so the swap is invisible to the caller. Returns false when the spawn
|
| 431 |
+
* itself failed — the caller then continues startup with the old in-memory
|
| 432 |
+
* code; the binary on disk is already the new version.
|
| 433 |
+
*/
|
| 434 |
+
function reexec(
|
| 435 |
+
deps: NativeSwapDeps & { readonly spawnImpl: typeof spawn },
|
| 436 |
+
): Promise<boolean> {
|
| 437 |
+
return new Promise((resolve) => {
|
| 438 |
+
let child: SpawnedChild;
|
| 439 |
+
try {
|
| 440 |
+
child = deps.spawnImpl(deps.exePath, deps.argv.slice(2), {
|
| 441 |
+
stdio: 'inherit',
|
| 442 |
+
env: { ...deps.env, [KIMI_CODE_UPDATE_REEXEC_ENV]: '1' },
|
| 443 |
+
}) as unknown as SpawnedChild;
|
| 444 |
+
} catch (error) {
|
| 445 |
+
logSwap('re-exec spawn threw', { error: String(error) });
|
| 446 |
+
resolve(false);
|
| 447 |
+
return;
|
| 448 |
+
}
|
| 449 |
+
child.once('error', (error) => {
|
| 450 |
+
logSwap('re-exec spawn failed', { error: error.message });
|
| 451 |
+
resolve(false);
|
| 452 |
+
});
|
| 453 |
+
child.once('exit', (code, signal) => {
|
| 454 |
+
resolve(true);
|
| 455 |
+
const exitImpl = deps.exitImpl ?? ((exitCode: number) => process.exit(exitCode));
|
| 456 |
+
if (code !== null) {
|
| 457 |
+
exitImpl(code);
|
| 458 |
+
return;
|
| 459 |
+
}
|
| 460 |
+
// Terminated by a signal (OOM kill, external SIGKILL, …): mirror the
|
| 461 |
+
// shell's 128 + signo convention so the wrapper never reports a killed
|
| 462 |
+
// run as a successful CLI invocation.
|
| 463 |
+
const signo = signal !== null ? (osConstants.signals[signal] ?? 0) : 0;
|
| 464 |
+
exitImpl(signo > 0 ? 128 + signo : 1);
|
| 465 |
+
});
|
| 466 |
+
});
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/**
|
| 470 |
+
* Swap in a staged native update and re-exec when one is ready.
|
| 471 |
+
*
|
| 472 |
+
* Returns true only when the process was re-launched (the caller must not
|
| 473 |
+
* continue startup — the exit handler fires once the child exits). Every
|
| 474 |
+
* other outcome returns false so startup proceeds untouched.
|
| 475 |
+
*/
|
| 476 |
+
export async function maybeRelaunchWithStagedNativeUpdate(
|
| 477 |
+
deps: NativeSwapDeps,
|
| 478 |
+
): Promise<boolean> {
|
| 479 |
+
if (!deps.isNative) return false;
|
| 480 |
+
const swapInProgress = await sweepStaleNativeUpdateArtifacts(deps.exePath);
|
| 481 |
+
if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) {
|
| 482 |
+
// Read-once guard: drop it so this session's children (and any nested
|
| 483 |
+
// kimi launches from them) do not inherit the swap skip.
|
| 484 |
+
delete deps.env[KIMI_CODE_UPDATE_REEXEC_ENV];
|
| 485 |
+
return false;
|
| 486 |
+
}
|
| 487 |
+
if (swapInProgress) {
|
| 488 |
+
// Another instance holds a fresh swap claim and finishes (or rolls back)
|
| 489 |
+
// on its own. Starting a second swap here would rename the install path
|
| 490 |
+
// from under it and let each launcher delete the `.bak` the other may
|
| 491 |
+
// still need for rollback. Its re-exec — or our next launch — lands the
|
| 492 |
+
// update, so this session simply runs the current exe.
|
| 493 |
+
logSwap('another instance is mid-swap, skipping', { exePath: deps.exePath });
|
| 494 |
+
return false;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
const claimed = await claimStagedUpdate(deps.exePath);
|
| 498 |
+
if (claimed === null) return false;
|
| 499 |
+
const { staged, claimedPath } = claimed;
|
| 500 |
+
const spawnImpl = deps.spawnImpl ?? spawn;
|
| 501 |
+
|
| 502 |
+
const discard = async (): Promise<boolean> => {
|
| 503 |
+
await discardClaimedUpdate(claimedPath);
|
| 504 |
+
return false;
|
| 505 |
+
};
|
| 506 |
+
|
| 507 |
+
// Downgrade guard: the staged version must be newer than what is running.
|
| 508 |
+
// (The user may have installed a newer build manually after we staged.)
|
| 509 |
+
if (!gt(staged.version, deps.currentVersion)) {
|
| 510 |
+
logSwap('discarding staged update (not newer)', {
|
| 511 |
+
staged: staged.version,
|
| 512 |
+
current: deps.currentVersion,
|
| 513 |
+
});
|
| 514 |
+
return discard();
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// Automatic stages apply only while automatic updates are enabled — both
|
| 518 |
+
// the env opt-out and the persisted `[upgrade] auto_install = false`
|
| 519 |
+
// preference gate them. Evaluated on the CLAIMED metadata: a pre-claim
|
| 520 |
+
// snapshot could be replaced by a downloader before the claim, smuggling an
|
| 521 |
+
// automatic payload past the gate. A manually requested stage always
|
| 522 |
+
// applies. When disabled, restore the claim (never overwriting a newer
|
| 523 |
+
// stage) so a later launch without the opt-out can still apply it.
|
| 524 |
+
if (
|
| 525 |
+
staged.manual !== true &&
|
| 526 |
+
(isAutoUpdateDisabledByEnv(deps.env) || !(await shouldAutoInstallUpdates()))
|
| 527 |
+
) {
|
| 528 |
+
await restoreClaimedUpdate(deps.exePath, claimedPath);
|
| 529 |
+
return false;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// Re-verify the staged bytes against the recorded checksum: the exe could
|
| 533 |
+
// have been damaged on disk after the download verified it (corruption, a
|
| 534 |
+
// non-durable interrupted write), and the `--version` smoke check alone
|
| 535 |
+
// would not catch every such case. Only paid once the swap actually
|
| 536 |
+
// proceeds. A mismatch discards the stage so a later cycle re-downloads
|
| 537 |
+
// it — this is not a swap failure.
|
| 538 |
+
const digest = await hashFileSha256(stagedExePath(deps.exePath, staged));
|
| 539 |
+
if (digest !== staged.sha256) {
|
| 540 |
+
logSwap('staged exe failed checksum verification, discarding', {
|
| 541 |
+
version: staged.version,
|
| 542 |
+
});
|
| 543 |
+
return discard();
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
const stagedExe = stagedExePath(deps.exePath, staged);
|
| 547 |
+
|
| 548 |
+
// 1. Smoke-check the staged exe BEFORE touching the install path: a staged
|
| 549 |
+
// binary that cannot start (or lies about its version) is discarded with
|
| 550 |
+
// the running exe never moved — the safest possible failure shape.
|
| 551 |
+
if (!(await smokeCheck(stagedExe, staged, spawnImpl))) {
|
| 552 |
+
logSwap('smoke check failed, discarding staged update', { version: staged.version });
|
| 553 |
+
await recordSwapFailure(staged.version);
|
| 554 |
+
return discard();
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
// The fresh-claim sweep at startup is only a directory snapshot — another
|
| 558 |
+
// instance may have begun its swap after our sweep ran. Take the swap
|
| 559 |
+
// mutex before touching the install path so two swaps never rename the
|
| 560 |
+
// same exe concurrently (each would delete the other's `.bak` rollback
|
| 561 |
+
// source). The staged payload is immutable (unique generation name), so
|
| 562 |
+
// nothing validated above can change while we contend here.
|
| 563 |
+
const swapMutex = await acquireSwapMutex(getNativeStagingDir(deps.exePath));
|
| 564 |
+
if (swapMutex === null) {
|
| 565 |
+
logSwap('another instance is in its swap critical section, deferring', {
|
| 566 |
+
exePath: deps.exePath,
|
| 567 |
+
});
|
| 568 |
+
await restoreClaimedUpdate(deps.exePath, claimedPath);
|
| 569 |
+
return false;
|
| 570 |
+
}
|
| 571 |
+
try {
|
| 572 |
+
// 2. Pick a backup slot and move the running exe aside (rename of a running
|
| 573 |
+
// exe is legal on Windows and POSIX alike; overwriting is not).
|
| 574 |
+
//
|
| 575 |
+
// Crash window: if the process dies between this rename and step 3, the
|
| 576 |
+
// install path is left empty and no CLI code can run to self-heal. Each
|
| 577 |
+
// rename is atomic, the window is two adjacent syscalls, and recovery is
|
| 578 |
+
// `mv <exe>.bak <exe>` or re-running the install script.
|
| 579 |
+
let bakPath = `${deps.exePath}.bak`;
|
| 580 |
+
try {
|
| 581 |
+
await unlink(bakPath);
|
| 582 |
+
} catch (error) {
|
| 583 |
+
if (!isNotFound(error)) {
|
| 584 |
+
// The leftover `.bak` is locked by a still-running old instance (or
|
| 585 |
+
// undeletable for another reason) — take a unique backup name, the same
|
| 586 |
+
// fallback install.ps1 uses. It is best-effort cleaned up on later runs.
|
| 587 |
+
bakPath = `${deps.exePath}.${process.pid}.bak`;
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
try {
|
| 591 |
+
await rename(deps.exePath, bakPath);
|
| 592 |
+
} catch (error) {
|
| 593 |
+
// Nothing was moved: startup continues with the old exe. Restore the
|
| 594 |
+
// claimed metadata so a later launch retries the swap (transient locks
|
| 595 |
+
// clear on reboot) — but only into a still-free state-file path: a
|
| 596 |
+
// downloader may have published a NEWER stage while we smoke-checked,
|
| 597 |
+
// and an unconditional restore would silently replace it. The restore is
|
| 598 |
+
// create-if-absent, so it can never overwrite; when the path is taken,
|
| 599 |
+
// the newer stage wins and ours is discarded.
|
| 600 |
+
logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) });
|
| 601 |
+
await restoreClaimedUpdate(deps.exePath, claimedPath);
|
| 602 |
+
return false;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
// 3. Move the staged exe into place; roll back on failure.
|
| 606 |
+
if ((await rename(stagedExe, deps.exePath).catch(() => null)) === null) {
|
| 607 |
+
logSwap('failed to move staged exe into place, rolling back', { exePath: deps.exePath });
|
| 608 |
+
if (!(await rollback(bakPath, deps.exePath))) {
|
| 609 |
+
// Rollback failed too (transient file lock, AV, …): the install path is
|
| 610 |
+
// now absent and no next launch can start. Keep every artifact instead
|
| 611 |
+
// of discarding — the `.bak` IS the old exe and the staged payload is a
|
| 612 |
+
// second recovery copy, so `mv <exe>.bak <exe>` or re-running the
|
| 613 |
+
// installer still recovers.
|
| 614 |
+
logSwap('rollback failed, keeping recovery artifacts', {
|
| 615 |
+
exePath: deps.exePath,
|
| 616 |
+
bakPath,
|
| 617 |
+
});
|
| 618 |
+
await recordSwapFailure(staged.version);
|
| 619 |
+
return false;
|
| 620 |
+
}
|
| 621 |
+
await recordSwapFailure(staged.version);
|
| 622 |
+
return await discard();
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
// 4. Success: clean up, STILL INSIDE the mutex — a swap that acquires it
|
| 626 |
+
// the instant we release could rename the exe we just installed to the
|
| 627 |
+
// shared `.bak` path, and this cleanup would delete that rollback
|
| 628 |
+
// source. Then re-exec into the new binary.
|
| 629 |
+
await unlink(claimedPath).catch(() => {});
|
| 630 |
+
await unlink(bakPath).catch(() => {});
|
| 631 |
+
await cleanupBackups(deps.exePath, bakPath);
|
| 632 |
+
logSwap('swap succeeded, re-launching', { version: staged.version });
|
| 633 |
+
} finally {
|
| 634 |
+
await swapMutex.release();
|
| 635 |
+
}
|
| 636 |
+
// Cosmetic, now that the release removed our mutex file: drop the staging
|
| 637 |
+
// dir when empty. And re-exec OUTSIDE the critical section: the child runs
|
| 638 |
+
// the user session, so awaiting it inside the try would hold the mutex for
|
| 639 |
+
// its whole lifetime.
|
| 640 |
+
await rmdir(getNativeStagingDir(deps.exePath)).catch(() => {});
|
| 641 |
+
return reexec({ ...deps, spawnImpl });
|
| 642 |
+
}
|
apps/kimi-code/src/cli/update/preflight.ts
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { spawn } from 'node:child_process';
|
| 2 |
+
|
| 3 |
+
import { log, type Logger } from '@moonshot-ai/kimi-code-sdk';
|
| 4 |
+
import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry';
|
| 5 |
+
|
| 6 |
+
import {
|
| 7 |
+
kimiCodeOfficialInstallUrl,
|
| 8 |
+
nativeInstallCommandUnix,
|
| 9 |
+
nativeInstallCommandWin,
|
| 10 |
+
} from '#/constant/app';
|
| 11 |
+
import { loadTuiConfig } from '#/tui/config';
|
| 12 |
+
import { resolveCommandPath } from '#/utils/process/resolve-command';
|
| 13 |
+
|
| 14 |
+
import { readUpdateCache } from './cache';
|
| 15 |
+
import { tryAcquireUpdateInstallLock } from './install-lock';
|
| 16 |
+
import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state';
|
| 17 |
+
import {
|
| 18 |
+
CHANGELOG_URL,
|
| 19 |
+
promptForInstallChoice,
|
| 20 |
+
type InstallPromptChoiceValue,
|
| 21 |
+
type InstallPromptOptions,
|
| 22 |
+
} from './prompt';
|
| 23 |
+
import { refreshUpdateCache } from './refresh';
|
| 24 |
+
import {
|
| 25 |
+
appendRolloutDecisionLog,
|
| 26 |
+
decidePassiveUpdateTarget,
|
| 27 |
+
isRolloutBypassedByExperimentalEnv,
|
| 28 |
+
resolveUpdateDeviceId,
|
| 29 |
+
rolloutBucket,
|
| 30 |
+
rolloutDelayForBucket,
|
| 31 |
+
type PassiveUpdateDecision,
|
| 32 |
+
} from './rollout';
|
| 33 |
+
import { detectInstallSource } from './source';
|
| 34 |
+
import {
|
| 35 |
+
NPM_PACKAGE_NAME,
|
| 36 |
+
type InstallSource,
|
| 37 |
+
type UpdateDecision,
|
| 38 |
+
type UpdateInstallState,
|
| 39 |
+
type UpdateManifest,
|
| 40 |
+
type UpdatePreflightResult,
|
| 41 |
+
type UpdateTarget,
|
| 42 |
+
} from './types';
|
| 43 |
+
|
| 44 |
+
export type { UpdatePreflightResult } from './types';
|
| 45 |
+
|
| 46 |
+
export interface RunUpdatePreflightOptions {
|
| 47 |
+
readonly stdout?: { write(chunk: string): boolean };
|
| 48 |
+
readonly stderr?: { write(chunk: string): boolean };
|
| 49 |
+
readonly isTTY?: boolean;
|
| 50 |
+
readonly track?: (event: string, properties?: TelemetryProperties) => void;
|
| 51 |
+
readonly logger?: UpdateLogger;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2;
|
| 55 |
+
const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000;
|
| 56 |
+
const USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS = 1_000;
|
| 57 |
+
|
| 58 |
+
type UpdateLogger = Pick<Logger, 'info' | 'warn'>;
|
| 59 |
+
|
| 60 |
+
function withCmdSuffix(base: string, platform: NodeJS.Platform): string {
|
| 61 |
+
return platform === 'win32' ? `${base}.cmd` : base;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function bunCommand(platform: NodeJS.Platform): string {
|
| 65 |
+
return platform === 'win32' ? 'bun.exe' : 'bun';
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function installCommandFor(
|
| 69 |
+
source: InstallSource,
|
| 70 |
+
version: string,
|
| 71 |
+
platform: NodeJS.Platform,
|
| 72 |
+
): string {
|
| 73 |
+
switch (source) {
|
| 74 |
+
case 'npm-global':
|
| 75 |
+
return `npm install -g ${NPM_PACKAGE_NAME}@${version}`;
|
| 76 |
+
case 'pnpm-global':
|
| 77 |
+
return `pnpm add -g ${NPM_PACKAGE_NAME}@${version}`;
|
| 78 |
+
case 'yarn-global':
|
| 79 |
+
return `yarn global add ${NPM_PACKAGE_NAME}@${version}`;
|
| 80 |
+
case 'bun-global':
|
| 81 |
+
return `bun add -g ${NPM_PACKAGE_NAME}@${version}`;
|
| 82 |
+
case 'homebrew':
|
| 83 |
+
return 'brew upgrade kimi-code';
|
| 84 |
+
case 'native':
|
| 85 |
+
return platform === 'win32' ? nativeInstallCommandWin() : nativeInstallCommandUnix();
|
| 86 |
+
case 'unsupported':
|
| 87 |
+
return `npm install -g ${NPM_PACKAGE_NAME}@${version}`;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform): boolean {
|
| 92 |
+
switch (source) {
|
| 93 |
+
case 'npm-global':
|
| 94 |
+
case 'pnpm-global':
|
| 95 |
+
case 'yarn-global':
|
| 96 |
+
case 'bun-global':
|
| 97 |
+
return true;
|
| 98 |
+
case 'homebrew':
|
| 99 |
+
// Homebrew upgrade may mutate other dependents and the formula can lag
|
| 100 |
+
// behind the CDN release — prompt the user to run `brew upgrade` manually.
|
| 101 |
+
return false;
|
| 102 |
+
case 'native':
|
| 103 |
+
// Staged-swap self update works on every platform (win32 included).
|
| 104 |
+
return true;
|
| 105 |
+
case 'unsupported':
|
| 106 |
+
return false;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
interface SpawnCommand {
|
| 111 |
+
readonly cmd: string;
|
| 112 |
+
readonly args: readonly string[];
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export function spawnForSource(
|
| 116 |
+
source: InstallSource,
|
| 117 |
+
version: string,
|
| 118 |
+
platform: NodeJS.Platform,
|
| 119 |
+
): SpawnCommand {
|
| 120 |
+
switch (source) {
|
| 121 |
+
case 'npm-global':
|
| 122 |
+
return { cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
|
| 123 |
+
case 'pnpm-global':
|
| 124 |
+
return { cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
|
| 125 |
+
case 'yarn-global':
|
| 126 |
+
return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] };
|
| 127 |
+
case 'bun-global':
|
| 128 |
+
return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
|
| 129 |
+
case 'homebrew':
|
| 130 |
+
return { cmd: 'brew', args: ['upgrade', 'kimi-code'] };
|
| 131 |
+
case 'native':
|
| 132 |
+
// Native installs self-spawn the hidden downloader sub-command, which
|
| 133 |
+
// stages the binary next to the exe (verified against the release
|
| 134 |
+
// manifest's sha256); the swap happens on the next startup. This
|
| 135 |
+
// replaces the old `curl|bash` / `irm|iex` re-install dance — no shell,
|
| 136 |
+
// no pipeline exit-status loss, no PowerShell dependency on Windows.
|
| 137 |
+
return { cmd: process.execPath, args: ['__update_download', version] };
|
| 138 |
+
case 'unsupported':
|
| 139 |
+
throw new Error('unsupported install source cannot be auto-installed');
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function formatErrorMessage(error: unknown): string {
|
| 144 |
+
return error instanceof Error ? error.message : String(error);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/**
|
| 148 |
+
* Resolve a spawn target from `spawnForSource` to an absolute executable path
|
| 149 |
+
* via PATH, refusing hits inside the current working directory: the update
|
| 150 |
+
* preflight runs before the workspace trust gate, so a package-manager binary
|
| 151 |
+
* planted in an untrusted workspace must never be executed. On win32 the
|
| 152 |
+
* resolved path is quoted because the spawn goes through cmd.exe (shell:
|
| 153 |
+
* true) and paths like `C:\Program Files\...` would otherwise split. Returns
|
| 154 |
+
* undefined when the command cannot be safely resolved.
|
| 155 |
+
*/
|
| 156 |
+
function resolveSpawnCommand(cmd: string, platform: NodeJS.Platform): string | undefined {
|
| 157 |
+
const resolved = resolveCommandPath(cmd);
|
| 158 |
+
if (resolved === undefined) return undefined;
|
| 159 |
+
return platform === 'win32' ? `"${resolved}"` : resolved;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* Resolve the spawn target for an install. Package managers are resolved from
|
| 164 |
+
* `PATH` to an absolute executable via `resolveSpawnCommand` (workspace-trust
|
| 165 |
+
* safety, see above). The native self-spawn instead uses `process.execPath`
|
| 166 |
+
* verbatim — already absolute — and never goes through a shell. Returns the
|
| 167 |
+
* shell flag alongside, since Windows package-manager shims (.cmd) still
|
| 168 |
+
* need one.
|
| 169 |
+
*/
|
| 170 |
+
function resolveInstallSpawn(
|
| 171 |
+
source: InstallSource,
|
| 172 |
+
version: string,
|
| 173 |
+
platform: NodeJS.Platform,
|
| 174 |
+
options?: { readonly manual?: boolean },
|
| 175 |
+
): { readonly resolvedCmd: string; readonly args: readonly string[]; readonly shell: boolean } | undefined {
|
| 176 |
+
const { cmd, args } = spawnForSource(source, version, platform);
|
| 177 |
+
if (source === 'native') {
|
| 178 |
+
// A user-confirmed install marks the stage as manual so the startup swap
|
| 179 |
+
// applies it even when automatic updates are opted out via env.
|
| 180 |
+
return { resolvedCmd: cmd, args: options?.manual === true ? [...args, '--manual'] : args, shell: false };
|
| 181 |
+
}
|
| 182 |
+
const resolvedCmd = resolveSpawnCommand(cmd, platform);
|
| 183 |
+
if (resolvedCmd === undefined) return undefined;
|
| 184 |
+
return { resolvedCmd, args, shell: platform === 'win32' };
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// Built per call: the official-installer URL follows the current region.
|
| 188 |
+
function thirdPartySourceNote(): string {
|
| 189 |
+
return (
|
| 190 |
+
'\nNote: Third-party sources may lag behind the official release.\n' +
|
| 191 |
+
`For the latest updates, use the official installer: ${kimiCodeOfficialInstallUrl()}\n`
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
export function renderManualUpdateMessage(
|
| 196 |
+
currentVersion: string,
|
| 197 |
+
target: UpdateTarget,
|
| 198 |
+
source: InstallSource,
|
| 199 |
+
installCommand: string,
|
| 200 |
+
): string {
|
| 201 |
+
let sourceDesc: string;
|
| 202 |
+
switch (source) {
|
| 203 |
+
case 'npm-global':
|
| 204 |
+
case 'pnpm-global':
|
| 205 |
+
case 'yarn-global':
|
| 206 |
+
case 'bun-global':
|
| 207 |
+
sourceDesc = source;
|
| 208 |
+
break;
|
| 209 |
+
case 'homebrew':
|
| 210 |
+
sourceDesc = 'homebrew';
|
| 211 |
+
break;
|
| 212 |
+
case 'native':
|
| 213 |
+
sourceDesc = 'native installer';
|
| 214 |
+
break;
|
| 215 |
+
case 'unsupported':
|
| 216 |
+
sourceDesc = 'unsupported package manager or layout.';
|
| 217 |
+
break;
|
| 218 |
+
}
|
| 219 |
+
return (
|
| 220 |
+
`A newer version of ${NPM_PACKAGE_NAME} is available ` +
|
| 221 |
+
`(${currentVersion} -> ${target.version}).\n` +
|
| 222 |
+
`Detected install source: ${sourceDesc}\n` +
|
| 223 |
+
`To update manually, run: ${installCommand}\n` +
|
| 224 |
+
(source === 'homebrew' ? thirdPartySourceNote() : '')
|
| 225 |
+
);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export function renderInstallSuccessMessage(target: UpdateTarget): string {
|
| 229 |
+
return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function renderBackgroundInstallSuccessNotice(version: string): string {
|
| 233 |
+
const displayVersion = version.startsWith('v') ? version : `v${version}`;
|
| 234 |
+
return `Kimi Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function refreshInBackground(): void {
|
| 238 |
+
void refreshUpdateCache().catch(() => {});
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/** Telemetry properties describing where this device sits in the rollout. */
|
| 242 |
+
interface RolloutTelemetry {
|
| 243 |
+
readonly rollout_bucket: number;
|
| 244 |
+
readonly rollout_delay_seconds: number;
|
| 245 |
+
readonly rollout_from_manifest: boolean;
|
| 246 |
+
readonly rollout_bypassed: boolean;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function rolloutTelemetryFor(
|
| 250 |
+
deviceId: string,
|
| 251 |
+
targetVersion: string,
|
| 252 |
+
manifest: UpdateManifest | null,
|
| 253 |
+
bypassRollout: boolean,
|
| 254 |
+
): RolloutTelemetry {
|
| 255 |
+
const bucket = rolloutBucket(deviceId, targetVersion);
|
| 256 |
+
return {
|
| 257 |
+
rollout_bucket: bucket,
|
| 258 |
+
rollout_delay_seconds:
|
| 259 |
+
manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket),
|
| 260 |
+
rollout_from_manifest: manifest !== null,
|
| 261 |
+
rollout_bypassed: bypassRollout,
|
| 262 |
+
};
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh';
|
| 266 |
+
|
| 267 |
+
/** Record which case a passive version check hit in `updates/rollout.log`. */
|
| 268 |
+
function logRolloutDecision(
|
| 269 |
+
phase: RolloutCheckPhase,
|
| 270 |
+
currentVersion: string,
|
| 271 |
+
latest: string | null,
|
| 272 |
+
manifest: UpdateManifest | null,
|
| 273 |
+
decision: PassiveUpdateDecision,
|
| 274 |
+
): void {
|
| 275 |
+
void appendRolloutDecisionLog({
|
| 276 |
+
ts: nowIso(),
|
| 277 |
+
phase,
|
| 278 |
+
reason: decision.reason,
|
| 279 |
+
current: currentVersion,
|
| 280 |
+
latest,
|
| 281 |
+
target: decision.target?.version ?? null,
|
| 282 |
+
manifestPresent: manifest !== null,
|
| 283 |
+
publishedAt: manifest?.publishedAt ?? null,
|
| 284 |
+
bucket: decision.bucket,
|
| 285 |
+
delaySeconds: decision.delaySeconds,
|
| 286 |
+
eligibleAt: decision.eligibleAt,
|
| 287 |
+
});
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function refreshAndMaybeInstallInBackground(
|
| 291 |
+
currentVersion: string,
|
| 292 |
+
deviceId: string,
|
| 293 |
+
bypassRollout: boolean,
|
| 294 |
+
isInteractive: boolean,
|
| 295 |
+
installState: UpdateInstallState,
|
| 296 |
+
platform: NodeJS.Platform,
|
| 297 |
+
track: RunUpdatePreflightOptions['track'],
|
| 298 |
+
logger: UpdateLogger,
|
| 299 |
+
): void {
|
| 300 |
+
void (async () => {
|
| 301 |
+
const refreshed = await refreshUpdateCache();
|
| 302 |
+
if (!isInteractive) return;
|
| 303 |
+
const decision = decidePassiveUpdateTarget(
|
| 304 |
+
currentVersion,
|
| 305 |
+
refreshed.latest,
|
| 306 |
+
refreshed.manifest,
|
| 307 |
+
deviceId,
|
| 308 |
+
new Date(),
|
| 309 |
+
bypassRollout,
|
| 310 |
+
);
|
| 311 |
+
logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
|
| 312 |
+
const target = decision.target;
|
| 313 |
+
if (target === null) return;
|
| 314 |
+
const source = await detectInstallSource().catch(() => 'unsupported' as const);
|
| 315 |
+
await tryStartAutomaticBackgroundInstall(
|
| 316 |
+
installState,
|
| 317 |
+
currentVersion,
|
| 318 |
+
target,
|
| 319 |
+
source,
|
| 320 |
+
platform,
|
| 321 |
+
track,
|
| 322 |
+
logger,
|
| 323 |
+
rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout),
|
| 324 |
+
);
|
| 325 |
+
})().catch(() => {});
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
interface UserVisibleUpdateTarget {
|
| 329 |
+
readonly target: UpdateTarget | null;
|
| 330 |
+
readonly manifest: UpdateManifest | null;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
async function refreshUserVisibleUpdateTarget(
|
| 334 |
+
currentVersion: string,
|
| 335 |
+
deviceId: string,
|
| 336 |
+
bypassRollout: boolean,
|
| 337 |
+
fallbackTarget: UpdateTarget,
|
| 338 |
+
fallbackManifest: UpdateManifest | null,
|
| 339 |
+
): Promise<UserVisibleUpdateTarget> {
|
| 340 |
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
| 341 |
+
const fallback: UserVisibleUpdateTarget = {
|
| 342 |
+
target: fallbackTarget,
|
| 343 |
+
manifest: fallbackManifest,
|
| 344 |
+
};
|
| 345 |
+
try {
|
| 346 |
+
const refresh = refreshUpdateCache()
|
| 347 |
+
.then((refreshed) => {
|
| 348 |
+
const decision = decidePassiveUpdateTarget(
|
| 349 |
+
currentVersion,
|
| 350 |
+
refreshed.latest,
|
| 351 |
+
refreshed.manifest,
|
| 352 |
+
deviceId,
|
| 353 |
+
new Date(),
|
| 354 |
+
bypassRollout,
|
| 355 |
+
);
|
| 356 |
+
logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
|
| 357 |
+
return {
|
| 358 |
+
target: decision.target,
|
| 359 |
+
manifest: refreshed.manifest,
|
| 360 |
+
};
|
| 361 |
+
})
|
| 362 |
+
.catch(() => fallback);
|
| 363 |
+
const timeoutFallback = new Promise<UserVisibleUpdateTarget>((resolve) => {
|
| 364 |
+
timeout = setTimeout(() => {
|
| 365 |
+
resolve(fallback);
|
| 366 |
+
}, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS);
|
| 367 |
+
});
|
| 368 |
+
return await Promise.race([refresh, timeoutFallback]);
|
| 369 |
+
} catch {
|
| 370 |
+
return fallback;
|
| 371 |
+
} finally {
|
| 372 |
+
if (timeout !== undefined) {
|
| 373 |
+
clearTimeout(timeout);
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function nowIso(): string {
|
| 379 |
+
return new Date().toISOString();
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
function failureAttemptsFor(state: UpdateInstallState, target: UpdateTarget): number {
|
| 383 |
+
return state.lastFailure?.version === target.version ? state.lastFailure.attempts : 0;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function hasFreshActiveInstall(state: UpdateInstallState, target: UpdateTarget): boolean {
|
| 387 |
+
const active = state.active;
|
| 388 |
+
if (active === null || active.version !== target.version) return false;
|
| 389 |
+
const startedAt = Date.parse(active.startedAt);
|
| 390 |
+
if (!Number.isFinite(startedAt)) return false;
|
| 391 |
+
return Date.now() - startedAt < AUTO_INSTALL_ACTIVE_TTL_MS;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/**
|
| 395 |
+
* A fresh-looking `active` record is not proof of work for native installs:
|
| 396 |
+
* the parent that wrote it may have exited before the spawned downloader's
|
| 397 |
+
* exit event (or the downloader died before doing anything), and the 6 h TTL
|
| 398 |
+
* would then silently block every retry. Past the spawn grace window — the
|
| 399 |
+
* worker needs a moment to self-acquire the lock — lock liveness IS the
|
| 400 |
+
* truth: held ⇒ a download is running; free ⇒ the record is an orphan and
|
| 401 |
+
* the caller may start a new attempt. Package-manager sources have no such
|
| 402 |
+
* liveness signal and keep the TTL behavior above.
|
| 403 |
+
*/
|
| 404 |
+
const NATIVE_INSTALL_SPAWN_GRACE_MS = 60_000;
|
| 405 |
+
|
| 406 |
+
async function hasNativeInstallInFlight(
|
| 407 |
+
state: UpdateInstallState,
|
| 408 |
+
target: UpdateTarget,
|
| 409 |
+
): Promise<boolean> {
|
| 410 |
+
const active = state.active;
|
| 411 |
+
if (active === null || active.version !== target.version) return false;
|
| 412 |
+
const startedAt = Date.parse(active.startedAt);
|
| 413 |
+
if (Number.isFinite(startedAt) && Date.now() - startedAt < NATIVE_INSTALL_SPAWN_GRACE_MS) {
|
| 414 |
+
return true;
|
| 415 |
+
}
|
| 416 |
+
const probe = await tryAcquireUpdateInstallLock({ version: target.version });
|
| 417 |
+
if (probe === null) return true;
|
| 418 |
+
await probe.release().catch(() => {});
|
| 419 |
+
return false;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
async function hasInstallInFlight(
|
| 423 |
+
source: InstallSource,
|
| 424 |
+
state: UpdateInstallState,
|
| 425 |
+
target: UpdateTarget,
|
| 426 |
+
): Promise<boolean> {
|
| 427 |
+
return source === 'native'
|
| 428 |
+
? hasNativeInstallInFlight(state, target)
|
| 429 |
+
: hasFreshActiveInstall(state, target);
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
async function showPendingBackgroundInstallNotice(
|
| 433 |
+
state: UpdateInstallState,
|
| 434 |
+
currentVersion: string,
|
| 435 |
+
stdout: { write(chunk: string): boolean },
|
| 436 |
+
track: RunUpdatePreflightOptions['track'],
|
| 437 |
+
logger: UpdateLogger,
|
| 438 |
+
): Promise<UpdateInstallState> {
|
| 439 |
+
const success = state.lastSuccess;
|
| 440 |
+
if (success !== null && success.notifiedAt === null && success.version === currentVersion) {
|
| 441 |
+
stdout.write(renderBackgroundInstallSuccessNotice(success.version));
|
| 442 |
+
trackUpdateEvent(track, 'update_success_notice_shown', {
|
| 443 |
+
version: success.version,
|
| 444 |
+
inferred_from_active: false,
|
| 445 |
+
});
|
| 446 |
+
logUpdateInfo(logger, 'background update success notice shown', {
|
| 447 |
+
version: success.version,
|
| 448 |
+
inferredFromActive: false,
|
| 449 |
+
});
|
| 450 |
+
const nextState: UpdateInstallState = {
|
| 451 |
+
...state,
|
| 452 |
+
active: null,
|
| 453 |
+
lastFailure: null,
|
| 454 |
+
lastSuccess: {
|
| 455 |
+
...success,
|
| 456 |
+
notifiedAt: nowIso(),
|
| 457 |
+
},
|
| 458 |
+
};
|
| 459 |
+
await writeUpdateInstallState(nextState).catch(() => {});
|
| 460 |
+
return nextState;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
const active = state.active;
|
| 464 |
+
if (active === null || active.version !== currentVersion) return state;
|
| 465 |
+
if (success !== null && success.version === currentVersion && success.notifiedAt !== null) {
|
| 466 |
+
return state;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
const notifiedAt = nowIso();
|
| 470 |
+
stdout.write(renderBackgroundInstallSuccessNotice(active.version));
|
| 471 |
+
trackUpdateEvent(track, 'update_success_notice_shown', {
|
| 472 |
+
version: active.version,
|
| 473 |
+
inferred_from_active: true,
|
| 474 |
+
});
|
| 475 |
+
logUpdateInfo(logger, 'background update success notice shown', {
|
| 476 |
+
version: active.version,
|
| 477 |
+
inferredFromActive: true,
|
| 478 |
+
});
|
| 479 |
+
const nextState: UpdateInstallState = {
|
| 480 |
+
...state,
|
| 481 |
+
active: null,
|
| 482 |
+
lastFailure: null,
|
| 483 |
+
lastSuccess: {
|
| 484 |
+
version: active.version,
|
| 485 |
+
installedAt: notifiedAt,
|
| 486 |
+
notifiedAt,
|
| 487 |
+
},
|
| 488 |
+
};
|
| 489 |
+
await writeUpdateInstallState(nextState).catch(() => {});
|
| 490 |
+
return nextState;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
/**
|
| 494 |
+
* `KIMI_CODE_NO_AUTO_UPDATE` (or the legacy `KIMI_CLI_NO_AUTO_UPDATE` alias)
|
| 495 |
+
* fully disables automatic update behavior — no check, no background install,
|
| 496 |
+
* no prompt, and no staged-swap at startup (see `native-swap.ts`). Migrated
|
| 497 |
+
* from kimi-cli, where the variable gated all auto-update behavior. Accepts
|
| 498 |
+
* the usual truthy values (`1`/`true`/`yes`/`on`).
|
| 499 |
+
*/
|
| 500 |
+
export function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
| 501 |
+
const truthy = (value?: string): boolean =>
|
| 502 |
+
['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
|
| 503 |
+
return truthy(env['KIMI_CODE_NO_AUTO_UPDATE']) || truthy(env['KIMI_CLI_NO_AUTO_UPDATE']);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
/**
|
| 507 |
+
* The persisted `[upgrade].auto_install` preference (defaults to true when
|
| 508 |
+
* the config cannot be read). Gates the passive background install — and the
|
| 509 |
+
* startup swap of automatically staged payloads (see `native-swap.ts`).
|
| 510 |
+
*/
|
| 511 |
+
export async function shouldAutoInstallUpdates(): Promise<boolean> {
|
| 512 |
+
try {
|
| 513 |
+
const config = await loadTuiConfig();
|
| 514 |
+
return config.upgrade.autoInstall;
|
| 515 |
+
} catch {
|
| 516 |
+
return true;
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
function trackUpdatePrompted(
|
| 521 |
+
track: RunUpdatePreflightOptions['track'],
|
| 522 |
+
currentVersion: string,
|
| 523 |
+
target: UpdateTarget,
|
| 524 |
+
source: InstallSource,
|
| 525 |
+
decision: UpdateDecision,
|
| 526 |
+
rolloutTelemetry: RolloutTelemetry,
|
| 527 |
+
): void {
|
| 528 |
+
trackUpdateEvent(track, 'update_prompted', {
|
| 529 |
+
current_version: currentVersion,
|
| 530 |
+
target_version: target.version,
|
| 531 |
+
source,
|
| 532 |
+
decision,
|
| 533 |
+
...rolloutTelemetry,
|
| 534 |
+
});
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
function trackUpdateEvent(
|
| 538 |
+
track: RunUpdatePreflightOptions['track'],
|
| 539 |
+
event: string,
|
| 540 |
+
properties: TelemetryProperties,
|
| 541 |
+
): void {
|
| 542 |
+
try {
|
| 543 |
+
track?.(event, properties);
|
| 544 |
+
} catch {
|
| 545 |
+
// Telemetry must never affect update prompting.
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
function logUpdateInfo(logger: UpdateLogger, message: string, payload: Record<string, unknown>): void {
|
| 550 |
+
try {
|
| 551 |
+
logger.info(message, payload);
|
| 552 |
+
} catch {
|
| 553 |
+
// Diagnostic logging must never affect update prompting.
|
| 554 |
+
}
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
function logUpdateWarn(logger: UpdateLogger, message: string, payload: Record<string, unknown>): void {
|
| 558 |
+
try {
|
| 559 |
+
logger.warn(message, payload);
|
| 560 |
+
} catch {
|
| 561 |
+
// Diagnostic logging must never affect update prompting.
|
| 562 |
+
}
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
async function promptInstall(
|
| 566 |
+
currentVersion: string,
|
| 567 |
+
target: UpdateTarget,
|
| 568 |
+
source: InstallSource,
|
| 569 |
+
installCommand: string,
|
| 570 |
+
): Promise<InstallPromptChoiceValue> {
|
| 571 |
+
const options: InstallPromptOptions = {
|
| 572 |
+
currentVersion,
|
| 573 |
+
target,
|
| 574 |
+
installSource: source,
|
| 575 |
+
installCommand,
|
| 576 |
+
};
|
| 577 |
+
return promptForInstallChoice(options);
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
export async function installUpdate(
|
| 581 |
+
source: InstallSource,
|
| 582 |
+
version: string,
|
| 583 |
+
platform: NodeJS.Platform,
|
| 584 |
+
): Promise<void> {
|
| 585 |
+
// installUpdate only runs after an explicit user choice (the `upgrade`
|
| 586 |
+
// command or the interactive prompt) — mark the stage as manual.
|
| 587 |
+
const spawnTarget = resolveInstallSpawn(source, version, platform, { manual: true });
|
| 588 |
+
if (spawnTarget === undefined) {
|
| 589 |
+
throw new Error(
|
| 590 |
+
`${spawnForSource(source, version, platform).cmd} was not found in PATH; cannot install the update`,
|
| 591 |
+
);
|
| 592 |
+
}
|
| 593 |
+
await new Promise<void>((resolve, reject) => {
|
| 594 |
+
// Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the
|
| 595 |
+
// CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without
|
| 596 |
+
// a shell, so run through the shell on win32. The version is a validated
|
| 597 |
+
// semver and the package name is a constant, so args are shell-safe. The
|
| 598 |
+
// native self-spawn is an .exe and needs no shell.
|
| 599 |
+
const child = spawn(spawnTarget.resolvedCmd, [...spawnTarget.args], {
|
| 600 |
+
stdio: 'inherit',
|
| 601 |
+
shell: spawnTarget.shell ? true : undefined,
|
| 602 |
+
});
|
| 603 |
+
child.once('error', reject);
|
| 604 |
+
child.once('exit', (code, signal) => {
|
| 605 |
+
if (code === 0) {
|
| 606 |
+
resolve();
|
| 607 |
+
return;
|
| 608 |
+
}
|
| 609 |
+
const detail = signal !== null ? `signal ${signal}` : `code ${String(code)}`;
|
| 610 |
+
reject(new Error(`update install exited with ${detail}`));
|
| 611 |
+
});
|
| 612 |
+
});
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
async function startBackgroundInstall(
|
| 616 |
+
state: UpdateInstallState,
|
| 617 |
+
currentVersion: string,
|
| 618 |
+
target: UpdateTarget,
|
| 619 |
+
source: InstallSource,
|
| 620 |
+
platform: NodeJS.Platform,
|
| 621 |
+
track: RunUpdatePreflightOptions['track'],
|
| 622 |
+
logger: UpdateLogger,
|
| 623 |
+
rolloutTelemetry: RolloutTelemetry,
|
| 624 |
+
): Promise<void> {
|
| 625 |
+
// The native self-spawned downloader holds the install lock itself for the
|
| 626 |
+
// whole download — taking it here too would race the child (it starts before
|
| 627 |
+
// this function's finally releases) into a false success. Package-manager
|
| 628 |
+
// installs keep the outer lock, which only guards against duplicate spawns.
|
| 629 |
+
const lock =
|
| 630 |
+
source === 'native'
|
| 631 |
+
? { filePath: '', release: async (): Promise<void> => {} }
|
| 632 |
+
: await tryAcquireUpdateInstallLock({ version: target.version });
|
| 633 |
+
if (lock === null) return;
|
| 634 |
+
|
| 635 |
+
try {
|
| 636 |
+
const freshState = await readUpdateInstallState().catch(() => state);
|
| 637 |
+
if (
|
| 638 |
+
(await hasInstallInFlight(source, freshState, target)) ||
|
| 639 |
+
failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD
|
| 640 |
+
) {
|
| 641 |
+
return;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
const startedState: UpdateInstallState = {
|
| 645 |
+
...freshState,
|
| 646 |
+
active: {
|
| 647 |
+
version: target.version,
|
| 648 |
+
source,
|
| 649 |
+
startedAt: nowIso(),
|
| 650 |
+
},
|
| 651 |
+
};
|
| 652 |
+
await writeUpdateInstallState(startedState);
|
| 653 |
+
trackUpdateEvent(track, 'update_background_install_started', {
|
| 654 |
+
current_version: currentVersion,
|
| 655 |
+
target_version: target.version,
|
| 656 |
+
source,
|
| 657 |
+
...rolloutTelemetry,
|
| 658 |
+
});
|
| 659 |
+
logUpdateInfo(logger, 'background update install started', {
|
| 660 |
+
currentVersion,
|
| 661 |
+
targetVersion: target.version,
|
| 662 |
+
source,
|
| 663 |
+
});
|
| 664 |
+
|
| 665 |
+
const spawnTarget = resolveInstallSpawn(source, target.version, platform);
|
| 666 |
+
let settled = false;
|
| 667 |
+
|
| 668 |
+
const finish = (succeeded: boolean): void => {
|
| 669 |
+
if (settled) return;
|
| 670 |
+
settled = true;
|
| 671 |
+
const attempts = failureAttemptsFor(startedState, target) + 1;
|
| 672 |
+
|
| 673 |
+
const nextState: UpdateInstallState = succeeded
|
| 674 |
+
? {
|
| 675 |
+
...startedState,
|
| 676 |
+
active: null,
|
| 677 |
+
lastFailure: null,
|
| 678 |
+
lastSuccess: {
|
| 679 |
+
version: target.version,
|
| 680 |
+
installedAt: nowIso(),
|
| 681 |
+
notifiedAt: null,
|
| 682 |
+
},
|
| 683 |
+
}
|
| 684 |
+
: {
|
| 685 |
+
...startedState,
|
| 686 |
+
active: null,
|
| 687 |
+
lastFailure: {
|
| 688 |
+
version: target.version,
|
| 689 |
+
failedAt: nowIso(),
|
| 690 |
+
attempts,
|
| 691 |
+
},
|
| 692 |
+
};
|
| 693 |
+
void writeUpdateInstallState(nextState).catch(() => {});
|
| 694 |
+
if (succeeded) {
|
| 695 |
+
trackUpdateEvent(track, 'update_background_install_succeeded', {
|
| 696 |
+
target_version: target.version,
|
| 697 |
+
source,
|
| 698 |
+
});
|
| 699 |
+
logUpdateInfo(logger, 'background update install succeeded', {
|
| 700 |
+
targetVersion: target.version,
|
| 701 |
+
source,
|
| 702 |
+
});
|
| 703 |
+
return;
|
| 704 |
+
}
|
| 705 |
+
trackUpdateEvent(track, 'update_background_install_failed', {
|
| 706 |
+
target_version: target.version,
|
| 707 |
+
source,
|
| 708 |
+
attempts,
|
| 709 |
+
});
|
| 710 |
+
logUpdateWarn(logger, 'background update install failed', {
|
| 711 |
+
targetVersion: target.version,
|
| 712 |
+
source,
|
| 713 |
+
attempts,
|
| 714 |
+
});
|
| 715 |
+
};
|
| 716 |
+
|
| 717 |
+
if (spawnTarget === undefined) {
|
| 718 |
+
// The package manager cannot be resolved to an absolute path outside
|
| 719 |
+
// the cwd — record a normal install failure instead of spawning a bare
|
| 720 |
+
// command name that Windows would resolve into the untrusted workspace.
|
| 721 |
+
finish(false);
|
| 722 |
+
return;
|
| 723 |
+
}
|
| 724 |
+
const child = spawn(spawnTarget.resolvedCmd, [...spawnTarget.args], {
|
| 725 |
+
detached: true,
|
| 726 |
+
stdio: 'ignore',
|
| 727 |
+
shell: spawnTarget.shell ? true : undefined,
|
| 728 |
+
// On Windows a detached child gets its own console window; with shell:true
|
| 729 |
+
// that window would flash during a passive background update. Hide it so
|
| 730 |
+
// the silent updater stays silent.
|
| 731 |
+
windowsHide: platform === 'win32' ? true : undefined,
|
| 732 |
+
});
|
| 733 |
+
child.once('error', () => { finish(false); });
|
| 734 |
+
child.once('exit', (code) => { finish(code === 0); });
|
| 735 |
+
child.unref();
|
| 736 |
+
} finally {
|
| 737 |
+
await lock.release().catch(() => {});
|
| 738 |
+
}
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
async function tryStartAutomaticBackgroundInstall(
|
| 742 |
+
installState: UpdateInstallState,
|
| 743 |
+
currentVersion: string,
|
| 744 |
+
target: UpdateTarget,
|
| 745 |
+
source: InstallSource,
|
| 746 |
+
platform: NodeJS.Platform,
|
| 747 |
+
track: RunUpdatePreflightOptions['track'],
|
| 748 |
+
logger: UpdateLogger,
|
| 749 |
+
rolloutTelemetry: RolloutTelemetry,
|
| 750 |
+
): Promise<boolean> {
|
| 751 |
+
const sourceCanAutoInstall = canAutoInstall(source, platform);
|
| 752 |
+
const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false;
|
| 753 |
+
if (!autoInstallUpdates || !sourceCanAutoInstall) return false;
|
| 754 |
+
if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) {
|
| 755 |
+
return false;
|
| 756 |
+
}
|
| 757 |
+
if (!(await hasInstallInFlight(source, installState, target))) {
|
| 758 |
+
await startBackgroundInstall(
|
| 759 |
+
installState,
|
| 760 |
+
currentVersion,
|
| 761 |
+
target,
|
| 762 |
+
source,
|
| 763 |
+
platform,
|
| 764 |
+
track,
|
| 765 |
+
logger,
|
| 766 |
+
rolloutTelemetry,
|
| 767 |
+
).catch(() => {});
|
| 768 |
+
}
|
| 769 |
+
return true;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
export function decideUpdateAction(
|
| 773 |
+
target: UpdateTarget | null,
|
| 774 |
+
isInteractive: boolean,
|
| 775 |
+
source: InstallSource,
|
| 776 |
+
platform: NodeJS.Platform,
|
| 777 |
+
): UpdateDecision {
|
| 778 |
+
if (target === null || !isInteractive) return 'none';
|
| 779 |
+
return canAutoInstall(source, platform) ? 'prompt-install' : 'manual-command';
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
export async function runUpdatePreflight(
|
| 783 |
+
currentVersion: string,
|
| 784 |
+
options: RunUpdatePreflightOptions = {},
|
| 785 |
+
): Promise<UpdatePreflightResult> {
|
| 786 |
+
const stdout = options.stdout ?? process.stdout;
|
| 787 |
+
const stderr = options.stderr ?? process.stderr;
|
| 788 |
+
const logger = options.logger ?? log;
|
| 789 |
+
const platform = process.platform;
|
| 790 |
+
|
| 791 |
+
if (isAutoUpdateDisabledByEnv()) {
|
| 792 |
+
return 'continue';
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
try {
|
| 796 |
+
const isInteractive =
|
| 797 |
+
options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
|
| 798 |
+
const deviceId = resolveUpdateDeviceId();
|
| 799 |
+
const bypassRollout = isRolloutBypassedByExperimentalEnv();
|
| 800 |
+
let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState());
|
| 801 |
+
if (isInteractive) {
|
| 802 |
+
installState = await showPendingBackgroundInstallNotice(
|
| 803 |
+
installState,
|
| 804 |
+
currentVersion,
|
| 805 |
+
stdout,
|
| 806 |
+
options.track,
|
| 807 |
+
logger,
|
| 808 |
+
);
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
const cache = await readUpdateCache().catch(() => null);
|
| 812 |
+
const cachedManifest = cache?.manifest ?? null;
|
| 813 |
+
const cachedDecision = decidePassiveUpdateTarget(
|
| 814 |
+
currentVersion,
|
| 815 |
+
cache?.latest ?? null,
|
| 816 |
+
cachedManifest,
|
| 817 |
+
deviceId,
|
| 818 |
+
new Date(),
|
| 819 |
+
bypassRollout,
|
| 820 |
+
);
|
| 821 |
+
logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision);
|
| 822 |
+
const target = cachedDecision.target;
|
| 823 |
+
if (target === null) {
|
| 824 |
+
refreshAndMaybeInstallInBackground(
|
| 825 |
+
currentVersion,
|
| 826 |
+
deviceId,
|
| 827 |
+
bypassRollout,
|
| 828 |
+
isInteractive,
|
| 829 |
+
installState,
|
| 830 |
+
platform,
|
| 831 |
+
options.track,
|
| 832 |
+
logger,
|
| 833 |
+
);
|
| 834 |
+
return 'continue';
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
const source: InstallSource =
|
| 838 |
+
!isInteractive
|
| 839 |
+
? 'unsupported'
|
| 840 |
+
: await detectInstallSource().catch(() => 'unsupported' as const);
|
| 841 |
+
|
| 842 |
+
const decision = decideUpdateAction(target, isInteractive, source, platform);
|
| 843 |
+
if (decision === 'none') {
|
| 844 |
+
refreshInBackground();
|
| 845 |
+
return 'continue';
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
if (
|
| 849 |
+
await tryStartAutomaticBackgroundInstall(
|
| 850 |
+
installState,
|
| 851 |
+
currentVersion,
|
| 852 |
+
target,
|
| 853 |
+
source,
|
| 854 |
+
platform,
|
| 855 |
+
options.track,
|
| 856 |
+
logger,
|
| 857 |
+
rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout),
|
| 858 |
+
)
|
| 859 |
+
) {
|
| 860 |
+
refreshInBackground();
|
| 861 |
+
return 'continue';
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
const userVisibleUpdate = await refreshUserVisibleUpdateTarget(
|
| 865 |
+
currentVersion,
|
| 866 |
+
deviceId,
|
| 867 |
+
bypassRollout,
|
| 868 |
+
target,
|
| 869 |
+
cachedManifest,
|
| 870 |
+
);
|
| 871 |
+
const userVisibleTarget = userVisibleUpdate.target;
|
| 872 |
+
if (userVisibleTarget === null) return 'continue';
|
| 873 |
+
const userVisibleRollout = rolloutTelemetryFor(
|
| 874 |
+
deviceId,
|
| 875 |
+
userVisibleTarget.version,
|
| 876 |
+
userVisibleUpdate.manifest,
|
| 877 |
+
bypassRollout,
|
| 878 |
+
);
|
| 879 |
+
if (
|
| 880 |
+
await tryStartAutomaticBackgroundInstall(
|
| 881 |
+
installState,
|
| 882 |
+
currentVersion,
|
| 883 |
+
userVisibleTarget,
|
| 884 |
+
source,
|
| 885 |
+
platform,
|
| 886 |
+
options.track,
|
| 887 |
+
logger,
|
| 888 |
+
userVisibleRollout,
|
| 889 |
+
)
|
| 890 |
+
) {
|
| 891 |
+
return 'continue';
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
const installCommand = installCommandFor(source, userVisibleTarget.version, platform);
|
| 895 |
+
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout);
|
| 896 |
+
|
| 897 |
+
if (decision === 'manual-command') {
|
| 898 |
+
stdout.write(renderManualUpdateMessage(
|
| 899 |
+
currentVersion,
|
| 900 |
+
userVisibleTarget,
|
| 901 |
+
source,
|
| 902 |
+
installCommand,
|
| 903 |
+
));
|
| 904 |
+
return 'continue';
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
const choice = await promptInstall(currentVersion, userVisibleTarget, source, installCommand);
|
| 908 |
+
if (choice === 'skip') return 'continue';
|
| 909 |
+
|
| 910 |
+
try {
|
| 911 |
+
await installUpdate(source, userVisibleTarget.version, platform);
|
| 912 |
+
stdout.write(renderInstallSuccessMessage(userVisibleTarget));
|
| 913 |
+
return 'exit';
|
| 914 |
+
} catch (error) {
|
| 915 |
+
stderr.write(
|
| 916 |
+
`warning: failed to install ${NPM_PACKAGE_NAME}@${userVisibleTarget.version}: ` +
|
| 917 |
+
`${formatErrorMessage(error)}\n`,
|
| 918 |
+
);
|
| 919 |
+
return 'continue';
|
| 920 |
+
}
|
| 921 |
+
} catch {
|
| 922 |
+
return 'continue';
|
| 923 |
+
}
|
| 924 |
+
}
|
apps/kimi-code/src/cli/update/prompt.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clearLine, cursorTo, emitKeypressEvents, moveCursor } from 'node:readline';
|
| 2 |
+
|
| 3 |
+
import chalk from 'chalk';
|
| 4 |
+
|
| 5 |
+
import { PRODUCT_NAME } from '#/constant/app';
|
| 6 |
+
import { HIDE_CURSOR, SHOW_CURSOR } from '#/constant/terminal';
|
| 7 |
+
import {
|
| 8 |
+
UPDATE_PROMPT_MUTED,
|
| 9 |
+
UPDATE_PROMPT_PRIMARY,
|
| 10 |
+
UPDATE_PROMPT_SUCCESS,
|
| 11 |
+
UPDATE_PROMPT_TEXT_DIM,
|
| 12 |
+
UPDATE_PROMPT_WARNING,
|
| 13 |
+
} from '#/constant/update';
|
| 14 |
+
|
| 15 |
+
import { type InstallSource, type UpdateTarget } from './types';
|
| 16 |
+
|
| 17 |
+
export const CHANGELOG_URL = 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html';
|
| 18 |
+
|
| 19 |
+
export type InstallPromptChoiceValue = 'install' | 'skip';
|
| 20 |
+
|
| 21 |
+
export interface InstallPromptChoice {
|
| 22 |
+
readonly value: InstallPromptChoiceValue;
|
| 23 |
+
readonly label: string;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface InstallPromptOptions {
|
| 27 |
+
readonly currentVersion: string;
|
| 28 |
+
readonly target: UpdateTarget;
|
| 29 |
+
readonly installCommand: string;
|
| 30 |
+
readonly installSource: InstallSource;
|
| 31 |
+
readonly input?: NodeJS.ReadStream;
|
| 32 |
+
readonly output?: NodeJS.WriteStream;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const INSTALL_HINT = 'Install update now';
|
| 36 |
+
const SKIP_HINT = 'Continue with current version';
|
| 37 |
+
|
| 38 |
+
export function createInstallPromptChoices(target: UpdateTarget): readonly InstallPromptChoice[] {
|
| 39 |
+
return [
|
| 40 |
+
{ value: 'install', label: `${INSTALL_HINT} (${target.version})` },
|
| 41 |
+
{ value: 'skip', label: SKIP_HINT },
|
| 42 |
+
];
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function getDefaultInstallPromptSelection(choices: readonly InstallPromptChoice[]): number {
|
| 46 |
+
const installIndex = choices.findIndex((choice) => choice.value === 'install');
|
| 47 |
+
return Math.max(installIndex, 0);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function moveInstallPromptSelection(
|
| 51 |
+
currentIndex: number,
|
| 52 |
+
direction: 'up' | 'down',
|
| 53 |
+
choiceCount: number,
|
| 54 |
+
): number {
|
| 55 |
+
if (direction === 'up') {
|
| 56 |
+
return Math.max(0, currentIndex - 1);
|
| 57 |
+
}
|
| 58 |
+
return Math.min(choiceCount - 1, currentIndex + 1);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function renderInstallPrompt(
|
| 62 |
+
options: InstallPromptOptions,
|
| 63 |
+
choices: readonly InstallPromptChoice[],
|
| 64 |
+
selectedIndex: number,
|
| 65 |
+
): readonly string[] {
|
| 66 |
+
const label = chalk.hex(UPDATE_PROMPT_TEXT_DIM).bold;
|
| 67 |
+
const currentVersion = chalk.hex(UPDATE_PROMPT_WARNING).bold(options.currentVersion);
|
| 68 |
+
const targetVersion = chalk.hex(UPDATE_PROMPT_SUCCESS).bold(options.target.version);
|
| 69 |
+
const sourceLabel = chalk.hex(UPDATE_PROMPT_PRIMARY).bold(options.installSource);
|
| 70 |
+
const command = chalk.hex(UPDATE_PROMPT_PRIMARY)(options.installCommand);
|
| 71 |
+
const changelogText = chalk.hex(UPDATE_PROMPT_PRIMARY).underline(`View changelog: ${CHANGELOG_URL}`);
|
| 72 |
+
const lines = [
|
| 73 |
+
chalk.hex(UPDATE_PROMPT_PRIMARY).bold('Kimi Code Update Available'),
|
| 74 |
+
chalk.hex(UPDATE_PROMPT_MUTED)(`${PRODUCT_NAME} has a newer release ready.`),
|
| 75 |
+
`]8;;${CHANGELOG_URL}\\${changelogText}]8;;\\`,
|
| 76 |
+
'',
|
| 77 |
+
`${label('Current')} ${currentVersion}`,
|
| 78 |
+
`${label('Target ')} ${targetVersion}`,
|
| 79 |
+
`${label('Source ')} ${sourceLabel}`,
|
| 80 |
+
`${label('Command')} ${command}`,
|
| 81 |
+
'',
|
| 82 |
+
chalk.hex(UPDATE_PROMPT_MUTED)('↑↓ choose · Enter confirm · Esc continue'),
|
| 83 |
+
'',
|
| 84 |
+
];
|
| 85 |
+
|
| 86 |
+
for (let i = 0; i < choices.length; i++) {
|
| 87 |
+
const choice = choices[i];
|
| 88 |
+
if (choice === undefined) continue;
|
| 89 |
+
const isSelected = i === selectedIndex;
|
| 90 |
+
const pointer = isSelected ? '❯' : ' ';
|
| 91 |
+
const content = ` ${pointer} ${choice.label}`;
|
| 92 |
+
if (isSelected) {
|
| 93 |
+
lines.push(chalk.hex(UPDATE_PROMPT_PRIMARY).bold(content));
|
| 94 |
+
continue;
|
| 95 |
+
}
|
| 96 |
+
lines.push(chalk.hex(UPDATE_PROMPT_TEXT_DIM)(content));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return lines;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function writePromptFrame(
|
| 103 |
+
output: NodeJS.WriteStream,
|
| 104 |
+
lines: readonly string[],
|
| 105 |
+
previousLineCount: number,
|
| 106 |
+
): number {
|
| 107 |
+
if (previousLineCount > 0) {
|
| 108 |
+
moveCursor(output, 0, -(previousLineCount - 1));
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
for (let i = 0; i < lines.length; i++) {
|
| 112 |
+
clearLine(output, 0);
|
| 113 |
+
cursorTo(output, 0);
|
| 114 |
+
output.write(lines[i] ?? '');
|
| 115 |
+
if (i < lines.length - 1) {
|
| 116 |
+
output.write('\n');
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return lines.length;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export async function promptForInstallChoice(
|
| 124 |
+
options: InstallPromptOptions,
|
| 125 |
+
): Promise<InstallPromptChoiceValue> {
|
| 126 |
+
const input = options.input ?? process.stdin;
|
| 127 |
+
const output = options.output ?? process.stdout;
|
| 128 |
+
const choices = createInstallPromptChoices(options.target);
|
| 129 |
+
let selectedIndex = getDefaultInstallPromptSelection(choices);
|
| 130 |
+
|
| 131 |
+
return new Promise<InstallPromptChoiceValue>((resolve) => {
|
| 132 |
+
let lineCount = 0;
|
| 133 |
+
const hadRawMode = 'isRaw' in input ? input.isRaw : false;
|
| 134 |
+
const canSetRawMode = typeof input.setRawMode === 'function';
|
| 135 |
+
|
| 136 |
+
const cleanup = (): void => {
|
| 137 |
+
input.off('keypress', onKeypress);
|
| 138 |
+
if (canSetRawMode) {
|
| 139 |
+
input.setRawMode(hadRawMode);
|
| 140 |
+
}
|
| 141 |
+
output.write(SHOW_CURSOR);
|
| 142 |
+
output.write('\n');
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const finish = (choice: InstallPromptChoiceValue): void => {
|
| 146 |
+
cleanup();
|
| 147 |
+
resolve(choice);
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
const render = (): void => {
|
| 151 |
+
lineCount = writePromptFrame(
|
| 152 |
+
output,
|
| 153 |
+
renderInstallPrompt(options, choices, selectedIndex),
|
| 154 |
+
lineCount,
|
| 155 |
+
);
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const onKeypress = (_input: string, key: { name?: string; ctrl?: boolean }): void => {
|
| 159 |
+
if (key.name === 'up') {
|
| 160 |
+
selectedIndex = moveInstallPromptSelection(selectedIndex, 'up', choices.length);
|
| 161 |
+
render();
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
if (key.name === 'down') {
|
| 165 |
+
selectedIndex = moveInstallPromptSelection(selectedIndex, 'down', choices.length);
|
| 166 |
+
render();
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
if (key.name === 'return' || key.name === 'enter') {
|
| 170 |
+
const chosen = choices[selectedIndex]?.value ?? 'skip';
|
| 171 |
+
finish(chosen);
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
if (key.name === 'escape' || (key.ctrl === true && key.name === 'c')) {
|
| 175 |
+
finish('skip');
|
| 176 |
+
}
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
emitKeypressEvents(input);
|
| 180 |
+
if (canSetRawMode) {
|
| 181 |
+
input.setRawMode(true);
|
| 182 |
+
}
|
| 183 |
+
input.resume();
|
| 184 |
+
input.on('keypress', onKeypress);
|
| 185 |
+
output.write(HIDE_CURSOR);
|
| 186 |
+
render();
|
| 187 |
+
});
|
| 188 |
+
}
|
apps/kimi-code/src/cli/update/refresh.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { writeUpdateCache } from './cache';
|
| 2 |
+
import { fetchLatestFromCdn, type FetchLatestResult } from './cdn';
|
| 3 |
+
import { type UpdateCache } from './types';
|
| 4 |
+
|
| 5 |
+
export interface RefreshUpdateCacheDeps {
|
| 6 |
+
/** Resolves with the latest version + rollout manifest. **Throws** on any
|
| 7 |
+
* failure — callers (including the default background invocation in
|
| 8 |
+
* preflight) must catch. Errors intentionally skip `writeCache` so a
|
| 9 |
+
* transient CDN blip does not overwrite a previously known `latest` with
|
| 10 |
+
* `null`. */
|
| 11 |
+
readonly fetchLatest: () => Promise<FetchLatestResult>;
|
| 12 |
+
readonly writeCache: (cache: UpdateCache) => Promise<void>;
|
| 13 |
+
readonly now: () => Date;
|
| 14 |
+
readonly timeoutMs?: number;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export async function refreshUpdateCache(
|
| 18 |
+
overrides: Partial<RefreshUpdateCacheDeps> = {},
|
| 19 |
+
): Promise<UpdateCache> {
|
| 20 |
+
const resolved: RefreshUpdateCacheDeps = {
|
| 21 |
+
fetchLatest:
|
| 22 |
+
overrides.fetchLatest ?? (() => fetchLatestFromCdn(undefined, overrides.timeoutMs)),
|
| 23 |
+
writeCache: overrides.writeCache ?? writeUpdateCache,
|
| 24 |
+
now: overrides.now ?? (() => new Date()),
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const { latest, manifest } = await resolved.fetchLatest();
|
| 28 |
+
const cache: UpdateCache = {
|
| 29 |
+
source: 'cdn',
|
| 30 |
+
checkedAt: resolved.now().toISOString(),
|
| 31 |
+
latest,
|
| 32 |
+
manifest,
|
| 33 |
+
};
|
| 34 |
+
await resolved.writeCache(cache);
|
| 35 |
+
return cache;
|
| 36 |
+
}
|
apps/kimi-code/src/cli/update/rollout.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash, randomUUID } from 'node:crypto';
|
| 2 |
+
import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises';
|
| 3 |
+
import { dirname } from 'node:path';
|
| 4 |
+
|
| 5 |
+
import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
|
| 6 |
+
import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk';
|
| 7 |
+
|
| 8 |
+
import { getUpdateRolloutLogFile } from '#/utils/paths';
|
| 9 |
+
|
| 10 |
+
import { selectUpdateTarget } from './select';
|
| 11 |
+
import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types';
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Hard ceiling for any rollout delay. Combined with the uncovered-bucket
|
| 15 |
+
* fallback below, it guarantees every device sees a release no later than
|
| 16 |
+
* `publishedAt + 24h`, no matter what the published plan says.
|
| 17 |
+
*/
|
| 18 |
+
export const MAX_ROLLOUT_DELAY_SECONDS = 86_400;
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Deterministic 0-99 bucket for a device. The version is mixed into the hash
|
| 22 |
+
* so each release reshuffles which devices land in the early batches.
|
| 23 |
+
*/
|
| 24 |
+
export function rolloutBucket(deviceId: string, version: string): number {
|
| 25 |
+
const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest();
|
| 26 |
+
return digest.readUInt32BE(0) % 100;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Delay assigned to a bucket by the published plan. Batches claim bucket
|
| 31 |
+
* ranges in array order; buckets left uncovered (percents summing under 100)
|
| 32 |
+
* fall into the slowest cohort, and oversized delays are clamped to 24h.
|
| 33 |
+
*/
|
| 34 |
+
export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number {
|
| 35 |
+
let cumulative = 0;
|
| 36 |
+
for (const batch of rollout) {
|
| 37 |
+
cumulative += batch.percent;
|
| 38 |
+
if (bucket < cumulative) {
|
| 39 |
+
return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
if (rollout.length === 0) return 0;
|
| 43 |
+
return MAX_ROLLOUT_DELAY_SECONDS;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number {
|
| 47 |
+
return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version));
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function isRolloutEligible(
|
| 51 |
+
manifest: UpdateManifest,
|
| 52 |
+
deviceId: string,
|
| 53 |
+
now: Date,
|
| 54 |
+
): boolean {
|
| 55 |
+
const publishedAt = Date.parse(manifest.publishedAt);
|
| 56 |
+
// Schema validation rejects unparseable timestamps before they get here;
|
| 57 |
+
// fail open defensively so a defect can never block updates indefinitely.
|
| 58 |
+
if (!Number.isFinite(publishedAt)) return true;
|
| 59 |
+
const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000;
|
| 60 |
+
return now.getTime() >= publishedAt + delayMs;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/** Which case a passive update check hit; written to the rollout log. */
|
| 64 |
+
export type PassiveUpdateReason =
|
| 65 |
+
/** Nothing known yet (no cache / CDN never reached). */
|
| 66 |
+
| 'no-latest'
|
| 67 |
+
/** Known version is not an upgrade over the running one. */
|
| 68 |
+
| 'not-newer'
|
| 69 |
+
/** Plain-text fallback or legacy cache: visible immediately, no gating. */
|
| 70 |
+
| 'no-manifest'
|
| 71 |
+
/** Gated: this device's batch delay has not elapsed yet. */
|
| 72 |
+
| 'held'
|
| 73 |
+
/** Gated and the batch delay has elapsed: update is visible. */
|
| 74 |
+
| 'eligible'
|
| 75 |
+
/** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */
|
| 76 |
+
| 'experimental';
|
| 77 |
+
|
| 78 |
+
export interface PassiveUpdateDecision {
|
| 79 |
+
readonly target: UpdateTarget | null;
|
| 80 |
+
readonly reason: PassiveUpdateReason;
|
| 81 |
+
readonly bucket: number | null;
|
| 82 |
+
readonly delaySeconds: number | null;
|
| 83 |
+
readonly eligibleAt: string | null;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Update decision for the passive surfaces (background install, startup
|
| 88 |
+
* prompt, manual-command notice). Devices whose batch is not yet eligible see
|
| 89 |
+
* no update at all. A null manifest (plain-text fallback or legacy cache)
|
| 90 |
+
* keeps the pre-rollout behavior: the latest version is visible immediately.
|
| 91 |
+
*
|
| 92 |
+
* `kimi upgrade` must NOT go through this gate — it selects directly from the
|
| 93 |
+
* raw latest version.
|
| 94 |
+
*/
|
| 95 |
+
export function decidePassiveUpdateTarget(
|
| 96 |
+
currentVersion: string,
|
| 97 |
+
latest: string | null,
|
| 98 |
+
manifest: UpdateManifest | null,
|
| 99 |
+
deviceId: string,
|
| 100 |
+
now: Date,
|
| 101 |
+
bypassRollout = false,
|
| 102 |
+
): PassiveUpdateDecision {
|
| 103 |
+
if (bypassRollout) {
|
| 104 |
+
if (latest === null) {
|
| 105 |
+
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
|
| 106 |
+
}
|
| 107 |
+
const target = selectUpdateTarget(currentVersion, latest);
|
| 108 |
+
return {
|
| 109 |
+
target,
|
| 110 |
+
reason: target === null ? 'not-newer' : 'experimental',
|
| 111 |
+
bucket: null,
|
| 112 |
+
delaySeconds: null,
|
| 113 |
+
eligibleAt: null,
|
| 114 |
+
};
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (manifest === null) {
|
| 118 |
+
if (latest === null) {
|
| 119 |
+
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
|
| 120 |
+
}
|
| 121 |
+
const target = selectUpdateTarget(currentVersion, latest);
|
| 122 |
+
return {
|
| 123 |
+
target,
|
| 124 |
+
reason: target === null ? 'not-newer' : 'no-manifest',
|
| 125 |
+
bucket: null,
|
| 126 |
+
delaySeconds: null,
|
| 127 |
+
eligibleAt: null,
|
| 128 |
+
};
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const target = selectUpdateTarget(currentVersion, manifest.version);
|
| 132 |
+
if (target === null) {
|
| 133 |
+
return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null };
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const bucket = rolloutBucket(deviceId, manifest.version);
|
| 137 |
+
const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket);
|
| 138 |
+
const publishedAt = Date.parse(manifest.publishedAt);
|
| 139 |
+
const eligibleAt = Number.isFinite(publishedAt)
|
| 140 |
+
? new Date(publishedAt + delaySeconds * 1000).toISOString()
|
| 141 |
+
: null;
|
| 142 |
+
const eligible = isRolloutEligible(manifest, deviceId, now);
|
| 143 |
+
return {
|
| 144 |
+
target: eligible ? target : null,
|
| 145 |
+
reason: eligible ? 'eligible' : 'held',
|
| 146 |
+
bucket,
|
| 147 |
+
delaySeconds,
|
| 148 |
+
eligibleAt,
|
| 149 |
+
};
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
export function selectPassiveUpdateTarget(
|
| 153 |
+
currentVersion: string,
|
| 154 |
+
latest: string | null,
|
| 155 |
+
manifest: UpdateManifest | null,
|
| 156 |
+
deviceId: string,
|
| 157 |
+
now: Date,
|
| 158 |
+
): UpdateTarget | null {
|
| 159 |
+
return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const ROLLOUT_LOG_MAX_BYTES = 256 * 1024;
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Append one JSON line describing a passive update decision to
|
| 166 |
+
* `<dataDir>/updates/rollout.log`. Best-effort diagnostics: any I/O failure
|
| 167 |
+
* is swallowed — logging must never affect update prompting. The file is
|
| 168 |
+
* reset once it grows past a small cap so it cannot grow unbounded.
|
| 169 |
+
*/
|
| 170 |
+
export async function appendRolloutDecisionLog(
|
| 171 |
+
entry: Record<string, unknown>,
|
| 172 |
+
filePath: string = getUpdateRolloutLogFile(),
|
| 173 |
+
): Promise<void> {
|
| 174 |
+
try {
|
| 175 |
+
await mkdir(dirname(filePath), { recursive: true });
|
| 176 |
+
const line = `${JSON.stringify(entry)}\n`;
|
| 177 |
+
const size = await stat(filePath).then((s) => s.size, () => 0);
|
| 178 |
+
if (size > ROLLOUT_LOG_MAX_BYTES) {
|
| 179 |
+
await writeFile(filePath, line, 'utf-8');
|
| 180 |
+
return;
|
| 181 |
+
}
|
| 182 |
+
await appendFile(filePath, line, 'utf-8');
|
| 183 |
+
} catch {
|
| 184 |
+
// Diagnostic logging must never affect the update flow.
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* Stable per-installation id used for bucketing when telemetry has already
|
| 190 |
+
* minted one. Missing ids stay ephemeral here so update preflight never
|
| 191 |
+
* creates the telemetry device_id before telemetry can emit first_launch.
|
| 192 |
+
*/
|
| 193 |
+
export function resolveUpdateDeviceId(): string {
|
| 194 |
+
return readKimiDeviceId(resolveKimiHome()) ?? randomUUID();
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* The experimental master switch opts a device out of staged rollouts: the
|
| 199 |
+
* newest version is always visible to the passive update surfaces, exactly as
|
| 200 |
+
* if every release were fully rolled out. Read directly from the env (same
|
| 201 |
+
* truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) — the update preflight runs
|
| 202 |
+
* before the harness exists, so the core flag registry is not consulted.
|
| 203 |
+
* `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in.
|
| 204 |
+
*/
|
| 205 |
+
export function isRolloutBypassedByExperimentalEnv(
|
| 206 |
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
| 207 |
+
): boolean {
|
| 208 |
+
const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase();
|
| 209 |
+
return ['1', 'true', 'yes', 'on'].includes(value);
|
| 210 |
+
}
|
apps/kimi-code/src/cli/update/select.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { gt, valid } from 'semver';
|
| 2 |
+
|
| 3 |
+
import { type UpdateTarget } from './types';
|
| 4 |
+
|
| 5 |
+
export function selectUpdateTarget(
|
| 6 |
+
currentVersion: string,
|
| 7 |
+
latest: string | null,
|
| 8 |
+
): UpdateTarget | null {
|
| 9 |
+
if (latest === null) return null;
|
| 10 |
+
if (valid(currentVersion) === null || valid(latest) === null) return null;
|
| 11 |
+
if (!gt(latest, currentVersion)) return null;
|
| 12 |
+
return { version: latest };
|
| 13 |
+
}
|
apps/kimi-code/src/cli/update/source.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { execFile } from 'node:child_process';
|
| 2 |
+
import { realpathSync } from 'node:fs';
|
| 3 |
+
import { createRequire } from 'node:module';
|
| 4 |
+
import { join, resolve } from 'node:path';
|
| 5 |
+
|
| 6 |
+
import { getHostPackageRoot } from '#/cli/version';
|
| 7 |
+
import { resolveCommandPath } from '#/utils/process/resolve-command';
|
| 8 |
+
|
| 9 |
+
import { NPM_PACKAGE_NAME, type InstallSource } from './types';
|
| 10 |
+
|
| 11 |
+
const nodeRequire = createRequire(import.meta.url);
|
| 12 |
+
|
| 13 |
+
interface NodeSeaModule {
|
| 14 |
+
isSea(): boolean;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
let cachedSea: NodeSeaModule | null | undefined;
|
| 18 |
+
|
| 19 |
+
function loadSeaModule(): NodeSeaModule | null {
|
| 20 |
+
if (cachedSea !== undefined) return cachedSea;
|
| 21 |
+
try {
|
| 22 |
+
cachedSea = nodeRequire('node:sea') as NodeSeaModule;
|
| 23 |
+
} catch {
|
| 24 |
+
cachedSea = null;
|
| 25 |
+
}
|
| 26 |
+
return cachedSea;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/** Runtime SEA detection — true when running as a packaged native binary. */
|
| 30 |
+
export function detectNativeInstall(): boolean {
|
| 31 |
+
const sea = loadSeaModule();
|
| 32 |
+
if (sea === null) return false;
|
| 33 |
+
try {
|
| 34 |
+
return sea.isSea();
|
| 35 |
+
} catch {
|
| 36 |
+
return false;
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Path heuristic markers (compared in lowercase; both forward and backward slashes accepted).
|
| 41 |
+
const PNPM_PATH_SEGMENT = 'pnpm/global/';
|
| 42 |
+
const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/'];
|
| 43 |
+
const BUN_PATH_SEGMENT = '.bun/install/global/';
|
| 44 |
+
// Homebrew installs formulae under its Cellar directory. Avoid matching the
|
| 45 |
+
// broader /homebrew/ prefix — on Apple Silicon, npm itself lives under
|
| 46 |
+
// /opt/homebrew/, so `npm install -g` paths also contain /homebrew/.
|
| 47 |
+
const HOMEBREW_PATH_SEGMENT = '/cellar/';
|
| 48 |
+
|
| 49 |
+
function normalizeForHeuristic(filePath: string): string {
|
| 50 |
+
return filePath.replaceAll('\\', '/').toLowerCase();
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Heuristic classification by package root path segments. Returns the
|
| 55 |
+
* matching `InstallSource` or `null` if no heuristic matches (caller should
|
| 56 |
+
* fall through to npm-prefix comparison).
|
| 57 |
+
*/
|
| 58 |
+
export function classifyByPathHeuristic(packageRoot: string): InstallSource | null {
|
| 59 |
+
const normalized = normalizeForHeuristic(packageRoot);
|
| 60 |
+
if (normalized.includes(PNPM_PATH_SEGMENT)) return 'pnpm-global';
|
| 61 |
+
for (const seg of YARN_PATH_SEGMENTS) {
|
| 62 |
+
if (normalized.includes(seg)) return 'yarn-global';
|
| 63 |
+
}
|
| 64 |
+
if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global';
|
| 65 |
+
if (normalized.includes(HOMEBREW_PATH_SEGMENT)) return 'homebrew';
|
| 66 |
+
return null;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export interface DetectInstallSourceDeps {
|
| 70 |
+
readonly getPackageRoot: () => string;
|
| 71 |
+
readonly getGlobalPrefix: () => Promise<string>;
|
| 72 |
+
readonly detectNative: () => boolean;
|
| 73 |
+
readonly platform: NodeJS.Platform;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function npmCommand(platform: NodeJS.Platform): string {
|
| 77 |
+
return platform === 'win32' ? 'npm.cmd' : 'npm';
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// The install-source detection runs before the workspace trust gate, so the
|
| 81 |
+
// npm binary must be resolved through PATH to an absolute path — a bare name
|
| 82 |
+
// would let cmd.exe pick up an `npm.cmd` planted in the current directory.
|
| 83 |
+
function npmGlobalPrefix(platform: NodeJS.Platform): Promise<string> {
|
| 84 |
+
const resolved = resolveCommandPath(npmCommand(platform));
|
| 85 |
+
if (resolved === undefined) {
|
| 86 |
+
return Promise.reject(new Error('npm was not found in PATH'));
|
| 87 |
+
}
|
| 88 |
+
return execFileText(resolved, ['prefix', '-g']).then((text) => text.trim());
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function execFileText(command: string, args: readonly string[]): Promise<string> {
|
| 92 |
+
return new Promise((resolveOutput, reject) => {
|
| 93 |
+
execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => {
|
| 94 |
+
if (error) {
|
| 95 |
+
reject(error);
|
| 96 |
+
return;
|
| 97 |
+
}
|
| 98 |
+
resolveOutput(stdout);
|
| 99 |
+
});
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function normalizePathForComparison(filePath: string, platform: NodeJS.Platform): string | null {
|
| 104 |
+
const trimmed = filePath.trim();
|
| 105 |
+
if (trimmed.length === 0) return null;
|
| 106 |
+
try {
|
| 107 |
+
return normalizeResolvedPath(realpathSync(trimmed), platform);
|
| 108 |
+
} catch {
|
| 109 |
+
return normalizeResolvedPath(resolve(trimmed), platform);
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function normalizeResolvedPath(filePath: string, platform: NodeJS.Platform): string {
|
| 114 |
+
const resolvedPath = resolve(filePath);
|
| 115 |
+
return platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function candidateGlobalPackageDirs(
|
| 119 |
+
globalPrefix: string,
|
| 120 |
+
platform: NodeJS.Platform,
|
| 121 |
+
): readonly string[] {
|
| 122 |
+
if (platform === 'win32') {
|
| 123 |
+
return [join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME)];
|
| 124 |
+
}
|
| 125 |
+
return [
|
| 126 |
+
join(globalPrefix, 'lib', 'node_modules', NPM_PACKAGE_NAME),
|
| 127 |
+
join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME),
|
| 128 |
+
];
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export function classifyInstallSource(
|
| 132 |
+
packageRoot: string,
|
| 133 |
+
globalPrefix: string,
|
| 134 |
+
platform: NodeJS.Platform = process.platform,
|
| 135 |
+
): InstallSource {
|
| 136 |
+
const normalizedPackageRoot = normalizePathForComparison(packageRoot, platform);
|
| 137 |
+
if (normalizedPackageRoot === null) return 'unsupported';
|
| 138 |
+
|
| 139 |
+
for (const candidate of candidateGlobalPackageDirs(globalPrefix, platform)) {
|
| 140 |
+
if (normalizePathForComparison(candidate, platform) === normalizedPackageRoot) {
|
| 141 |
+
return 'npm-global';
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
return 'unsupported';
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export async function detectInstallSource(
|
| 148 |
+
deps: Partial<DetectInstallSourceDeps> = {},
|
| 149 |
+
): Promise<InstallSource> {
|
| 150 |
+
const platform = deps.platform ?? process.platform;
|
| 151 |
+
const resolved: DetectInstallSourceDeps = {
|
| 152 |
+
getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot,
|
| 153 |
+
getGlobalPrefix:
|
| 154 |
+
deps.getGlobalPrefix ??
|
| 155 |
+
(() => npmGlobalPrefix(platform)),
|
| 156 |
+
detectNative: deps.detectNative ?? detectNativeInstall,
|
| 157 |
+
platform,
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
if (resolved.detectNative()) return 'native';
|
| 161 |
+
|
| 162 |
+
const packageRoot = resolved.getPackageRoot();
|
| 163 |
+
const heuristic = classifyByPathHeuristic(packageRoot);
|
| 164 |
+
if (heuristic !== null) return heuristic;
|
| 165 |
+
|
| 166 |
+
try {
|
| 167 |
+
const globalPrefix = await resolved.getGlobalPrefix();
|
| 168 |
+
return classifyInstallSource(packageRoot, globalPrefix, resolved.platform);
|
| 169 |
+
} catch {
|
| 170 |
+
return 'unsupported';
|
| 171 |
+
}
|
| 172 |
+
}
|
apps/kimi-code/src/cli/update/types.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NPM_PACKAGE_NAME } from '#/constant/app';
|
| 2 |
+
|
| 3 |
+
export { NPM_PACKAGE_NAME };
|
| 4 |
+
|
| 5 |
+
/** Where the running CLI was installed from. Drives update command + spawn. */
|
| 6 |
+
export type InstallSource =
|
| 7 |
+
| 'npm-global'
|
| 8 |
+
| 'pnpm-global'
|
| 9 |
+
| 'yarn-global'
|
| 10 |
+
| 'bun-global'
|
| 11 |
+
| 'homebrew'
|
| 12 |
+
| 'native'
|
| 13 |
+
| 'unsupported';
|
| 14 |
+
|
| 15 |
+
export interface UpdateTarget {
|
| 16 |
+
readonly version: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */
|
| 20 |
+
export interface RolloutBatch {
|
| 21 |
+
readonly percent: number;
|
| 22 |
+
readonly delaySeconds: number;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array
|
| 27 |
+
* order; an empty array means the release is fully rolled out immediately.
|
| 28 |
+
*/
|
| 29 |
+
export interface UpdateManifest {
|
| 30 |
+
readonly version: string;
|
| 31 |
+
readonly publishedAt: string;
|
| 32 |
+
readonly rollout: readonly RolloutBatch[];
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export interface UpdateCache {
|
| 36 |
+
readonly source: 'cdn';
|
| 37 |
+
readonly checkedAt: string | null;
|
| 38 |
+
readonly latest: string | null;
|
| 39 |
+
/** Null when the manifest came from the plain-text fallback or a legacy cache file. */
|
| 40 |
+
readonly manifest: UpdateManifest | null;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface UpdateInstallActive {
|
| 44 |
+
readonly version: string;
|
| 45 |
+
readonly source: InstallSource;
|
| 46 |
+
readonly startedAt: string;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export interface UpdateInstallFailure {
|
| 50 |
+
readonly version: string;
|
| 51 |
+
readonly failedAt: string;
|
| 52 |
+
readonly attempts: number;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface UpdateInstallSuccess {
|
| 56 |
+
readonly version: string;
|
| 57 |
+
readonly installedAt: string;
|
| 58 |
+
readonly notifiedAt: string | null;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export interface UpdateInstallState {
|
| 62 |
+
readonly active: UpdateInstallActive | null;
|
| 63 |
+
readonly lastFailure: UpdateInstallFailure | null;
|
| 64 |
+
readonly lastSuccess: UpdateInstallSuccess | null;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export type UpdateDecision = 'none' | 'prompt-install' | 'manual-command';
|
| 68 |
+
export type UpdatePreflightResult = 'continue' | 'exit';
|
| 69 |
+
|
| 70 |
+
export function emptyUpdateCache(): UpdateCache {
|
| 71 |
+
return {
|
| 72 |
+
source: 'cdn',
|
| 73 |
+
checkedAt: null,
|
| 74 |
+
latest: null,
|
| 75 |
+
manifest: null,
|
| 76 |
+
};
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export function emptyUpdateInstallState(): UpdateInstallState {
|
| 80 |
+
return {
|
| 81 |
+
active: null,
|
| 82 |
+
lastFailure: null,
|
| 83 |
+
lastSuccess: null,
|
| 84 |
+
};
|
| 85 |
+
}
|
apps/kimi-code/src/cli/v2/run-v2-print.ts
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Native v2 `kimi -p` (print mode) runner.
|
| 3 |
+
*
|
| 4 |
+
* Unlike the v1 path (and the former `V2PromptHarness` / `V2Session` shim), this
|
| 5 |
+
* runner talks to agent-core-v2's native DI services directly — no
|
| 6 |
+
* `PromptHarness`, no SDK-shaped session, no v2→v1 event translation. It:
|
| 7 |
+
* - `bootstrap()`s the app scope,
|
| 8 |
+
* - creates / resumes a session and its main agent via native services,
|
| 9 |
+
* - subscribes to the main agent's per-agent `IEventBus` and renders the
|
| 10 |
+
* native `Event2` stream (payloads are already v1-protocol-shaped),
|
| 11 |
+
* - drives a turn through `IAgentLoopService.enqueuePrompt()` and awaits
|
| 12 |
+
* `Turn.result` for authoritative completion,
|
| 13 |
+
* - applies the print-mode background policy (config-driven, v1-aligned:
|
| 14 |
+
* `exit` / `drain` / `steer`) before exiting.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import { readFile } from 'node:fs/promises';
|
| 18 |
+
|
| 19 |
+
import {
|
| 20 |
+
IAgentCronService,
|
| 21 |
+
IAgentGoalService,
|
| 22 |
+
IAgentLifecycleService,
|
| 23 |
+
IAgentLoopService,
|
| 24 |
+
IAgentPermissionModeService,
|
| 25 |
+
IAgentProfileService,
|
| 26 |
+
IAgentTaskService,
|
| 27 |
+
IAuthSummaryService,
|
| 28 |
+
IBootstrapService,
|
| 29 |
+
IConfigService,
|
| 30 |
+
IEventBus,
|
| 31 |
+
IEventDispatcher,
|
| 32 |
+
IHostFileSystem,
|
| 33 |
+
IOAuthToolkit,
|
| 34 |
+
ISessionIndex,
|
| 35 |
+
ISessionManager,
|
| 36 |
+
ITelemetryService,
|
| 37 |
+
IWorkspaceInstanceManager,
|
| 38 |
+
PRINT_MAX_TURNS_DEFAULT,
|
| 39 |
+
PRINT_WAIT_CEILING_S_DEFAULT,
|
| 40 |
+
applyPrintModeConfigDefaults,
|
| 41 |
+
bootstrap,
|
| 42 |
+
createCloudAppender,
|
| 43 |
+
ensureMainAgent,
|
| 44 |
+
resumeSessionById,
|
| 45 |
+
logSeed,
|
| 46 |
+
parseAgentFileText,
|
| 47 |
+
resolveAgentPath,
|
| 48 |
+
resolveAgentTaskConfig,
|
| 49 |
+
resolveKimiHome,
|
| 50 |
+
resolveLoggingConfig,
|
| 51 |
+
resolvePrintBackgroundMode,
|
| 52 |
+
setClampedTimeout,
|
| 53 |
+
type Event2,
|
| 54 |
+
type IAgentScopeHandle,
|
| 55 |
+
type ISessionScopeHandle,
|
| 56 |
+
type LoopRunResult,
|
| 57 |
+
type McpServerConfig,
|
| 58 |
+
type PrintBackgroundMode,
|
| 59 |
+
type Scope,
|
| 60 |
+
} from '@moonshot-ai/agent-core-v2';
|
| 61 |
+
import {
|
| 62 |
+
loadMcpServersDetailed,
|
| 63 |
+
resolveMcpJsonPaths,
|
| 64 |
+
} from '@moonshot-ai/agent-core-v2/app/mcpConfig/configLoader';
|
| 65 |
+
import {
|
| 66 |
+
createKimiDefaultHeaders,
|
| 67 |
+
createKimiDeviceId,
|
| 68 |
+
KIMI_CODE_PROVIDER_NAME,
|
| 69 |
+
} from '@moonshot-ai/kimi-code-oauth';
|
| 70 |
+
import {
|
| 71 |
+
initializeTelemetry,
|
| 72 |
+
setCrashPhase,
|
| 73 |
+
setTelemetryContext,
|
| 74 |
+
setTelemetryModel,
|
| 75 |
+
shouldEnableTelemetry,
|
| 76 |
+
shutdownTelemetry,
|
| 77 |
+
} from '@moonshot-ai/kimi-telemetry';
|
| 78 |
+
import type { GoalUpdated } from '@moonshot-ai/agent-core-v2/features/goal/goalOps';
|
| 79 |
+
import type { TurnEnded } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps';
|
| 80 |
+
import type {
|
| 81 |
+
AssistantDelta,
|
| 82 |
+
ThinkingDelta,
|
| 83 |
+
ToolCallDelta,
|
| 84 |
+
} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents';
|
| 85 |
+
import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents';
|
| 86 |
+
import type { HookResult } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService';
|
| 87 |
+
import type {
|
| 88 |
+
ToolCallStarted,
|
| 89 |
+
ToolProgress,
|
| 90 |
+
ToolResultEvent,
|
| 91 |
+
} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents';
|
| 92 |
+
import { resolve } from 'pathe';
|
| 93 |
+
|
| 94 |
+
import {
|
| 95 |
+
CLI_SHUTDOWN_TIMEOUT_MS,
|
| 96 |
+
CLI_USER_AGENT_PRODUCT,
|
| 97 |
+
PROMPT_CLEANUP_TIMEOUT_MS,
|
| 98 |
+
} from '#/constant/app';
|
| 99 |
+
import { currentKimiProfile } from '#/utils/region';
|
| 100 |
+
|
| 101 |
+
import {
|
| 102 |
+
formatGoalSummaryText,
|
| 103 |
+
goalExitCode,
|
| 104 |
+
goalSummaryJson,
|
| 105 |
+
parseHeadlessGoalCreate,
|
| 106 |
+
type HeadlessGoalCreate,
|
| 107 |
+
} from '../goal-prompt';
|
| 108 |
+
import {
|
| 109 |
+
type PromptRunIO,
|
| 110 |
+
configuredModel,
|
| 111 |
+
installPromptTerminationCleanup,
|
| 112 |
+
raceWithTimeout,
|
| 113 |
+
requireConfiguredModel,
|
| 114 |
+
} from '../run-prompt';
|
| 115 |
+
import { createKimiCodeHostIdentity } from '../version';
|
| 116 |
+
|
| 117 |
+
import { resolveOutputFormat } from '../options';
|
| 118 |
+
import type { CLIOptions, PromptOutputFormat } from '../options';
|
| 119 |
+
import {
|
| 120 |
+
type PromptOutput,
|
| 121 |
+
PromptJsonWriter,
|
| 122 |
+
type PromptTurnWriter,
|
| 123 |
+
PromptTranscriptWriter,
|
| 124 |
+
writeExperimentalVersion,
|
| 125 |
+
writeResumeHint,
|
| 126 |
+
} from '../prompt-render';
|
| 127 |
+
|
| 128 |
+
const PROMPT_UI_MODE = 'print';
|
| 129 |
+
/** Re-check `goalActive` at least this often while waiting for goal turns. */
|
| 130 |
+
const GOAL_WAIT_POLL_MS = 250;
|
| 131 |
+
/** Re-check each agent's prompt queue while waiting for it to drain at exit. */
|
| 132 |
+
const PROMPT_QUIESCE_POLL_MS = 10;
|
| 133 |
+
/**
|
| 134 |
+
* Slack on top of a scheduled cron fire time while waiting for the steered
|
| 135 |
+
* turn: covers the 1s tick poll interval plus fire → inject → turn-launch
|
| 136 |
+
* latency.
|
| 137 |
+
*/
|
| 138 |
+
const CRON_FIRE_GRACE_MS = 5_000;
|
| 139 |
+
|
| 140 |
+
export async function runV2Print(
|
| 141 |
+
opts: CLIOptions,
|
| 142 |
+
version: string,
|
| 143 |
+
io: PromptRunIO = {},
|
| 144 |
+
): Promise<void> {
|
| 145 |
+
const startedAt = Date.now();
|
| 146 |
+
const stdout = io.stdout ?? process.stdout;
|
| 147 |
+
const stderr = io.stderr ?? process.stderr;
|
| 148 |
+
const promptProcess = io.process ?? process;
|
| 149 |
+
const outputFormat = resolveOutputFormat(opts);
|
| 150 |
+
const workDir = process.cwd();
|
| 151 |
+
|
| 152 |
+
writeExperimentalVersion(version, outputFormat, stdout, stderr);
|
| 153 |
+
|
| 154 |
+
const homeDir = resolveKimiHome();
|
| 155 |
+
let firstLaunch = false;
|
| 156 |
+
const deviceId = createKimiDeviceId(homeDir, {
|
| 157 |
+
onFirstLaunch: () => {
|
| 158 |
+
firstLaunch = true;
|
| 159 |
+
},
|
| 160 |
+
});
|
| 161 |
+
const logging = resolveLoggingConfig({ homeDir, env: process.env });
|
| 162 |
+
const identity = createKimiCodeHostIdentity(version);
|
| 163 |
+
const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
|
| 164 |
+
|
| 165 |
+
const { app } = bootstrap(
|
| 166 |
+
{
|
| 167 |
+
homeDir,
|
| 168 |
+
clientIdentity: identity,
|
| 169 |
+
args: {
|
| 170 |
+
requestHeaders: hostHeaders,
|
| 171 |
+
nonInteractive: true,
|
| 172 |
+
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
|
| 173 |
+
// user / project discovery for this process.
|
| 174 |
+
skillDirs: opts.skillsDirs,
|
| 175 |
+
// `--agent-file`: explicit agent definition files, registered with the
|
| 176 |
+
// highest-precedence source for this process. Passed through unresolved —
|
| 177 |
+
// the engine expands `~` and resolves relative paths against the session
|
| 178 |
+
// workDir (mirroring `--skills-dir`).
|
| 179 |
+
agentFiles: opts.agentFiles,
|
| 180 |
+
},
|
| 181 |
+
},
|
| 182 |
+
[...logSeed(logging)],
|
| 183 |
+
);
|
| 184 |
+
const auth = app.accessor.get(IOAuthToolkit);
|
| 185 |
+
|
| 186 |
+
const configService = app.accessor.get(IConfigService);
|
| 187 |
+
await configService.ready;
|
| 188 |
+
// Print-mode config defaults (task timeouts / loop step cap / subagent
|
| 189 |
+
// timeout → unbounded) before anything resolves a session; only keys the
|
| 190 |
+
// user left unset are filled, in the memory layer.
|
| 191 |
+
await applyPrintModeConfigDefaults(configService);
|
| 192 |
+
const defaultModel = configService.get<string>('defaultModel') ?? undefined;
|
| 193 |
+
let configTelemetryEnabled = true;
|
| 194 |
+
try {
|
| 195 |
+
configTelemetryEnabled = configService.get('telemetry') !== false;
|
| 196 |
+
} catch {
|
| 197 |
+
configTelemetryEnabled = true;
|
| 198 |
+
}
|
| 199 |
+
const telemetryEnabled = shouldEnableTelemetry({ enabled: configTelemetryEnabled });
|
| 200 |
+
for (const diagnostic of configService.diagnostics()) {
|
| 201 |
+
if (diagnostic.severity === 'warning') {
|
| 202 |
+
stderr.write(`Warning: ${diagnostic.message}\n`);
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
let restorePermission = async (): Promise<void> => {};
|
| 207 |
+
let quiesceAgents = async (): Promise<void> => {};
|
| 208 |
+
let releaseQuiescence: (() => void) | undefined;
|
| 209 |
+
let flushWires = async (): Promise<void> => {};
|
| 210 |
+
let removeTerminationCleanup: (() => void) | undefined;
|
| 211 |
+
let cleanupPromise: Promise<void> | undefined;
|
| 212 |
+
let telemetryService: ITelemetryService | undefined;
|
| 213 |
+
const cleanup = async (): Promise<void> => {
|
| 214 |
+
const pending = (cleanupPromise ??= (async () => {
|
| 215 |
+
removeTerminationCleanup?.();
|
| 216 |
+
setCrashPhase('shutdown');
|
| 217 |
+
try {
|
| 218 |
+
await restorePermission();
|
| 219 |
+
// A termination signal can arrive mid-turn: cancel turns and wait for idle agents first.
|
| 220 |
+
await raceWithTimeout(quiesceAgents(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {});
|
| 221 |
+
} finally {
|
| 222 |
+
try {
|
| 223 |
+
// Concurrent so the phases' allowances cannot sum past PROMPT_CLEANUP_TIMEOUT_MS.
|
| 224 |
+
await Promise.all([
|
| 225 |
+
// The turn's tail records reach the journal only via the wire's
|
| 226 |
+
// async persist queue; process.exit must not cut off that queue.
|
| 227 |
+
raceWithTimeout(flushWires(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {}),
|
| 228 |
+
telemetryService !== undefined
|
| 229 |
+
? raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS)
|
| 230 |
+
: Promise.resolve(),
|
| 231 |
+
shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}),
|
| 232 |
+
]);
|
| 233 |
+
app.dispose();
|
| 234 |
+
} finally {
|
| 235 |
+
// Keep producers frozen until the journals are drained and disposed.
|
| 236 |
+
releaseQuiescence?.();
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
})());
|
| 240 |
+
await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
|
| 241 |
+
};
|
| 242 |
+
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup);
|
| 243 |
+
|
| 244 |
+
try {
|
| 245 |
+
// Install the appender BEFORE resolving the session: `session_started` and
|
| 246 |
+
// `session_load_failed` fire inside create()/resume(), so an appender wired
|
| 247 |
+
// up only after resolveNativeSession() would drop them to the null appender.
|
| 248 |
+
// The model below is the best known up front; a resumed session's real
|
| 249 |
+
// model is reconciled once resolved (v2 via setContext, v1 via
|
| 250 |
+
// setTelemetryModel). The v1 pipeline is initialized here too: the
|
| 251 |
+
// process-wide crash handlers report through its default client, so its
|
| 252 |
+
// sink must be attached before the run can crash.
|
| 253 |
+
telemetryService = app.accessor.get(ITelemetryService);
|
| 254 |
+
if (telemetryEnabled) {
|
| 255 |
+
telemetryService.addAppender(
|
| 256 |
+
createCloudAppender(app.accessor, {
|
| 257 |
+
deviceId,
|
| 258 |
+
appName: CLI_USER_AGENT_PRODUCT,
|
| 259 |
+
uiMode: PROMPT_UI_MODE,
|
| 260 |
+
model: opts.model ?? defaultModel,
|
| 261 |
+
getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null,
|
| 262 |
+
}),
|
| 263 |
+
);
|
| 264 |
+
// No `first_launch` on the v1 client: the v2 side already tracks it via
|
| 265 |
+
// `telemetryService.track2` below, so tracking here would double-send.
|
| 266 |
+
initializeTelemetry({
|
| 267 |
+
homeDir,
|
| 268 |
+
deviceId,
|
| 269 |
+
appName: CLI_USER_AGENT_PRODUCT,
|
| 270 |
+
version,
|
| 271 |
+
uiMode: PROMPT_UI_MODE,
|
| 272 |
+
model: opts.model ?? defaultModel,
|
| 273 |
+
endpoint: () => currentKimiProfile().telemetryEndpoint,
|
| 274 |
+
getAccessToken: async () =>
|
| 275 |
+
(await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
|
| 276 |
+
onUnexpectedError: (error) => console.error('[unexpected]', error),
|
| 277 |
+
});
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// Print mode has no trust prompt, so the engine's workspace-trust gate
|
| 281 |
+
// would silently drop project-level MCP servers — say so on stderr.
|
| 282 |
+
try {
|
| 283 |
+
const gated = await listTrustGatedMcpServers(app, workDir, homeDir);
|
| 284 |
+
if (gated.length > 0) stderr.write(formatTrustGatedMcpWarning(gated));
|
| 285 |
+
} catch {
|
| 286 |
+
// Best-effort: a broken mcp.json or trust store must not fail the run.
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
|
| 290 |
+
restorePermission = resolved.restorePermission;
|
| 291 |
+
quiesceAgents = async () => {
|
| 292 |
+
releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent);
|
| 293 |
+
};
|
| 294 |
+
flushWires = () => flushSessionWires(resolved.session, resolved.agent);
|
| 295 |
+
|
| 296 |
+
telemetryService.setContext({ session_id: resolved.session.id, model: resolved.telemetryModel });
|
| 297 |
+
setTelemetryContext({ sessionId: resolved.session.id });
|
| 298 |
+
setTelemetryModel(resolved.telemetryModel);
|
| 299 |
+
setCrashPhase('runtime');
|
| 300 |
+
if (firstLaunch) {
|
| 301 |
+
telemetryService.track2('first_launch');
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
|
| 305 |
+
if (goalCreate !== undefined) {
|
| 306 |
+
await runNativeGoal(
|
| 307 |
+
app,
|
| 308 |
+
resolved.session,
|
| 309 |
+
resolved.agent,
|
| 310 |
+
goalCreate,
|
| 311 |
+
resolved.goalModel,
|
| 312 |
+
outputFormat,
|
| 313 |
+
stdout,
|
| 314 |
+
stderr,
|
| 315 |
+
);
|
| 316 |
+
} else {
|
| 317 |
+
await runNativeTurn(
|
| 318 |
+
app,
|
| 319 |
+
resolved.session,
|
| 320 |
+
resolved.agent,
|
| 321 |
+
opts.prompt!,
|
| 322 |
+
outputFormat,
|
| 323 |
+
stdout,
|
| 324 |
+
stderr,
|
| 325 |
+
);
|
| 326 |
+
}
|
| 327 |
+
writeResumeHint(resolved.session.id, outputFormat, stdout, stderr);
|
| 328 |
+
|
| 329 |
+
telemetryService.withContext({ session_id: resolved.session.id }).track2('exit', {
|
| 330 |
+
duration_ms: Date.now() - startedAt,
|
| 331 |
+
});
|
| 332 |
+
} finally {
|
| 333 |
+
await cleanup();
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
interface ResolvedNativeSession {
|
| 338 |
+
readonly session: ISessionScopeHandle;
|
| 339 |
+
readonly agent: IAgentScopeHandle;
|
| 340 |
+
readonly restorePermission: () => Promise<void>;
|
| 341 |
+
readonly telemetryModel: string | undefined;
|
| 342 |
+
readonly goalModel: string | undefined;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
async function resolveNativeSession(
|
| 346 |
+
app: Scope,
|
| 347 |
+
opts: CLIOptions,
|
| 348 |
+
workDir: string,
|
| 349 |
+
defaultModel: string | undefined,
|
| 350 |
+
stderr: PromptOutput,
|
| 351 |
+
): Promise<ResolvedNativeSession> {
|
| 352 |
+
const sessions = app.accessor.get(ISessionManager);
|
| 353 |
+
const index = app.accessor.get(ISessionIndex);
|
| 354 |
+
|
| 355 |
+
// `--agent` selects a catalog profile by name; otherwise `--agent-file`
|
| 356 |
+
// implicitly selects the profile that file defines. The file
|
| 357 |
+
// is parsed here (fatal on error) so a bad file fails before any turn.
|
| 358 |
+
let agentProfileName = opts.agent;
|
| 359 |
+
const agentFile = opts.agentFiles[0];
|
| 360 |
+
if (agentProfileName === undefined && agentFile !== undefined) {
|
| 361 |
+
const agentFilePath = resolveAgentPath(
|
| 362 |
+
agentFile,
|
| 363 |
+
workDir,
|
| 364 |
+
app.accessor.get(IBootstrapService).osHomeDir,
|
| 365 |
+
);
|
| 366 |
+
let agentFileText: string;
|
| 367 |
+
try {
|
| 368 |
+
agentFileText = await readFile(agentFilePath, 'utf8');
|
| 369 |
+
} catch (error) {
|
| 370 |
+
throw new Error(
|
| 371 |
+
`Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`,
|
| 372 |
+
{ cause: error },
|
| 373 |
+
);
|
| 374 |
+
}
|
| 375 |
+
try {
|
| 376 |
+
agentProfileName = parseAgentFileText({
|
| 377 |
+
path: agentFilePath,
|
| 378 |
+
source: 'explicit',
|
| 379 |
+
text: agentFileText,
|
| 380 |
+
}).name;
|
| 381 |
+
} catch (error) {
|
| 382 |
+
throw new Error(
|
| 383 |
+
`Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`,
|
| 384 |
+
{ cause: error },
|
| 385 |
+
);
|
| 386 |
+
}
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
// `--agent` / `--agent-file` are creation-only: validateOptions rejects them
|
| 390 |
+
// together with --session/--continue, so resume paths only apply an
|
| 391 |
+
// explicitly requested model — the bound profile is restored by the engine.
|
| 392 |
+
const applyModelOverride = async (
|
| 393 |
+
profile: IAgentProfileService,
|
| 394 |
+
model: string | undefined,
|
| 395 |
+
): Promise<void> => {
|
| 396 |
+
if (model !== undefined) await profile.setModel(model);
|
| 397 |
+
};
|
| 398 |
+
|
| 399 |
+
const resumeById = async (id: string): Promise<ISessionScopeHandle> => {
|
| 400 |
+
const session = await resumeSessionById(app.accessor, id);
|
| 401 |
+
if (session === undefined) {
|
| 402 |
+
throw new Error(`Session "${id}" not found.`);
|
| 403 |
+
}
|
| 404 |
+
return session;
|
| 405 |
+
};
|
| 406 |
+
|
| 407 |
+
const forceAuto = (
|
| 408 |
+
agent: IAgentScopeHandle,
|
| 409 |
+
): { readonly restorePermission: () => Promise<void> } => {
|
| 410 |
+
const permissionMode = agent.accessor.get(IAgentPermissionModeService);
|
| 411 |
+
const previous = permissionMode.mode;
|
| 412 |
+
permissionMode.setMode('auto');
|
| 413 |
+
return {
|
| 414 |
+
restorePermission: async () => {
|
| 415 |
+
permissionMode.setMode(previous);
|
| 416 |
+
},
|
| 417 |
+
};
|
| 418 |
+
};
|
| 419 |
+
|
| 420 |
+
if (opts.session !== undefined) {
|
| 421 |
+
const target = await index.get(opts.session);
|
| 422 |
+
if (target === undefined) {
|
| 423 |
+
throw new Error(`Session "${opts.session}" not found.`);
|
| 424 |
+
}
|
| 425 |
+
if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) {
|
| 426 |
+
stderr.write(
|
| 427 |
+
`Session "${opts.session}" was created under a different directory.\n` +
|
| 428 |
+
` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`,
|
| 429 |
+
);
|
| 430 |
+
throw new Error(`Session "${opts.session}" was created under a different directory.`);
|
| 431 |
+
}
|
| 432 |
+
const session = await resumeById(opts.session);
|
| 433 |
+
const agentContext = await ensureMainAgent(session);
|
| 434 |
+
const agent = session.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId)!;
|
| 435 |
+
const profile = agent.accessor.get(IAgentProfileService);
|
| 436 |
+
await applyModelOverride(profile, opts.model);
|
| 437 |
+
const currentModel = profile.getModel();
|
| 438 |
+
const { restorePermission } = forceAuto(agent);
|
| 439 |
+
return {
|
| 440 |
+
session,
|
| 441 |
+
agent,
|
| 442 |
+
restorePermission,
|
| 443 |
+
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
|
| 444 |
+
goalModel: configuredModel(opts.model, currentModel),
|
| 445 |
+
};
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
if (opts.continue) {
|
| 449 |
+
const page = await index.listRecent({});
|
| 450 |
+
const previous = page.items.find((summary) => summary.cwd === workDir);
|
| 451 |
+
if (previous !== undefined) {
|
| 452 |
+
const session = await resumeById(previous.id);
|
| 453 |
+
const agentContext = await ensureMainAgent(session);
|
| 454 |
+
const agent = session.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId)!;
|
| 455 |
+
const profile = agent.accessor.get(IAgentProfileService);
|
| 456 |
+
await applyModelOverride(profile, opts.model);
|
| 457 |
+
const currentModel = profile.getModel();
|
| 458 |
+
const { restorePermission } = forceAuto(agent);
|
| 459 |
+
return {
|
| 460 |
+
session,
|
| 461 |
+
agent,
|
| 462 |
+
restorePermission,
|
| 463 |
+
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
|
| 464 |
+
goalModel: configuredModel(opts.model, currentModel),
|
| 465 |
+
};
|
| 466 |
+
}
|
| 467 |
+
stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
const model = requireConfiguredModel(opts.model, defaultModel);
|
| 471 |
+
const session = await sessions.create({
|
| 472 |
+
workDir,
|
| 473 |
+
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
| 474 |
+
mainAgentBinding: {
|
| 475 |
+
profile: agentProfileName ?? 'agent',
|
| 476 |
+
model,
|
| 477 |
+
},
|
| 478 |
+
});
|
| 479 |
+
const agentContext = await ensureMainAgent(session);
|
| 480 |
+
const agent = session.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId)!;
|
| 481 |
+
agent.accessor.get(IAgentPermissionModeService).setMode('auto');
|
| 482 |
+
return {
|
| 483 |
+
session,
|
| 484 |
+
agent,
|
| 485 |
+
restorePermission: async () => {},
|
| 486 |
+
telemetryModel: model,
|
| 487 |
+
goalModel: model,
|
| 488 |
+
};
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
export interface TrustGatedMcpServer {
|
| 492 |
+
readonly name: string;
|
| 493 |
+
readonly target: string;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
/**
|
| 497 |
+
* Project-level MCP servers the workspace-trust gate leaves out in this
|
| 498 |
+
* folder, identified by the origin of each entry in the final merged config
|
| 499 |
+
* (mirrors the SDK's `getWorkspaceTrustInfo`). Empty when the folder is trusted
|
| 500 |
+
* or nothing project-level is declared.
|
| 501 |
+
*/
|
| 502 |
+
export async function listTrustGatedMcpServers(
|
| 503 |
+
app: Scope,
|
| 504 |
+
workDir: string,
|
| 505 |
+
homeDir: string,
|
| 506 |
+
): Promise<readonly TrustGatedMcpServer[]> {
|
| 507 |
+
const workspace = await app.accessor
|
| 508 |
+
.get(IWorkspaceInstanceManager)
|
| 509 |
+
.getOrCreate({ root: workDir });
|
| 510 |
+
if (await workspace.program.trust.get()) return [];
|
| 511 |
+
const fs = app.accessor.get(IHostFileSystem);
|
| 512 |
+
const [paths, loaded] = await Promise.all([
|
| 513 |
+
resolveMcpJsonPaths({ fs, cwd: workDir, homeDir }),
|
| 514 |
+
loadMcpServersDetailed({ fs, cwd: workDir, homeDir, includeProject: true }),
|
| 515 |
+
]);
|
| 516 |
+
const projectPaths = new Set([paths.projectRoot, paths.project]);
|
| 517 |
+
return Object.entries(loaded.servers)
|
| 518 |
+
.filter(([name]) => projectPaths.has(loaded.origins[name] ?? ''))
|
| 519 |
+
.map(([name, config]) => ({ name, target: describeMcpTarget(config) }))
|
| 520 |
+
.toSorted((a, b) => a.name.localeCompare(b.name));
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
export function formatTrustGatedMcpWarning(servers: readonly TrustGatedMcpServer[]): string {
|
| 524 |
+
const noun = servers.length === 1 ? 'server' : 'servers';
|
| 525 |
+
const list = servers.map((server) => `${server.name} (${server.target})`).join(', ');
|
| 526 |
+
return (
|
| 527 |
+
`Warning: this folder is not trusted; skipped ${servers.length} project-level MCP ${noun}: ${list}.\n` +
|
| 528 |
+
' Run `kimi` here and choose "Trust this folder" to enable them.\n\n'
|
| 529 |
+
);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
function describeMcpTarget(config: McpServerConfig): string {
|
| 533 |
+
if (config.transport === 'stdio') {
|
| 534 |
+
const args = config.args === undefined ? '' : ` ${config.args.join(' ')}`;
|
| 535 |
+
return `stdio: ${config.command}${args}`;
|
| 536 |
+
}
|
| 537 |
+
return `${config.transport}: ${config.url}`;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
async function runNativeTurn(
|
| 541 |
+
app: Scope,
|
| 542 |
+
session: ISessionScopeHandle,
|
| 543 |
+
agent: IAgentScopeHandle,
|
| 544 |
+
prompt: string,
|
| 545 |
+
outputFormat: PromptOutputFormat,
|
| 546 |
+
stdout: PromptOutput,
|
| 547 |
+
stderr: PromptOutput,
|
| 548 |
+
): Promise<void> {
|
| 549 |
+
const writer: PromptTurnWriter =
|
| 550 |
+
outputFormat === 'stream-json'
|
| 551 |
+
? new PromptJsonWriter(stdout)
|
| 552 |
+
: new PromptTranscriptWriter(stdout, stderr);
|
| 553 |
+
|
| 554 |
+
await agent.accessor.get(IAuthSummaryService).ensureReady();
|
| 555 |
+
|
| 556 |
+
const turnEndings = createPrintTurnEndings();
|
| 557 |
+
const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2<any>) => {
|
| 558 |
+
dispatchNativeEvent(writer, event, stderr);
|
| 559 |
+
// Arm the turn-endings collector before `turn.result` settles so a
|
| 560 |
+
// background-task completion that steers a new turn right after the main
|
| 561 |
+
// turn ends cannot have its `turn.ended` slip past the policy loop.
|
| 562 |
+
if (event.type === 'turn.ended') turnEndings.push(event as TurnEnded);
|
| 563 |
+
});
|
| 564 |
+
try {
|
| 565 |
+
const loop = agent.accessor.get(IAgentLoopService);
|
| 566 |
+
const { id } = loop.submit({
|
| 567 |
+
message: { role: 'user', content: [{ type: 'text', text: prompt }] },
|
| 568 |
+
meta: { origin: { kind: 'user' }, tracked: true },
|
| 569 |
+
});
|
| 570 |
+
const handle = loop.promptHandle(id)!;
|
| 571 |
+
const turn = await handle.launched;
|
| 572 |
+
if (turn === undefined) {
|
| 573 |
+
// A prompt blocked by an onBeforeSubmitPrompt hook never launches a turn.
|
| 574 |
+
writer.finish();
|
| 575 |
+
const completion = await handle.completion;
|
| 576 |
+
throw new Error(
|
| 577 |
+
completion.state === 'blocked'
|
| 578 |
+
? 'Prompt hook blocked the request.'
|
| 579 |
+
: 'Prompt turn could not be started',
|
| 580 |
+
);
|
| 581 |
+
}
|
| 582 |
+
const result = await turn.result;
|
| 583 |
+
|
| 584 |
+
// Turn settled, but `-p` is not done until the print-mode background
|
| 585 |
+
// policy says so (config-driven: exit / drain / steer). Flush the buffered
|
| 586 |
+
// assistant message first so a long drain/steer wait does not withhold the
|
| 587 |
+
// final message.
|
| 588 |
+
writer.flushAssistant();
|
| 589 |
+
if (result.type === 'completed') {
|
| 590 |
+
const skipTurnId = turn.id;
|
| 591 |
+
if (skipTurnId === undefined) {
|
| 592 |
+
throw new Error('Prompt turn ended before it started');
|
| 593 |
+
}
|
| 594 |
+
const configService = app.accessor.get(IConfigService);
|
| 595 |
+
const taskConfig = resolveAgentTaskConfig(configService);
|
| 596 |
+
const goalService = agent.accessor.get(IAgentGoalService);
|
| 597 |
+
const cronService = agent.accessor.get(IAgentCronService);
|
| 598 |
+
try {
|
| 599 |
+
await applyPrintBackgroundPolicy({
|
| 600 |
+
mode: resolvePrintBackgroundMode(configService),
|
| 601 |
+
ceilingS: taskConfig?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT,
|
| 602 |
+
maxTurns: taskConfig?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT,
|
| 603 |
+
countPending: () => countPendingBackgroundTasks(session),
|
| 604 |
+
drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS),
|
| 605 |
+
turnEndings,
|
| 606 |
+
skipTurnId,
|
| 607 |
+
warn: (message) => stderr.write(`Warning: ${message}\n`),
|
| 608 |
+
now: () => Date.now(),
|
| 609 |
+
goalActive: () => goalService.getGoal().goal?.status === 'active',
|
| 610 |
+
cronNextFireAt: () => cronService.getNextFireTime(),
|
| 611 |
+
});
|
| 612 |
+
} catch (error) {
|
| 613 |
+
// A steered turn that fails fails the run (v1 parity). Anything else
|
| 614 |
+
// is best-effort: a wedged background task must not fail the (already
|
| 615 |
+
// completed) main turn.
|
| 616 |
+
if (error instanceof PrintSteeredTurnFailedError) {
|
| 617 |
+
writer.finish();
|
| 618 |
+
throw error;
|
| 619 |
+
}
|
| 620 |
+
stderr.write(
|
| 621 |
+
`Warning: print background policy failed: ${
|
| 622 |
+
error instanceof Error ? error.message : String(error)
|
| 623 |
+
}\n`,
|
| 624 |
+
);
|
| 625 |
+
}
|
| 626 |
+
writer.finish();
|
| 627 |
+
return;
|
| 628 |
+
}
|
| 629 |
+
writer.finish();
|
| 630 |
+
throw new Error(formatNativeTurnFailure(result));
|
| 631 |
+
} catch (error) {
|
| 632 |
+
writer.finish();
|
| 633 |
+
throw error instanceof Error ? error : new Error(String(error));
|
| 634 |
+
} finally {
|
| 635 |
+
subscription.dispose();
|
| 636 |
+
}
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
async function runNativeGoal(
|
| 640 |
+
app: Scope,
|
| 641 |
+
session: ISessionScopeHandle,
|
| 642 |
+
agent: IAgentScopeHandle,
|
| 643 |
+
goal: HeadlessGoalCreate,
|
| 644 |
+
model: string | undefined,
|
| 645 |
+
outputFormat: PromptOutputFormat,
|
| 646 |
+
stdout: PromptOutput,
|
| 647 |
+
stderr: PromptOutput,
|
| 648 |
+
): Promise<void> {
|
| 649 |
+
requireConfiguredModel(model);
|
| 650 |
+
const goalService = agent.accessor.get(IAgentGoalService);
|
| 651 |
+
await goalService.createGoal({
|
| 652 |
+
objective: goal.objective,
|
| 653 |
+
replace: goal.replace,
|
| 654 |
+
});
|
| 655 |
+
let completedSnapshot: { readonly status: string } | null = null;
|
| 656 |
+
const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2<any>) => {
|
| 657 |
+
if (event.type === 'goal.updated') {
|
| 658 |
+
const updated = event as unknown as GoalUpdated;
|
| 659 |
+
if (updated.change?.kind === 'completion' && updated.snapshot !== null) {
|
| 660 |
+
completedSnapshot = updated.snapshot;
|
| 661 |
+
}
|
| 662 |
+
}
|
| 663 |
+
});
|
| 664 |
+
try {
|
| 665 |
+
await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr);
|
| 666 |
+
} finally {
|
| 667 |
+
subscription.dispose();
|
| 668 |
+
const snapshot = completedSnapshot ?? goalService.getGoal().goal;
|
| 669 |
+
if (outputFormat === 'stream-json') {
|
| 670 |
+
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
|
| 671 |
+
} else {
|
| 672 |
+
stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
|
| 673 |
+
}
|
| 674 |
+
if (snapshot !== null && snapshot.status !== 'complete') {
|
| 675 |
+
process.exitCode = goalExitCode(snapshot.status);
|
| 676 |
+
}
|
| 677 |
+
}
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
function dispatchNativeEvent(
|
| 681 |
+
writer: PromptTurnWriter,
|
| 682 |
+
event: Event2<any>,
|
| 683 |
+
stderr: PromptOutput,
|
| 684 |
+
): void {
|
| 685 |
+
switch (event.type) {
|
| 686 |
+
case 'turn.step.started':
|
| 687 |
+
case 'turn.step.interrupted':
|
| 688 |
+
writer.flushAssistant();
|
| 689 |
+
return;
|
| 690 |
+
case 'turn.step.retrying':
|
| 691 |
+
writer.discardAssistant();
|
| 692 |
+
writer.writeRetrying(event as unknown as TurnStepRetrying);
|
| 693 |
+
return;
|
| 694 |
+
case 'assistant.delta':
|
| 695 |
+
writer.writeAssistantDelta((event as unknown as AssistantDelta).delta);
|
| 696 |
+
return;
|
| 697 |
+
case 'hook.result':
|
| 698 |
+
writer.writeHookResult(event as unknown as HookResult);
|
| 699 |
+
return;
|
| 700 |
+
case 'thinking.delta':
|
| 701 |
+
writer.writeThinkingDelta((event as unknown as ThinkingDelta).delta);
|
| 702 |
+
return;
|
| 703 |
+
case 'tool.call.started': {
|
| 704 |
+
const started = event as unknown as ToolCallStarted;
|
| 705 |
+
writer.writeToolCall(started.toolCallId, started.name, started.args);
|
| 706 |
+
return;
|
| 707 |
+
}
|
| 708 |
+
case 'tool.call.delta': {
|
| 709 |
+
const delta = event as unknown as ToolCallDelta;
|
| 710 |
+
writer.writeToolCallDelta(delta.toolCallId, delta.name, delta.argumentsPart);
|
| 711 |
+
return;
|
| 712 |
+
}
|
| 713 |
+
case 'tool.result': {
|
| 714 |
+
const result = event as unknown as ToolResultEvent;
|
| 715 |
+
writer.writeToolResult(result.toolCallId, result.output);
|
| 716 |
+
return;
|
| 717 |
+
}
|
| 718 |
+
case 'tool.progress': {
|
| 719 |
+
const progress = (event as unknown as ToolProgress).update;
|
| 720 |
+
if (progress.text !== undefined && progress.text.length > 0) {
|
| 721 |
+
stderr.write(progress.text.endsWith('\n') ? progress.text : `${progress.text}\n`);
|
| 722 |
+
}
|
| 723 |
+
return;
|
| 724 |
+
}
|
| 725 |
+
}
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
export type PrintTurnEnding = TurnEnded;
|
| 729 |
+
|
| 730 |
+
/**
|
| 731 |
+
* Source of `turn.ended` events for the print steer loop. `next` resolves with
|
| 732 |
+
* the next ending (skipping `skipTurnId`, the main turn's own buffered
|
| 733 |
+
* ending), or `null` when `remainingMs` elapses first.
|
| 734 |
+
*/
|
| 735 |
+
export interface PrintTurnEndings {
|
| 736 |
+
next(remainingMs: number, skipTurnId: number): Promise<PrintTurnEnding | null>;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
/**
|
| 740 |
+
* Buffered `turn.ended` collector fed from the agent event bus. Events that
|
| 741 |
+
* arrive while no one is waiting are queued, so endings that fire between the
|
| 742 |
+
* main turn settling and the policy loop starting are not missed.
|
| 743 |
+
*/
|
| 744 |
+
export function createPrintTurnEndings(): PrintTurnEndings & {
|
| 745 |
+
push: (event: PrintTurnEnding) => void;
|
| 746 |
+
} {
|
| 747 |
+
const buffer: PrintTurnEnding[] = [];
|
| 748 |
+
let waiter: ((ending: PrintTurnEnding | null) => void) | undefined;
|
| 749 |
+
return {
|
| 750 |
+
push: (event) => {
|
| 751 |
+
const resolve = waiter;
|
| 752 |
+
if (resolve !== undefined) {
|
| 753 |
+
waiter = undefined;
|
| 754 |
+
resolve(event);
|
| 755 |
+
return;
|
| 756 |
+
}
|
| 757 |
+
buffer.push(event);
|
| 758 |
+
},
|
| 759 |
+
next: async (remainingMs, skipTurnId) => {
|
| 760 |
+
const deadlineAt = Date.now() + remainingMs;
|
| 761 |
+
const waitOnce = (ms: number): Promise<PrintTurnEnding | null> =>
|
| 762 |
+
new Promise((resolve) => {
|
| 763 |
+
let settled = false;
|
| 764 |
+
const settle = (value: PrintTurnEnding | null): void => {
|
| 765 |
+
if (settled) return;
|
| 766 |
+
settled = true;
|
| 767 |
+
clearTimeout(timer);
|
| 768 |
+
waiter = undefined;
|
| 769 |
+
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
|
| 770 |
+
resolve(value);
|
| 771 |
+
};
|
| 772 |
+
// A delay beyond the host timer ceiling (an explicit
|
| 773 |
+
// `print_wait_ceiling_s` or a far-future cron fire can still reach
|
| 774 |
+
// it) is clamped by `setClampedTimeout`, so the timer can expire
|
| 775 |
+
// early: the loop below treats that as a chunk boundary and
|
| 776 |
+
// re-arms against the real deadline.
|
| 777 |
+
const timer = Number.isFinite(ms)
|
| 778 |
+
? setClampedTimeout(() => {
|
| 779 |
+
settle(null);
|
| 780 |
+
}, ms)
|
| 781 |
+
: undefined;
|
| 782 |
+
waiter = settle;
|
| 783 |
+
});
|
| 784 |
+
for (;;) {
|
| 785 |
+
while (buffer.length > 0) {
|
| 786 |
+
const ending = buffer.shift()!;
|
| 787 |
+
if (ending.turnId !== skipTurnId) return ending;
|
| 788 |
+
}
|
| 789 |
+
const ms = deadlineAt - Date.now();
|
| 790 |
+
if (ms <= 0) return null;
|
| 791 |
+
const ending = await waitOnce(ms);
|
| 792 |
+
// Timer-chunk boundary, not the real deadline: keep waiting.
|
| 793 |
+
if (ending === null) continue;
|
| 794 |
+
if (ending.turnId !== skipTurnId) return ending;
|
| 795 |
+
// The skipped turn's own ending: keep waiting within the same budget.
|
| 796 |
+
}
|
| 797 |
+
},
|
| 798 |
+
};
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
/** A background-task completion steered a new main turn that did not complete. */
|
| 802 |
+
export class PrintSteeredTurnFailedError extends Error {}
|
| 803 |
+
|
| 804 |
+
export interface PrintBackgroundPolicyInput {
|
| 805 |
+
readonly mode: PrintBackgroundMode;
|
| 806 |
+
readonly ceilingS: number;
|
| 807 |
+
readonly maxTurns: number;
|
| 808 |
+
readonly countPending: () => number;
|
| 809 |
+
readonly drain: () => Promise<void>;
|
| 810 |
+
readonly turnEndings: PrintTurnEndings;
|
| 811 |
+
readonly skipTurnId: number;
|
| 812 |
+
readonly warn: (message: string) => void;
|
| 813 |
+
readonly now: () => number;
|
| 814 |
+
/**
|
| 815 |
+
* Reports whether an agent goal is still `active`. v2 drives goal
|
| 816 |
+
* continuation as new turns (v1 keeps a single turn alive), so a `-p` goal
|
| 817 |
+
* run must stay alive until the goal leaves `active`, independent of the
|
| 818 |
+
* background policy.
|
| 819 |
+
*/
|
| 820 |
+
readonly goalActive?: () => boolean;
|
| 821 |
+
/**
|
| 822 |
+
* Reports the next scheduled cron fire time (epoch ms), or `null` when no
|
| 823 |
+
* cron task has a future fire. While it returns non-null the policy keeps
|
| 824 |
+
* the process alive — the cron tick timer itself is unref'd — waiting for
|
| 825 |
+
* the fire to steer a new turn, then re-evaluating (a fired one-shot task
|
| 826 |
+
* disappears; a recurring one reports its advanced next fire). Cron
|
| 827 |
+
* liveness is independent of the background mode: it applies under
|
| 828 |
+
* `exit`/`drain` too (v1 parity). Omitted = no cron waiting.
|
| 829 |
+
*/
|
| 830 |
+
readonly cronNextFireAt?: () => number | null;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
/**
|
| 834 |
+
* Apply the print-mode (`kimi -p`) background-resource policy after the main
|
| 835 |
+
* turn completes. A single loop re-evaluates the Session's live resources in
|
| 836 |
+
* order on every round and stays alive while any of them is pending:
|
| 837 |
+
* - goal : while a goal is `active`, keep waiting for its continuation
|
| 838 |
+
* turns (bounded by `ceilingS` as a safety net), regardless of
|
| 839 |
+
* the background mode; the goal summary drives the exit code.
|
| 840 |
+
* - cron : while `cronNextFireAt` reports a future fire, keep waiting —
|
| 841 |
+
* the cron tick timer is unref'd, so the process must hold the
|
| 842 |
+
* event loop itself (v1 parity, independent of the mode). The
|
| 843 |
+
* fire steers a new turn; a steered turn that does not complete
|
| 844 |
+
* fails the run. Each round re-reads the next fire time, so a
|
| 845 |
+
* fired one-shot task ends the wait while a recurring one keeps
|
| 846 |
+
* it. A fire time that stays unchanged and in the past across
|
| 847 |
+
* two consecutive rounds means the tick is wedged: warn once and
|
| 848 |
+
* stop cron waiting instead of spinning.
|
| 849 |
+
* - mode : 'exit' → return immediately;
|
| 850 |
+
* 'drain' → suppress + drain background tasks, then return;
|
| 851 |
+
* 'steer' → while background tasks are still pending, stay alive
|
| 852 |
+
* so task completions steer new main turns; return once
|
| 853 |
+
* quiescent, or when the wall-clock ceiling (`ceilingS`) or the
|
| 854 |
+
* turn cap (`maxTurns`) is reached. A steered turn that does not
|
| 855 |
+
* complete fails the run.
|
| 856 |
+
* The steer ceiling deadline is set once on entry, so goal/cron waiting
|
| 857 |
+
* consumes the same budget.
|
| 858 |
+
*/
|
| 859 |
+
export async function applyPrintBackgroundPolicy(
|
| 860 |
+
input: PrintBackgroundPolicyInput,
|
| 861 |
+
): Promise<void> {
|
| 862 |
+
const deadline = input.now() + input.ceilingS * 1000;
|
| 863 |
+
let turns = 0;
|
| 864 |
+
// Cron anti-spin guard: the last fire time seen already in the past. Two
|
| 865 |
+
// consecutive rounds with the same past fire time mean the tick never ran.
|
| 866 |
+
let lastPastFireAt: number | undefined;
|
| 867 |
+
let cronWedged = false;
|
| 868 |
+
for (;;) {
|
| 869 |
+
// (a) goal: while a goal is `active`, keep waiting for its continuation
|
| 870 |
+
// turns. Also wake on a short poll: a goal can leave `active` without any
|
| 871 |
+
// further turn.ended (budget block at a turn boundary, or a pause after a
|
| 872 |
+
// continuation-launch failure), which would otherwise hang the run until
|
| 873 |
+
// the ceiling. A continuation turn that does not complete pauses/blocks
|
| 874 |
+
// the goal, so the condition exits on the next check.
|
| 875 |
+
while (input.goalActive?.() === true) {
|
| 876 |
+
const ended = await input.turnEndings.next(
|
| 877 |
+
Math.min(deadline - input.now(), GOAL_WAIT_POLL_MS),
|
| 878 |
+
input.skipTurnId,
|
| 879 |
+
);
|
| 880 |
+
if (ended === null && input.now() >= deadline) {
|
| 881 |
+
input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`);
|
| 882 |
+
return;
|
| 883 |
+
}
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
// (b) cron: keep the process alive until the pending fire steered a turn
|
| 887 |
+
// (one-shot tasks vanish after firing; recurring ones advance their next
|
| 888 |
+
// fire), then re-evaluate from the top.
|
| 889 |
+
if (!cronWedged && input.cronNextFireAt !== undefined) {
|
| 890 |
+
const fireAt = input.cronNextFireAt();
|
| 891 |
+
if (fireAt !== null) {
|
| 892 |
+
if (fireAt <= input.now() && lastPastFireAt === fireAt) {
|
| 893 |
+
cronWedged = true;
|
| 894 |
+
input.warn(
|
| 895 |
+
'print cron wait: next fire time stuck in the past; cron tick appears wedged, giving up on cron',
|
| 896 |
+
);
|
| 897 |
+
} else {
|
| 898 |
+
if (fireAt <= input.now()) lastPastFireAt = fireAt;
|
| 899 |
+
const ended = await input.turnEndings.next(
|
| 900 |
+
Math.max(fireAt - input.now(), 0) + CRON_FIRE_GRACE_MS,
|
| 901 |
+
input.skipTurnId,
|
| 902 |
+
);
|
| 903 |
+
if (ended !== null && ended.reason !== 'completed') {
|
| 904 |
+
throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
|
| 905 |
+
}
|
| 906 |
+
// Fire observed (or its grace elapsed without a turn): re-read the
|
| 907 |
+
// next fire time from the top.
|
| 908 |
+
continue;
|
| 909 |
+
}
|
| 910 |
+
}
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
// (c) background-task mode.
|
| 914 |
+
if (input.mode === 'exit') return;
|
| 915 |
+
if (input.mode === 'drain') {
|
| 916 |
+
await input.drain();
|
| 917 |
+
return;
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
// 'steer'
|
| 921 |
+
turns += 1;
|
| 922 |
+
if (input.now() >= deadline) {
|
| 923 |
+
input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`);
|
| 924 |
+
return;
|
| 925 |
+
}
|
| 926 |
+
if (turns > input.maxTurns) {
|
| 927 |
+
input.warn(`print steer max turns reached (${input.maxTurns}), finishing`);
|
| 928 |
+
return;
|
| 929 |
+
}
|
| 930 |
+
if (input.countPending() === 0) return;
|
| 931 |
+
const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId);
|
| 932 |
+
if (ended === null) return;
|
| 933 |
+
if (ended.reason !== 'completed') {
|
| 934 |
+
throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
|
| 935 |
+
}
|
| 936 |
+
}
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
function formatTurnEndingFailure(ending: PrintTurnEnding): string {
|
| 940 |
+
if (ending.error?.code === 'provider.filtered') {
|
| 941 |
+
return 'Provider safety policy blocked the response.';
|
| 942 |
+
}
|
| 943 |
+
if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`;
|
| 944 |
+
if (ending.reason === 'blocked') {
|
| 945 |
+
return 'Prompt hook blocked the request.';
|
| 946 |
+
}
|
| 947 |
+
return `Prompt turn ended with reason: ${ending.reason}`;
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
function countPendingBackgroundTasks(session: ISessionScopeHandle): number {
|
| 951 |
+
let count = 0;
|
| 952 |
+
const agentManager = session.accessor.get(IAgentLifecycleService);
|
| 953 |
+
for (const agent of agentManager.list()) {
|
| 954 |
+
const handle = agentManager.handleOf(agent.agentId);
|
| 955 |
+
if (handle === undefined) continue;
|
| 956 |
+
count += handle.accessor.get(IAgentTaskService).list(true).length;
|
| 957 |
+
}
|
| 958 |
+
return count;
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
/** Every agent handle in the session; the main agent is included explicitly since the lifecycle list skips `closing` agents. */
|
| 962 |
+
function collectSessionAgentHandles(
|
| 963 |
+
session: ISessionScopeHandle,
|
| 964 |
+
mainAgent: IAgentScopeHandle,
|
| 965 |
+
): IAgentScopeHandle[] {
|
| 966 |
+
const agentManager = session.accessor.get(IAgentLifecycleService);
|
| 967 |
+
const handles = new Set<IAgentScopeHandle>([mainAgent]);
|
| 968 |
+
for (const agent of agentManager.list()) {
|
| 969 |
+
const handle = agentManager.handleOf(agent.agentId);
|
| 970 |
+
if (handle !== undefined) handles.add(handle);
|
| 971 |
+
}
|
| 972 |
+
return [...handles];
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
/**
|
| 976 |
+
* Stop producers, drain prompts, and cancel turns so closing records exist
|
| 977 |
+
* before the wire flush; returns a release holding a guard per loop.
|
| 978 |
+
*/
|
| 979 |
+
async function quiesceSessionAgents(
|
| 980 |
+
session: ISessionScopeHandle,
|
| 981 |
+
mainAgent: IAgentScopeHandle,
|
| 982 |
+
): Promise<(() => void) | undefined> {
|
| 983 |
+
const handles = collectSessionAgentHandles(session, mainAgent);
|
| 984 |
+
const loops = handles.flatMap((handle) => {
|
| 985 |
+
try {
|
| 986 |
+
return [handle.accessor.get(IAgentLoopService)];
|
| 987 |
+
} catch {
|
| 988 |
+
// A torn-down agent scope has no loop to quiesce.
|
| 989 |
+
return [];
|
| 990 |
+
}
|
| 991 |
+
});
|
| 992 |
+
// Task producers bypass the prompt queue and dispatch termination records
|
| 993 |
+
// straight to the wire; stop them first so the flush can persist those.
|
| 994 |
+
await Promise.allSettled(
|
| 995 |
+
handles.flatMap((handle) => {
|
| 996 |
+
try {
|
| 997 |
+
return [handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed')];
|
| 998 |
+
} catch {
|
| 999 |
+
return [];
|
| 1000 |
+
}
|
| 1001 |
+
}),
|
| 1002 |
+
);
|
| 1003 |
+
// Repeat until every queue is empty and every loop freezable: a prompt can
|
| 1004 |
+
// still surface from the launch window or a cancelled turn's settle chain.
|
| 1005 |
+
for (;;) {
|
| 1006 |
+
for (const loop of loops) {
|
| 1007 |
+
for (const queueId of loop.snapshot().queue.map((item) => item.meta?.promptId)) {
|
| 1008 |
+
if (queueId !== undefined) loop.cancel({ promptId: queueId });
|
| 1009 |
+
}
|
| 1010 |
+
loop.cancel();
|
| 1011 |
+
}
|
| 1012 |
+
await Promise.allSettled(loops.map((loop) => loop.settled()));
|
| 1013 |
+
const guards: { dispose(): void }[] = [];
|
| 1014 |
+
let frozen = true;
|
| 1015 |
+
for (const loop of loops) {
|
| 1016 |
+
let guard: { dispose(): void } | undefined;
|
| 1017 |
+
try {
|
| 1018 |
+
guard = loop.tryAcquireQuiescence();
|
| 1019 |
+
} catch {
|
| 1020 |
+
// A disposed loop cannot accept new submissions; it needs no guard.
|
| 1021 |
+
continue;
|
| 1022 |
+
}
|
| 1023 |
+
if (guard === undefined) {
|
| 1024 |
+
frozen = false;
|
| 1025 |
+
break;
|
| 1026 |
+
}
|
| 1027 |
+
guards.push(guard);
|
| 1028 |
+
}
|
| 1029 |
+
const busy = loops.some((loop) => {
|
| 1030 |
+
try {
|
| 1031 |
+
const snapshot = loop.snapshot();
|
| 1032 |
+
return snapshot.state === 'running' || snapshot.queue.length > 0;
|
| 1033 |
+
} catch {
|
| 1034 |
+
return false;
|
| 1035 |
+
}
|
| 1036 |
+
});
|
| 1037 |
+
if (frozen && !busy) {
|
| 1038 |
+
return () => {
|
| 1039 |
+
for (const guard of guards) guard.dispose();
|
| 1040 |
+
};
|
| 1041 |
+
}
|
| 1042 |
+
for (const guard of guards) guard.dispose();
|
| 1043 |
+
await new Promise((resolve) => {
|
| 1044 |
+
setTimeout(resolve, PROMPT_QUIESCE_POLL_MS);
|
| 1045 |
+
});
|
| 1046 |
+
}
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
/** Flush every session agent's wire journal; each flush settles independently. */
|
| 1050 |
+
async function flushSessionWires(
|
| 1051 |
+
session: ISessionScopeHandle,
|
| 1052 |
+
mainAgent: IAgentScopeHandle,
|
| 1053 |
+
): Promise<void> {
|
| 1054 |
+
await Promise.allSettled(
|
| 1055 |
+
collectSessionAgentHandles(session, mainAgent).map((handle) =>
|
| 1056 |
+
handle.accessor.get(IEventDispatcher).flush(),
|
| 1057 |
+
),
|
| 1058 |
+
);
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
async function drainBackgroundTasks(
|
| 1062 |
+
session: ISessionScopeHandle,
|
| 1063 |
+
ceilingS: number | undefined,
|
| 1064 |
+
): Promise<void> {
|
| 1065 |
+
const ceilingMs =
|
| 1066 |
+
typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
|
| 1067 |
+
? ceilingS * 1000
|
| 1068 |
+
: PRINT_WAIT_CEILING_S_DEFAULT * 1000;
|
| 1069 |
+
|
| 1070 |
+
const deadline = Date.now() + ceilingMs;
|
| 1071 |
+
const seen = new Set<string>();
|
| 1072 |
+
const allWaiters: Promise<unknown>[] = [];
|
| 1073 |
+
while (Date.now() < deadline) {
|
| 1074 |
+
const batch: Promise<unknown>[] = [];
|
| 1075 |
+
const suppressions: Promise<void>[] = [];
|
| 1076 |
+
let activeCount = 0;
|
| 1077 |
+
const agentManager = session.accessor.get(IAgentLifecycleService);
|
| 1078 |
+
for (const agent of agentManager.list()) {
|
| 1079 |
+
const handle = agentManager.handleOf(agent.agentId);
|
| 1080 |
+
if (handle === undefined) continue;
|
| 1081 |
+
const taskService = handle.accessor.get(IAgentTaskService);
|
| 1082 |
+
for (const task of taskService.list(true)) {
|
| 1083 |
+
activeCount++;
|
| 1084 |
+
if (seen.has(task.taskId)) continue;
|
| 1085 |
+
seen.add(task.taskId);
|
| 1086 |
+
suppressions.push(taskService.suppressTerminalNotification(task.taskId));
|
| 1087 |
+
const remaining = Math.max(1, deadline - Date.now());
|
| 1088 |
+
const waiter = taskService.wait(task.taskId, remaining);
|
| 1089 |
+
batch.push(waiter);
|
| 1090 |
+
allWaiters.push(waiter);
|
| 1091 |
+
}
|
| 1092 |
+
}
|
| 1093 |
+
if (suppressions.length > 0) await Promise.all(suppressions);
|
| 1094 |
+
if (activeCount === 0 || batch.length === 0) break;
|
| 1095 |
+
await Promise.all(batch);
|
| 1096 |
+
}
|
| 1097 |
+
if (allWaiters.length > 0) await Promise.all(allWaiters);
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
function formatNativeTurnFailure(result: LoopRunResult): string {
|
| 1101 |
+
if (result.type === 'failed') {
|
| 1102 |
+
const error = result.error as { readonly code?: string; readonly message?: string } | undefined;
|
| 1103 |
+
if (error?.code === 'provider.filtered') {
|
| 1104 |
+
return 'Provider safety policy blocked the response.';
|
| 1105 |
+
}
|
| 1106 |
+
if (error?.code !== undefined) {
|
| 1107 |
+
return `${error.code}: ${error.message ?? ''}`.trimEnd();
|
| 1108 |
+
}
|
| 1109 |
+
if (result.error instanceof Error) {
|
| 1110 |
+
return result.error.message;
|
| 1111 |
+
}
|
| 1112 |
+
}
|
| 1113 |
+
return `Prompt turn ended with reason: ${result.type}`;
|
| 1114 |
+
}
|