Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
552e119
feat(kimi-code): support automatic updates for native installations v…
liruifengv Aug 17, 2026
965e94b
fix(kimi-code): fully clean staged artifacts on swap discard paths
liruifengv Aug 17, 2026
6a21aed
fix(kimi-code): restore staged metadata on swap failure and sweep upd…
liruifengv Aug 17, 2026
4d3fbaf
Merge remote-tracking branch 'origin/main' into feat/native-staged-au…
liruifengv Aug 17, 2026
e8284b6
fix(kimi-code): address codex review on lock contention and swap cras…
liruifengv Aug 17, 2026
5ee963b
Merge remote-tracking branch 'origin/feat/native-staged-auto-update' …
liruifengv Aug 17, 2026
993dafa
test(kimi-code): align swap test expectation with smoke-before-rename…
liruifengv Aug 17, 2026
bec250b
fix(kimi-code): stage the bare CDN binary instead of unzipping
liruifengv Aug 17, 2026
f04b4c0
fix(kimi-code): address second codex review round
liruifengv Aug 17, 2026
844f558
feat(kimi-code): show download progress for native updates
liruifengv Aug 17, 2026
3cb0cc0
fix(kimi-code): bound native update downloads with an idle timeout
liruifengv Aug 17, 2026
ef97ea3
fix(kimi-code): retry native updates blocked by an orphaned active re…
liruifengv Aug 17, 2026
11a913b
fix: skip staged swap while another instance holds a fresh claim
liruifengv Aug 17, 2026
bc747e0
fix: keep the install lock while its holder process is alive
liruifengv Aug 17, 2026
e630b0b
fix: keep recovery artifacts on rollback failure and wait out same-ve…
liruifengv Aug 17, 2026
07b52bd
fix: stamp the swap claim with a fresh mtime when claiming
liruifengv Aug 17, 2026
4019f20
fix: stamp the claim before the rename so it is born fresh
liruifengv Aug 17, 2026
40fbbcc
fix: chmod the staged download before publishing it at its final name
liruifengv Aug 17, 2026
d4dcccd
fix: publish the install lock atomically via hard link
liruifengv Aug 17, 2026
21d32be
fix: serialize stale-lock takeover through a secondary lock
liruifengv Aug 17, 2026
32f0e33
fix: verify lock ownership after publish and preserve freshly staged …
liruifengv Aug 17, 2026
41b3c0a
fix: reject mismatched manifests, take over from dead holders, unique…
liruifengv Aug 18, 2026
c8dbe15
fix: restrict staging cleanup to updater-owned names and retry short …
liruifengv Aug 18, 2026
e5aa04b
fix: scope failure cleanup, recognize all semvers, reverify staged ch…
liruifengv Aug 18, 2026
37bbf47
fix: validate versions before path derivation and honor the update op…
liruifengv Aug 18, 2026
8d8f3b9
fix: restrict backup cleanup to updater-owned .bak names
liruifengv Aug 18, 2026
d199e98
fix: claim staged metadata before validating it and let manual upgrad…
liruifengv Aug 18, 2026
849145e
fix: promote adopted stages to manual and preserve claim-referenced p…
liruifengv Aug 18, 2026
53dfffb
fix: drop exe deletion from stale-claim cleanup
liruifengv Aug 18, 2026
bdc9567
fix: never delete the staged exe when discarding a claim
liruifengv Aug 18, 2026
f1af868
fix: only reap staging orphans old enough to be abandoned
liruifengv Aug 18, 2026
763ebcf
fix: honor the persisted auto-update preference in the swap and drop …
liruifengv Aug 18, 2026
b934466
fix: gate on claimed metadata, stop shared-path deletes on failure, e…
liruifengv Aug 18, 2026
8168d5e
fix: confirm the manual marker before reporting stage adoption
liruifengv Aug 18, 2026
7d2d52f
fix(cli): verify the staged payload digest before adopting it as alre…
liruifengv Aug 18, 2026
bd90100
fix(cli): keep staged metadata until its replacement is ready
liruifengv Aug 18, 2026
0de8d01
fix(cli): preserve the exe referenced by the current staged record du…
liruifengv Aug 18, 2026
e539748
chore(kimi-code): reword the native auto-update changeset
liruifengv Aug 18, 2026
c01433d
chore(kimi-code): trim the native auto-update changeset
liruifengv Aug 18, 2026
eb71053
fix(cli): support update locking on filesystems without hard links
liruifengv Aug 18, 2026
9c4cf49
fix(cli): publish staged exes under unique names and recover orphaned…
liruifengv Aug 18, 2026
cf5e0c8
fix(cli): verify the staged payload digest in the lock-wait adoption …
liruifengv Aug 18, 2026
fbc61f6
fix(cli): serialize swap critical sections and preserve in-flight pub…
liruifengv Aug 18, 2026
cfc6717
fix(cli): keep backup cleanup inside the swap mutex
liruifengv Aug 18, 2026
f576043
Merge branch 'main' into feat/native-staged-auto-update
liruifengv Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-staged-auto-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Support automatic updates for native (single-binary) installations, including Windows: new versions download in the background, verify against the release checksum, and swap in on the next launch. Run `kimi upgrade` to update now, or let the background updater handle it.
11 changes: 11 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void;
export type UpgradeCommandHandler = () => void | Promise<void>;
export type UpdateDownloadHandler = (version: string) => void;

export function createProgram(
version: string,
onMain: MainCommandHandler,
onMigrate: MigrateCommandHandler,
onPluginNodeRunner: PluginNodeRunnerHandler = () => {},
onUpgrade: UpgradeCommandHandler = () => {},
onUpdateDownload: UpdateDownloadHandler = () => {},
): Command {
const program = new Command(CLI_COMMAND_NAME)
.description('The Starting Point for Next-Gen Agents')
Expand Down Expand Up @@ -138,6 +140,15 @@ export function createProgram(
onPluginNodeRunner(entry, args);
});

// Self-spawned worker for native staged updates (detached background
// download, or foreground from `kimi upgrade`). Hidden: not user-facing.
program
.command('__update_download', { hidden: true })
.argument('<version>')
.action((targetVersion: string) => {
onUpdateDownload(targetVersion);
});

program.argument('[args...]').action((args: string[]) => {
if (args.length > 0) {
program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`);
Expand Down
104 changes: 104 additions & 0 deletions apps/kimi-code/src/cli/sub/update-download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Hidden `kimi __update_download <version>` sub-command: the self-spawned
* worker behind native staged updates. Preflight spawns it detached in the
* background (and the `upgrade` command in the foreground); it downloads,
* verifies and stages the binary next to the running exe. The swap into
* place happens on the next startup (see `cli/update/native-swap.ts`).
*/

import { log } from '@moonshot-ai/kimi-code-sdk';

import {
readUpdateInstallLockVersion,
tryAcquireUpdateInstallLock,
} from '#/cli/update/install-lock';
import { stageNativeUpdate } from '#/cli/update/native-stage';
import { detectNativeInstall } from '#/cli/update/source';

export async function runUpdateDownloadCommand(version: string): Promise<number> {
if (!detectNativeInstall()) {
process.stderr.write('error: update download is only available in the native build\n');
return 1;
}
let lock = await tryAcquireUpdateInstallLock({ version });
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
if (lock === null) {
// Another instance holds the lock. Same target version → its outcome is
// ours, exit quietly. A different version (or a lock that vanished
// between acquire and read) must not surface as a successful download.
const holderVersion = await readUpdateInstallLockVersion();
if (holderVersion === version) return 0;
Comment thread
liruifengv marked this conversation as resolved.
Outdated
if (holderVersion === undefined) {
// The lock was released between the two reads — retry the acquire once.
lock = await tryAcquireUpdateInstallLock({ version });
}
if (lock === null) {
process.stderr.write(
`error: another update (${holderVersion ?? 'unknown version'}) is already downloading\n`,
);
return 1;
}
}
const out = process.stdout;
const label = `Downloading Kimi Code ${version} (${process.platform}-${process.arch})…`;
const onProgress = createDownloadProgress(out, label);
try {
const result = await stageNativeUpdate({
version,
exePath: process.execPath,
onProgress,
});
if (out.isTTY) out.write('\n');
if (result.status === 'already-staged') {
out.write(`Kimi Code ${version} is already downloaded; it applies on the next start.\n`);
}
return 0;
} catch (error) {
if (out.isTTY) out.write('\n');
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`error: failed to download update ${version}: ${message}\n`);
log.warn('native update download failed', { version, error: message });
return 1;
} finally {
await lock.release().catch(() => {});
}
}

const PROGRESS_FRAME_INTERVAL_MS = 100;
const PROGRESS_LINE_INTERVAL_BYTES = 32 * 1024 * 1024;

function formatDownloadProgress(label: string, downloaded: number, total: number | null): string {
const mb = Math.floor(downloaded / (1024 * 1024));
if (total === null || total <= 0) return `${label} ${mb} MB`;
const totalMb = Math.max(1, Math.round(total / (1024 * 1024)));
const percent = Math.min(100, Math.floor((downloaded / total) * 100));
return `${label} ${percent}% (${mb}/${totalMb} MB)`;
}

/**
* Download progress renderer for the (foreground) downloader: a single
* in-place line on a TTY (`\r` + clear-line, throttled to 10 fps, final frame
* always rendered), or one line per 32 MB when piped to a file. The caller
* owns the trailing newline.
*/
export function createDownloadProgress(
out: NodeJS.WriteStream,
label: string,
): (downloadedBytes: number, totalBytes: number | null) => void {
const isTTY = out.isTTY;
let lastFrameAt = 0;
let lastLineAt = 0;
if (!isTTY) out.write(`${label}\n`);
return (downloaded, total) => {
const done = total !== null && downloaded >= total;
if (isTTY) {
const now = Date.now();
if (!done && now - lastFrameAt < PROGRESS_FRAME_INTERVAL_MS) return;
lastFrameAt = now;
out.write(`\r\u001B[K${formatDownloadProgress(label, downloaded, total)}`);
return;
}
if (!done && downloaded - lastLineAt < PROGRESS_LINE_INTERVAL_BYTES) return;
lastLineAt = downloaded;
out.write(`${formatDownloadProgress(label, downloaded, total)}\n`);
};
}
24 changes: 24 additions & 0 deletions apps/kimi-code/src/cli/update/install-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,27 @@ export async function tryAcquireUpdateInstallLock(
throw error;
}
}

/**
* Return the version recorded in the held lock file, or undefined when the
* lock is gone or unreadable. Lets a downloader that failed to acquire the
* lock distinguish "another instance is staging the SAME version" (its
* outcome is ours — report success) from "a different version is in flight"
* (must not be reported as success to a foreground `kimi upgrade`).
*/
export async function readUpdateInstallLockVersion(
filePath: string = getUpdateInstallLockFile(),
): Promise<string | undefined> {
let raw: string;
try {
raw = await readFile(filePath, 'utf-8');
} catch {
return undefined;
}
try {
const version: unknown = (JSON.parse(raw) as { version?: unknown }).version;
return typeof version === 'string' && version.length > 0 ? version : undefined;
} catch {
return undefined;
}
}
94 changes: 94 additions & 0 deletions apps/kimi-code/src/cli/update/native-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Per-release native artifact manifest (`/binaries/<version>/manifest.json`).
*
* Published alongside the release and consumed by the install scripts; the
* staged updater reuses the same file so checksums and file names have a
* single source of truth. Entries point at the bare platform binary
* (`kimi-code-<target>[.exe]`), not an archive.
*/

import { valid } from 'semver';
import { z } from 'zod';

import { KIMI_CODE_CDN_BINARIES_BASE } from '#/constant/app';

const MANIFEST_FETCH_TIMEOUT_MS = 10_000;

const PlatformEntrySchema = z.object({
filename: z.string().min(1),
checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
});

/**
* Deliberately NOT `.strict()` — unknown fields are ignored so future
* manifest additions never break shipped clients (same contract philosophy
* as the rollout manifest in `cdn.ts`).
*/
export const NativeReleaseManifestSchema = z.object({
version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
platforms: z.record(z.string(), PlatformEntrySchema),
});

export type NativeReleaseManifest = z.infer<typeof NativeReleaseManifestSchema>;
export type NativePlatformEntry = z.infer<typeof PlatformEntrySchema>;

export function nativeManifestUrl(version: string): string {
return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/manifest.json`;
}

export function nativeBinaryUrl(version: string, filename: string): string {
return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/${filename}`;
}

/**
* Fetch and parse the per-release manifest. **Throws** on any failure
* (network, non-2xx, malformed body, unknown version) — callers treat a
* throw as "staging failed" and record an install failure.
*
* `version` goes into the URL, so it must be a valid semver (it always is:
* upstream sources are the CDN `latest.json` / the `upgrade` command).
* `fetchImpl` is injectable for tests.
*/
export async function fetchNativeReleaseManifest(
version: string,
fetchImpl: typeof fetch = fetch,
): Promise<NativeReleaseManifest> {
if (valid(version) === null) {
throw new Error(`invalid semver for native manifest lookup: ${JSON.stringify(version)}`);
}
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, MANIFEST_FETCH_TIMEOUT_MS);
// The timeout must stay armed until the BODY is fully consumed: a CDN or
// proxy can deliver headers within the limit and then stall mid-body, and
// resolving `fetch()` alone would clear the timer and hang the worker.
try {
const response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal });
if (!response.ok) {
throw new Error(`native manifest for ${version} returned HTTP ${response.status}`);
}
return NativeReleaseManifestSchema.parse(JSON.parse(await response.text()));
Comment thread
liruifengv marked this conversation as resolved.
Outdated
} finally {
clearTimeout(timeout);
}
}

/**
* Pick the entry for the running platform. The release pipeline keys
* platforms by `<node platform>-<node arch>` (win32-x64, darwin-arm64, …).
* **Throws** when the platform is missing — a silent skip would strand the
* update in a retry loop.
*/
export function selectPlatformEntry(
manifest: NativeReleaseManifest,
platform: NodeJS.Platform,
arch: string,
): NativePlatformEntry {
const target = `${platform}-${arch}`;
const entry = manifest.platforms[target];
if (entry === undefined) {
throw new Error(`platform ${target} not found in native manifest for ${manifest.version}`);
}
return entry;
}
Loading
Loading