Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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/remote-control-asset-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Let browsers cache `kimi web --remote-control` UI assets across page loads and add reconnect jitter. Chunked tunnel responses ship as the `remote_control_chunked_responses` experimental feature (`KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`).
5 changes: 5 additions & 0 deletions .changeset/sessions-list-paginated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

The sessions list API now applies its filters while collecting so paged responses are full and `has_more` is accurate (an unsized request still returns the whole list), and the transcript ops catch-up API accepts an optional `limit` and reports `has_more` (an unsized request still returns every batch).
5 changes: 5 additions & 0 deletions .changeset/web-assets-compression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Serve the `kimi web` UI with Brotli/gzip-compressed assets and browser cache revalidation, and fix the content types of wasm and font files.
5 changes: 5 additions & 0 deletions .changeset/web-stream-batching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Batch streamed transcript text and enable WebSocket compression for the `kimi web` UI. Tune with `KIMI_CODE_TRANSCRIPT_OPS_BATCH_MS` and the `KIMI_CODE_WS_*` environment variables.
10 changes: 10 additions & 0 deletions .github/workflows/_native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ jobs:
# committed (synced from the code-app repo) — just verify it is in place.
run: node apps/kimi-code/scripts/check-web-assets.mjs

- name: Precompress Kimi web assets
# Emit .br/.gz siblings next to the bundle so the embedded server can
# answer Accept-Encoding negotiation without compressing on the fly.
run: node apps/kimi-code/scripts/precompress-web-assets.mjs

- name: Verify precompressed Kimi web assets
# Fail early if an entry bundle is missing its .br sibling instead of
# shipping a binary that falls back to on-the-fly compression.
run: node apps/kimi-code/scripts/precompress-web-assets.mjs --check

- name: Build native executable (release profile, macOS signed)
if: runner.os == 'macOS' && inputs.sign-macos
run: pnpm --filter @moonshot-ai/kimi-code run build:native:release
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
## Project Map

- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on engine packages. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`).
- the browser web UI: **its source no longer lives in this repo.** It is developed in the code-app repo (`apps/web`) and shipped as the committed, prebuilt bundle `apps/kimi-code/dist-web` (gitignored, force-added), synced from code-app with `KIMI_CODE_REPO=<this checkout> pnpm run sync:web` — sync and commit the bundle in the same change whenever the web UI should ship differently. `apps/kimi-code/scripts/check-web-assets.mjs` guards packaging against a missing bundle. To hack on the web UI against this repo's server, run `pnpm dev:server` here and point code-app's `pnpm dev:web` at it via `KIMI_SERVER_URL`.
- the browser web UI: **its source no longer lives in this repo.** It is developed in the code-app repo (`apps/web`) and shipped as the committed, prebuilt bundle `apps/kimi-code/dist-web` (tracked in git), synced from code-app with `KIMI_CODE_REPO=<this checkout> pnpm run sync:web` — sync and commit the bundle in the same change whenever the web UI should ship differently. `apps/kimi-code/scripts/check-web-assets.mjs` guards packaging against a missing bundle; `pnpm build` then writes gitignored `.br`/`.gz` siblings next to compressible files via `scripts/precompress-web-assets.mjs` (never commit them). To hack on the web UI against this repo's server, run `pnpm dev:server` here and point code-app's `pnpm dev:web` at it via `KIMI_SERVER_URL`.
- `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays.
- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session transcript chat, per-scope Service panels, and the DI unit inspection view. See `apps/kimi-inspect/AGENTS.md`.
- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler.
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ src/generated/vis-web-asset.ts

# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs
/native/

# Precompressed siblings generated at build time by scripts/precompress-web-assets.mjs
dist-web/**/*.br
dist-web/**/*.gz
dist-web/**/*.tmp
3 changes: 2 additions & 1 deletion apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@
"provenance": true
},
"scripts": {
"build": "tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/check-web-assets.mjs",
"build": "tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/check-web-assets.mjs && node scripts/precompress-web-assets.mjs",
"prebuild": "node scripts/build-vis-asset.mjs",
"precompress:web": "node scripts/precompress-web-assets.mjs",
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
"smoke": "node scripts/smoke.mjs",
"build:native:js": "node scripts/native/01-bundle.mjs",
Expand Down
214 changes: 214 additions & 0 deletions apps/kimi-code/scripts/precompress-web-assets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/usr/bin/env node
// Emit precompressed `.br` / `.gz` siblings next to the committed web bundle
// (apps/kimi-code/dist-web) so kap-server can answer `Accept-Encoding`
// negotiation by streaming a sibling instead of compressing on the fly.
//
// Siblings are derived artifacts: they are gitignored, regenerated by the
// package build, and pruned here when their base file disappears. Only
// siblings this script could have produced (`<compressible>.br|.gz`) are ever
// pruned; a `foo.tar.gz` or a bare `foo.br` in the bundle is left alone. The
// script is mtime-idempotent for hashed bundles — a sibling at least as new as
// its source is left alone unless `--force` is given. Unhashed files
// (`index.html`, `boot.js`) are tiny and always regenerated: their name does not
// change with their content, so mtimes are not a trustworthy signal for them.
//
// Usage: node scripts/precompress-web-assets.mjs [--check] [--force]

import { readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
import { brotliCompressSync, constants, gzipSync } from 'node:zlib';

const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_DIST_DIR = resolve(appRoot, 'dist-web');

// Text-like bundle outputs that compress well. Everything else (fonts, images,
// icons, rive animations) is either already compressed or binary-opaque.
const COMPRESSIBLE_EXTENSIONS = new Set([
'.html',
'.js',
'.mjs',
'.css',
'.svg',
'.json',
'.map',
'.wasm',
'.txt',
]);
const MIN_SOURCE_BYTES = 1024;
// A sibling only earns its place when it shaves at least this fraction off.
const MIN_SAVINGS_RATIO = 0.1;
const ENTRY_ASSET_PATTERN = /^index-[A-Za-z0-9_-]+\.(?:js|css)$/;
// Vite output whose name carries a content hash (`index-Dy7xs5tu.js`,
// `font.Ab12Cd34.woff2`); only these can trust the mtime up-to-date skip.
const HASHED_FILE_PATTERN = /[-.][A-Za-z0-9_-]{8}\.[^.]+$/;

const FORMATS = {
br: {
extension: '.br',
compress: (source) =>
brotliCompressSync(source, {
params: {
[constants.BROTLI_PARAM_QUALITY]: 11,
[constants.BROTLI_PARAM_SIZE_HINT]: source.length,
},
}),
},
gz: {
extension: '.gz',
compress: (source) => gzipSync(source, { level: 9 }),
},
};
const SIBLING_EXTENSIONS = new Set(Object.values(FORMATS).map((format) => format.extension));

const isMain =
process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
try {
const options = parseCliArgs(process.argv.slice(2));
const summary = await precompressWebAssets({ distDir: DEFAULT_DIST_DIR, ...options });
console.log(
options.check
? `[precompress-web-assets] OK: entry bundles in ${DEFAULT_DIST_DIR} have .br siblings`
: formatSummary(summary),
);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
}

/**
* @param {{ distDir: string, check?: boolean, force?: boolean }} options
* @returns {Promise<{ processed: number, written: number, skipped: number, removed: number, bytesBefore: number, bytesAfter: number }>}
*/
export async function precompressWebAssets({ distDir, check = false, force = false }) {
const files = await listFiles(distDir);
if (check) {
await assertEntryAssetsPrecompressed(distDir, files);
return { processed: 0, written: 0, skipped: 0, removed: 0, bytesBefore: 0, bytesAfter: 0 };
}

const formats = Object.values(FORMATS);
const summary = { processed: 0, written: 0, skipped: 0, removed: 0, bytesBefore: 0, bytesAfter: 0 };
const present = new Set(files);

for (const file of files) {
if (SIBLING_EXTENSIONS.has(extname(file))) {
const base = file.slice(0, -extname(file).length);
if (isCompressible(base) && !present.has(base)) {
await unlink(file);
summary.removed++;
}
continue;
}
if (!isCompressible(file)) {
continue;
}
const sourceStats = await stat(file);
if (sourceStats.size < MIN_SOURCE_BYTES) {
continue;
}
summary.processed++;
summary.bytesBefore += sourceStats.size;
const emitted = await emitSiblings(file, sourceStats, formats, force, summary);
summary.bytesAfter += emitted;
}
return summary;
}

async function emitSiblings(file, sourceStats, formats, force, summary) {
let source;
let smallest = sourceStats.size;
const reuseUpToDate = !force && HASHED_FILE_PATTERN.test(basename(file));
for (const format of formats) {
const siblingPath = `${file}${format.extension}`;
const existing = await stat(siblingPath).catch(() => undefined);
if (reuseUpToDate && existing !== undefined && existing.mtimeMs >= sourceStats.mtimeMs) {
summary.skipped++;
smallest = Math.min(smallest, existing.size);
continue;
}
source ??= await readFile(file);
const compressed = format.compress(source);
if (compressed.length > source.length * (1 - MIN_SAVINGS_RATIO)) {
if (existing !== undefined) {
await unlink(siblingPath);
}
continue;
}
await writeSiblingAtomically(siblingPath, compressed);
summary.written++;
smallest = Math.min(smallest, compressed.length);
}
return smallest;
}

// A sibling is written to a temp name and renamed into place so an interrupted
// build never leaves a truncated `.br`/`.gz` that the server would trust.
async function writeSiblingAtomically(siblingPath, data) {
const tempPath = `${siblingPath}.${process.pid}.tmp`;
await writeFile(tempPath, data);
await rename(tempPath, siblingPath);
}

// Mirrors the runtime rule in kap-server's webAssets route: a sibling only
// counts when it exists and is at least as new as its source. Entry files the
// writer would skip (under MIN_SOURCE_BYTES) are not required to have one.
async function assertEntryAssetsPrecompressed(distDir, files) {
const entryFiles = files
.filter((file) => dirname(file) === join(distDir, 'assets'))
.filter((file) => ENTRY_ASSET_PATTERN.test(relative(join(distDir, 'assets'), file)));
const missing = [];
for (const file of entryFiles) {
const sourceStats = await stat(file);
if (sourceStats.size < MIN_SOURCE_BYTES) continue;
const sibling = await stat(`${file}${FORMATS.br.extension}`).catch(() => undefined);
if (sibling === undefined || sibling.mtimeMs < sourceStats.mtimeMs) {
missing.push(relative(distDir, file));
}
}
if (missing.length > 0) {
throw new Error(
`Precompressed web assets are missing a .br sibling for: ${missing.join(', ')}. ` +
'Run `pnpm --filter @moonshot-ai/kimi-code run precompress:web` and rebuild.',
);
}
}

function isCompressible(file) {
return COMPRESSIBLE_EXTENSIONS.has(extname(file).toLowerCase());
}

async function listFiles(dir) {
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile())
.map((entry) => join(entry.parentPath, entry.name))
.sort();
}

function formatSummary(summary) {
const ratio =
summary.bytesBefore === 0 ? 1 : summary.bytesAfter / summary.bytesBefore;
return (
`[precompress-web-assets] ${summary.processed} files processed, ` +
`${summary.written} siblings written, ${summary.skipped} up to date, ${summary.removed} orphans removed; ` +
`${formatBytes(summary.bytesBefore)} -> ${formatBytes(summary.bytesAfter)} (${(ratio * 100).toFixed(0)}%)`
);
}

function formatBytes(bytes) {
return bytes >= 1024 * 1024
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
: `${(bytes / 1024).toFixed(0)} KB`;
}

function parseCliArgs(args) {
return parseArgs({
args,
options: { check: { type: 'boolean' }, force: { type: 'boolean' } },
strict: true,
}).values;
}
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/sub/web/remote-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
parseRawHttpRequest,
remoteControlLockPath,
RemoteControlAlreadyRunningError,
REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID,
REMOTE_CONTROL_RELAY_ORIGIN,
REMOTE_CONTROL_RELAY_URL_ENV,
resolveRemoteControlRelayOrigin,
Expand Down
20 changes: 16 additions & 4 deletions apps/kimi-code/src/cli/sub/web/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';

import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server';
import {
createServerLogger,
startServer,
type ExperimentalFlags,
type ServerLogger,
} from '@moonshot-ai/kap-server';
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
import chalk from 'chalk';
import { type Command, Option } from 'commander';
Expand Down Expand Up @@ -40,6 +45,7 @@ import { type NetworkAddress } from './networks';
import {
formatRemoteControlOutput,
formatRemoteControlStatus,
REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID,
startRemoteControl,
type RemoteControlHandle,
type RemoteControlOptions,
Expand Down Expand Up @@ -75,9 +81,14 @@ export interface WebCliOptions extends ServerCliOptions {
remoteControl?: boolean;
}

/** What the ready hook may ask of the listening server. */
export interface ForegroundServer {
readonly flags: ExperimentalFlags;
}

export interface StartForegroundHooks {
/** Fires once the server is listening, before the foreground runner blocks. */
onReady?: (origin: string) => void | Promise<void>;
onReady?: (origin: string, server: ForegroundServer) => void | Promise<void>;
onShutdown?: (reason: string) => void | Promise<void>;
}

Expand Down Expand Up @@ -201,7 +212,7 @@ export async function handleWebCommand(
const run = deps.startServerForeground ?? startServerForeground;
let remoteControl: RemoteControlHandle | undefined;
await run(parsed, {
onReady: async (origin) => {
onReady: async (origin, server) => {
// Resolve the persistent token only once the server is up: a fresh
// server writes `server.token` on first boot, so reading it beforehand
// would miss first-time starts and the browser would hit the auth gate.
Expand All @@ -227,6 +238,7 @@ export async function handleWebCommand(
clientVersion: `kimi-code/${getVersion()}`,
stderr: deps.stderr,
onStatus,
chunkedResponses: server.flags.enabled(REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID),
Comment thread
REtoolsx marked this conversation as resolved.
});
const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir);
deps.stdout.write(
Expand Down Expand Up @@ -400,7 +412,7 @@ async function runServerInProcess(
running.logger.info({ address: running.address }, 'server ready');

try {
await hooks.onReady?.(running.address);
await hooks.onReady?.(running.address, { flags: v2.flags });
} catch (error) {
try {
await hooks.onShutdown?.('startup_failed');
Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-code/src/tui/commands/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
formatRemoteControlOutput,
formatRemoteControlStatus,
inspectRemoteControlLock,
REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID,
startRemoteControl,
type RemoteControlStatus,
} from '#/cli/sub/web/remote-control';
Expand Down Expand Up @@ -56,7 +57,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis
let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined;
try {
await startServerForeground(options, {
onReady: async (origin) => {
onReady: async (origin, server) => {
const dataDir = getDataDir();
const token = tryResolveServerToken(dataDir);
if (token === undefined) throw new Error('Unable to read the local server token.');
Expand All @@ -73,6 +74,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis
localServerToken: token,
clientVersion: `kimi-code/${getVersion()}`,
onStatus,
chunkedResponses: server.flags.enabled(REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID),
});
const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id);
const qrCode = await generateRemoteControlQr(url, dataDir);
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/test/cli/web/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function makeRunner(origin = 'http://127.0.0.1:58627'): {
const calls: { options: ParsedServerOptions | undefined } = { options: undefined };
const runner: ForegroundRunner = async (options, hooks) => {
calls.options = options;
await hooks?.onReady?.(origin);
await hooks?.onReady?.(origin, { flags: { enabled: () => false } });
return undefined as never;
};
return { runner, calls };
Expand Down
Loading