Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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 a `limit` and reports `has_more`.
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