Skip to content
Merged
Show file tree
Hide file tree
Changes from 42 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
---

The Windows native (single-binary) CLI now supports automatic updates.
13 changes: 13 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, manual: boolean) => void;

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

// Self-spawned worker for native staged updates (detached background
// download, or foreground from `kimi upgrade` — `--manual` marks the
// latter's stage as user-requested). Hidden: not user-facing.
program
.command('__update_download', { hidden: true })
.argument('<version>')
.option('--manual', 'the stage answers an explicit user-initiated upgrade')
.action((targetVersion: string, options: { manual?: boolean }) => {
onUpdateDownload(targetVersion, options.manual === true);
});

program.argument('[args...]').action((args: string[]) => {
if (args.length > 0) {
program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`);
Expand Down
185 changes: 185 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,185 @@
/**
* 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,
type UpdateInstallLockHandle,
} from '#/cli/update/install-lock';
import {
hashFileSha256,
promoteStagedUpdateToManual,
readStagedNativeUpdate,
stagedExePath,
stageNativeUpdate,
} from '#/cli/update/native-stage';
import { detectNativeInstall } from '#/cli/update/source';

const LOCK_HELD_POLL_INTERVAL_MS = 2_000;

type StagedUpdateWait =
| { readonly status: 'staged' }
| { readonly status: 'takeover'; readonly lock: UpdateInstallLockHandle | null };

/**
* Another worker holds the install lock for the SAME version. Returning right
* away would report a success that has not happened yet — the in-flight
* download may still fail — so wait for it: 'staged' once its staged update is
* verified on disk; 'takeover' once the lock becomes acquirable, with the lock
* already held for the caller. The lock goes stale the moment its holder dies
* (see install-lock), so a killed downloader cannot strand a foreground
* `kimi upgrade` in this loop.
*
* Adoption applies the same integrity bar as stageNativeUpdate's
* already-staged path: the recorded size proves nothing, and the holder may
* still be RE-STAGING a same-size-corrupted payload (its metadata is only
* replaced when the new generation publishes). A recorded stage whose payload
* fails the checksum is treated as not-yet-staged — the lock poll below takes
* over once the holder finishes without repairing it.
*
* A manual (explicit-upgrade) waiter adopts only after CONFIRMING the manual
* marker landed on the stage — a concurrent startup swap may be claiming and
* restoring the metadata right now, and reporting adoption for a promotion
* that never persisted would strand the update under the env opt-out.
*/
async function waitForStagedUpdate(
version: string,
exePath: string,
manual: boolean,
): Promise<StagedUpdateWait> {
for (;;) {
const staged = await readStagedNativeUpdate(exePath);
const digest =
staged !== null && staged.version === version
? await hashFileSha256(stagedExePath(exePath, staged))
: null;
if (staged !== null && digest === staged.sha256) {
if (!manual || (await promoteStagedUpdateToManual(exePath, staged))) {
return { status: 'staged' };
}
// The stage is being claimed/restored by a concurrent swap — the next
// poll either promotes the restored stage or takes over once it is
// gone.
} else {
// Poll the acquisition itself: while the holder lives its lock stays
// fresh and this returns null without side effects; when the holder
// finishes (or dies) without staging a VERIFIED payload, the takeover
// happens right here.
const lock = await tryAcquireUpdateInstallLock({ version });
if (lock !== null) return { status: 'takeover', lock };
}
await new Promise((resolve) => {
setTimeout(resolve, LOCK_HELD_POLL_INTERVAL_MS);
});
}
}

export async function runUpdateDownloadCommand(
version: string,
manual: boolean = false,
): Promise<number> {
if (!detectNativeInstall()) {
process.stderr.write('error: update download is only available in the native build\n');
return 1;
}
const out = process.stdout;
let lock = await tryAcquireUpdateInstallLock({ version });
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
if (lock === null) {
const holderVersion = await readUpdateInstallLockVersion();
if (holderVersion === version) {
// Another worker is already downloading this exact version: wait for it
// and adopt its verified result instead of exiting on a maybe.
out.write(
`A download of Kimi Code ${version} is already in progress; waiting for it to finish…\n`,
);
const wait = await waitForStagedUpdate(version, process.execPath, manual);
if (wait.status === 'staged') {
out.write(`Kimi Code ${version} is downloaded; it applies on the next start.\n`);
return 0;
}
// The holder finished without staging (failed or died): take over. The
// lock may already be held by another winner of the takeover race —
// the null check below reports that as held.
lock = wait.lock;
} else if (holderVersion === undefined) {
// The lock was released between the two reads — retry the acquire once.
lock = await tryAcquireUpdateInstallLock({ version });
}
if (lock === null) {
process.stderr.write(
`error: another update (${holderVersion ?? 'unknown version'}) is already downloading\n`,
);
return 1;
}
}
const label = `Downloading Kimi Code ${version} (${process.platform}-${process.arch})…`;
const onProgress = createDownloadProgress(out, label);
try {
const result = await stageNativeUpdate({
version,
exePath: process.execPath,
onProgress,
manual,
});
if (out.isTTY) out.write('\n');
if (result.status === 'already-staged') {
out.write(`Kimi Code ${version} is already downloaded; it applies on the next start.\n`);
}
return 0;
} catch (error) {
if (out.isTTY) out.write('\n');
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`error: failed to download update ${version}: ${message}\n`);
log.warn('native update download failed', { version, error: message });
return 1;
} finally {
await lock.release().catch(() => {});
}
}

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

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

/**
* Download progress renderer for the (foreground) downloader: a single
* in-place line on a TTY (`\r` + clear-line, throttled to 10 fps, final frame
* always rendered), or one line per 32 MB when piped to a file. The caller
* owns the trailing newline.
*/
export function createDownloadProgress(
out: NodeJS.WriteStream,
label: string,
): (downloadedBytes: number, totalBytes: number | null) => void {
const isTTY = out.isTTY;
let lastFrameAt = 0;
let lastLineAt = 0;
if (!isTTY) out.write(`${label}\n`);
return (downloaded, total) => {
const done = total !== null && downloaded >= total;
if (isTTY) {
const now = Date.now();
if (!done && now - lastFrameAt < PROGRESS_FRAME_INTERVAL_MS) return;
lastFrameAt = now;
out.write(`\r\u001B[K${formatDownloadProgress(label, downloaded, total)}`);
return;
}
if (!done && downloaded - lastLineAt < PROGRESS_LINE_INTERVAL_BYTES) return;
lastLineAt = downloaded;
out.write(`${formatDownloadProgress(label, downloaded, total)}\n`);
};
}
Loading
Loading