SaylorTwift HF Staff commited on
Commit
7c5ed63
·
verified ·
1 Parent(s): 5710dd0

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. apps/kimi-code/dist-web/boot.js +31 -0
  2. apps/kimi-code/dist-web/favicon.ico +0 -0
  3. apps/kimi-code/dist-web/index.html +23 -0
  4. apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +264 -0
  5. apps/kimi-code/scripts/build-vis-asset.mjs +54 -0
  6. apps/kimi-code/scripts/built-in-catalog.mjs +10 -0
  7. apps/kimi-code/scripts/check-web-assets.mjs +32 -0
  8. apps/kimi-code/scripts/copy-native-assets.mjs +37 -0
  9. apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs +239 -0
  10. apps/kimi-code/scripts/dev-server-restart.mjs +127 -0
  11. apps/kimi-code/scripts/dev.mjs +82 -0
  12. apps/kimi-code/scripts/plugin-manifest-version.mjs +38 -0
  13. apps/kimi-code/scripts/postinstall.mjs +175 -0
  14. apps/kimi-code/scripts/smoke.mjs +71 -0
  15. apps/kimi-code/scripts/update-catalog.mjs +100 -0
  16. apps/kimi-code/src/built-in-catalog.ts +8 -0
  17. apps/kimi-code/src/cli/agent-selection.ts +42 -0
  18. apps/kimi-code/src/cli/build-info.ts +34 -0
  19. apps/kimi-code/src/cli/commands.ts +191 -0
  20. apps/kimi-code/src/cli/goal-prompt.ts +103 -0
  21. apps/kimi-code/src/cli/headless-exit.ts +96 -0
  22. apps/kimi-code/src/cli/options.ts +121 -0
  23. apps/kimi-code/src/cli/prompt-render.ts +410 -0
  24. apps/kimi-code/src/cli/prompt-session.ts +26 -0
  25. apps/kimi-code/src/cli/run-prompt.ts +94 -0
  26. apps/kimi-code/src/cli/run-shell.ts +288 -0
  27. apps/kimi-code/src/cli/startup-error.ts +35 -0
  28. apps/kimi-code/src/cli/sub/acp.ts +75 -0
  29. apps/kimi-code/src/cli/sub/doctor.ts +335 -0
  30. apps/kimi-code/src/cli/sub/export.ts +251 -0
  31. apps/kimi-code/src/cli/sub/login.ts +26 -0
  32. apps/kimi-code/src/cli/sub/plugin-run-node.ts +26 -0
  33. apps/kimi-code/src/cli/sub/update-download.ts +185 -0
  34. apps/kimi-code/src/cli/sub/vis.ts +158 -0
  35. apps/kimi-code/src/cli/telemetry.ts +131 -0
  36. apps/kimi-code/src/cli/update/cache.ts +38 -0
  37. apps/kimi-code/src/cli/update/cdn.ts +106 -0
  38. apps/kimi-code/src/cli/update/install-lock.ts +254 -0
  39. apps/kimi-code/src/cli/update/install-state.ts +64 -0
  40. apps/kimi-code/src/cli/update/native-manifest.ts +115 -0
  41. apps/kimi-code/src/cli/update/native-stage.ts +556 -0
  42. apps/kimi-code/src/cli/update/native-swap.ts +642 -0
  43. apps/kimi-code/src/cli/update/preflight.ts +924 -0
  44. apps/kimi-code/src/cli/update/prompt.ts +188 -0
  45. apps/kimi-code/src/cli/update/refresh.ts +36 -0
  46. apps/kimi-code/src/cli/update/rollout.ts +210 -0
  47. apps/kimi-code/src/cli/update/select.ts +13 -0
  48. apps/kimi-code/src/cli/update/source.ts +172 -0
  49. apps/kimi-code/src/cli/update/types.ts +85 -0
  50. 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
+ }