feat(kimi-code): add remote control web tunnel (#3034)

* feat(kimi-code): add remote control web tunnel

Add CLI and TUI entry points for exposing the local web UI remotely.
Bridge HTTP and WebSocket traffic with local authentication and reconnect handling.

* fix(kimi-code): prevent remote control websocket crash

* fix(kimi-code): align websocket dependency versions

* fix(kimi-code): harden remote control connection setup

Reconnect when management closes during the HTTP tunnel handshake.
Reject non-loopback Remote Control binds whose CSP blocks path bootstrap.

* fix(kimi-code): fix remote control rewriting, caching, and WS frame loss

* feat(kimi-code): add remote control QR output

* build: update pnpm dependencies hash

* refactor(kimi-code): remove the --allow-remote-terminals flag

* feat(kimi-code): add remote control lock, rc command, and QR fixes

* fix(kap-server): broadcast user prompts to all session clients on submit

- agent-core-v2: emit prompt.submitted (status running|queued) at enqueue and prompt.started when the turn launches
- kap-server: project prompt.submitted/prompt.started into transcript prompt entities and the live transcript REST response
- update flake.nix pnpmDeps hash for the PR lockfile

* fix(node-sdk): drop v2-only prompt.started from SDK event stream

- event-mapper: add prompt.started to the dropped v2-only prompt lifecycle types (parity with submitted/completed/aborted/steered)
- cli test: assert only visible sub-commands and stub the experimental flag env for determinism

* ci(pkg-pr-new): post custom install comment for npm 12 compatibility

* feat(kimi-code): render remote control QR as inline image on capable terminals

* feat(kimi-code): improve remote control terminal output

- add onboarding, security, device management, and help guidance
- show compact clickable links and QR image fallback details
- report relay and remote device connection lifecycle

* test(agent-core-v2): update tool event snapshot

* revert(ci): keep preview workflow unchanged in rc pr

---------

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
Haozhe 2026-08-25 20:22:06 +08:00 committed by GitHub
parent e6a302b310
commit f0a609487f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3001 additions and 92 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Add Remote Control as an experimental feature for accessing a local web session remotely. Enable it with `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL=1`, then run `kimi rc`, `kimi web --remote-control`, or `/remote-control` to start it.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix messages sent from one web client not appearing on other clients connected to the same session.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Remove the `--allow-remote-terminals` flag from `kimi web`; PTY terminal routes now stay available on loopback binds only.

View file

@ -94,7 +94,9 @@
"@moonshot-ai/pi-tui": "workspace:^",
"@moonshot-ai/vis-server": "workspace:^",
"@moonshot-ai/vis-web": "workspace:*",
"@types/qrcode": "^1.5.6",
"@types/semver": "^7.7.0",
"@types/ws": "^8.18.0",
"@types/yazl": "^2.4.6",
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
@ -110,5 +112,9 @@
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"qrcode": "^1.5.4",
"ws": "^8.18.0"
}
}

View file

@ -13,6 +13,7 @@
import type { Command } from 'commander';
import { registerDeprecatedServerCommand } from './deprecated-server';
import { isRemoteControlEnabled } from './remote-control';
import { registerRotateTokenCommand } from './rotate-token';
import { buildWebCommand } from './run';
@ -24,4 +25,11 @@ export function registerWebCommand(program: Command): void {
);
registerRotateTokenCommand(web);
registerDeprecatedServerCommand(program);
buildWebCommand(
program
.command('rc', { hidden: !isRemoteControlEnabled() })
.alias('remote')
.description('Run the local Kimi server and open the web UI through Remote Control (experimental).'),
{ forceRemoteControl: true },
);
}

View file

@ -0,0 +1,170 @@
import { randomBytes } from 'node:crypto';
import { mkdir, open, readFile, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export interface RemoteControlLockInfo {
readonly pid: number;
readonly nonce: string;
readonly localOrigin: string;
readonly deviceId: string;
readonly url: string;
readonly startedAt: number;
}
interface RemoteControlLockDisk {
readonly pid: number;
readonly nonce: string;
readonly local_origin: string;
readonly device_id: string;
readonly url: string;
readonly started_at: number;
}
export class RemoteControlAlreadyRunningError extends Error {
readonly holder: RemoteControlLockInfo;
constructor(holder: RemoteControlLockInfo) {
super(formatRemoteControlAlreadyRunning(holder));
this.name = 'RemoteControlAlreadyRunningError';
this.holder = holder;
}
}
export function formatRemoteControlAlreadyRunning(holder: RemoteControlLockInfo): string {
return [
`Remote Control is already running on this machine (pid ${holder.pid}, ${holder.localOrigin}, since ${new Date(holder.startedAt).toLocaleString()}).`,
`Use the existing link: ${holder.url}`,
'To start a new one here, stop the other `kimi web --remote-control` process first.',
].join('\n');
}
export function remoteControlLockPath(homeDir: string): string {
return join(homeDir, 'server', 'rc.json');
}
export interface RemoteControlLock {
release(): Promise<void>;
}
const MAX_ACQUIRE_ATTEMPTS = 3;
export async function acquireRemoteControlLock(
homeDir: string,
details: { localOrigin: string; deviceId: string; url: string },
): Promise<RemoteControlLock> {
const lockPath = remoteControlLockPath(homeDir);
await mkdir(dirname(lockPath), { recursive: true });
const info: RemoteControlLockInfo = {
pid: process.pid,
nonce: randomBytes(8).toString('hex'),
localOrigin: details.localOrigin,
deviceId: details.deviceId,
url: details.url,
startedAt: Date.now(),
};
for (let attempt = 0; ; attempt += 1) {
try {
const handle = await open(lockPath, 'wx');
try {
await handle.writeFile(encodeLock(info));
} finally {
await handle.close();
}
return { release: () => releaseRemoteControlLock(lockPath, info.nonce) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || attempt >= MAX_ACQUIRE_ATTEMPTS) {
throw error;
}
const holder = await readRemoteControlLock(lockPath);
if (holder !== undefined && pidAlive(holder.pid)) {
throw new RemoteControlAlreadyRunningError(holder);
}
await removeFile(lockPath);
}
}
}
export async function inspectRemoteControlLock(
homeDir: string,
): Promise<RemoteControlLockInfo | undefined> {
const lockPath = remoteControlLockPath(homeDir);
const info = await readRemoteControlLock(lockPath);
if (info === undefined) return undefined;
if (!pidAlive(info.pid)) {
await removeFile(lockPath);
return undefined;
}
return info;
}
async function releaseRemoteControlLock(lockPath: string, nonce: string): Promise<void> {
const info = await readRemoteControlLock(lockPath);
if (info === undefined || info.nonce !== nonce) return;
await removeFile(lockPath);
}
async function readRemoteControlLock(lockPath: string): Promise<RemoteControlLockInfo | undefined> {
let raw: string;
try {
raw = await readFile(lockPath, 'utf8');
} catch {
return undefined;
}
return decodeLock(raw);
}
function encodeLock(info: RemoteControlLockInfo): string {
const disk: RemoteControlLockDisk = {
pid: info.pid,
nonce: info.nonce,
local_origin: info.localOrigin,
device_id: info.deviceId,
url: info.url,
started_at: info.startedAt,
};
return JSON.stringify(disk);
}
function decodeLock(raw: string): RemoteControlLockInfo | undefined {
try {
const parsed = JSON.parse(raw) as Partial<RemoteControlLockDisk>;
if (
typeof parsed.pid === 'number' &&
typeof parsed.nonce === 'string' &&
typeof parsed.local_origin === 'string' &&
typeof parsed.device_id === 'string' &&
typeof parsed.url === 'string' &&
typeof parsed.started_at === 'number'
) {
return {
pid: parsed.pid,
nonce: parsed.nonce,
localOrigin: parsed.local_origin,
deviceId: parsed.device_id,
url: parsed.url,
startedAt: parsed.started_at,
};
}
return undefined;
} catch {
return undefined;
}
}
async function removeFile(lockPath: string): Promise<void> {
try {
await unlink(lockPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false;
return true;
}
}

File diff suppressed because it is too large Load diff

View file

@ -14,13 +14,14 @@ import { join } from 'node:path';
import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server';
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
import chalk from 'chalk';
import { type Command } from 'commander';
import { type Command, Option } from 'commander';
import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app';
import { getNativeWebAssetsDir } from '#/native/web-assets';
import { darkColors } from '#/tui/theme/colors';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import { getDataDir } from '#/utils/paths';
import { generateRemoteControlQr } from '#/utils/remote-control-qr';
import { initializeServerTelemetry } from '../../telemetry';
import {
@ -35,6 +36,16 @@ import {
splitTokenFragment,
} from './access-urls';
import { type NetworkAddress } from './networks';
import {
formatRemoteControlOutput,
formatRemoteControlStatus,
isRemoteControlEnabled,
REMOTE_CONTROL_FLAG_ENV,
startRemoteControl,
type RemoteControlHandle,
type RemoteControlOptions,
type RemoteControlStatus,
} from './remote-control';
import {
DEFAULT_FOREGROUND_LOG_LEVEL,
DEFAULT_LAN_HOST,
@ -62,11 +73,13 @@ interface RoutedServer {
export interface WebCliOptions extends ServerCliOptions {
open?: boolean;
remoteControl?: boolean;
}
export interface StartForegroundHooks {
/** Fires once the server is listening, before the foreground runner blocks. */
onReady?: (origin: string) => void;
onReady?: (origin: string) => void | Promise<void>;
onShutdown?: (reason: string) => void | Promise<void>;
}
export interface WebCommandDeps {
@ -75,6 +88,7 @@ export interface WebCommandDeps {
options: ParsedServerOptions,
hooks?: StartForegroundHooks,
) => Promise<never>;
startRemoteControl?: (options: RemoteControlOptions) => Promise<RemoteControlHandle>;
openUrl(url: string): void;
/**
* Best-effort read of the server's persistent bearer token. When it returns
@ -105,8 +119,12 @@ export function buildWebUrl(origin: string, token: string): string {
}
/** Build the `web` command, mounting the runner action on `cmd` itself. */
export function buildWebCommand(cmd: Command): Command {
return cmd
export function buildWebCommand(
cmd: Command,
opts: { forceRemoteControl?: boolean } = {},
): Command {
const forceRemoteControl = opts.forceRemoteControl === true;
const withServerOptions = cmd
.option(
'--port <port>',
`Bind port (default ${DEFAULT_SERVER_PORT})`,
@ -130,11 +148,6 @@ export function buildWebCommand(cmd: Command): Command {
'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).',
false,
)
.option(
'--allow-remote-terminals',
'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.',
false,
)
.option(
'--dangerous-bypass-auth',
'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.',
@ -152,11 +165,24 @@ export function buildWebCommand(cmd: Command): Command {
.option(
'--web-title <title>',
'Set a custom browser tab title for this web UI instance (default: "<workspace dir> | Kimi Code").',
)
);
if (!forceRemoteControl) {
withServerOptions.addOption(
new Option(
'--rc, --remote-control',
'Expose the web UI through Kimi Remote Control (experimental).',
)
.default(false)
.hideHelp(!isRemoteControlEnabled()),
);
}
return withServerOptions
.option('--no-open', 'Do not open the web UI in the default browser.', true)
.action(async (opts: WebCliOptions) => {
try {
await handleWebCommand(opts);
await handleWebCommand(
forceRemoteControl ? { ...opts, remoteControl: true } : opts,
);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
@ -169,9 +195,21 @@ export async function handleWebCommand(
deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS,
): Promise<void> {
const parsed = parseServerOptions(opts);
if (opts.remoteControl === true && !isRemoteControlEnabled()) {
throw new Error(
`--remote-control is experimental: set ${REMOTE_CONTROL_FLAG_ENV}=1 (or KIMI_CODE_EXPERIMENTAL_FLAG=1) to enable it.`,
);
}
if (opts.remoteControl === true && parsed.dangerousBypassAuth) {
throw new Error('--remote-control cannot be combined with --dangerous-bypass-auth.');
}
if (opts.remoteControl === true && !isLoopbackHost(parsed.host)) {
throw new Error('--remote-control requires a loopback host.');
}
const run = deps.startServerForeground ?? startServerForeground;
let remoteControl: RemoteControlHandle | undefined;
await run(parsed, {
onReady: (origin) => {
onReady: async (origin) => {
// 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.
@ -180,6 +218,38 @@ export async function handleWebCommand(
// token line when unavailable. When auth is bypassed, the token is
// meaningless and is intentionally NOT shown or carried in the URL.
const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.();
if (opts.remoteControl === true) {
if (token === undefined) throw new Error('Unable to read the local server token.');
const dataDir = getDataDir();
let outputReady = false;
const pendingStatuses: string[] = [];
const onStatus = (status: RemoteControlStatus): void => {
const line = formatRemoteControlStatus(status);
if (outputReady) deps.stdout.write(line);
else pendingStatuses.push(line);
};
remoteControl = await (deps.startRemoteControl ?? startRemoteControl)({
homeDir: dataDir,
localOrigin: origin,
localServerToken: token,
stderr: deps.stderr,
onStatus,
});
const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir);
deps.stdout.write(
formatRemoteControlOutput({
url: remoteControl.url,
localOrigin: origin,
deviceName: remoteControl.deviceName,
qrCode: qrCode.terminal,
pngPath: qrCode.pngPath,
}),
);
outputReady = true;
for (const line of pendingStatuses) deps.stdout.write(line);
if (opts.open === true) deps.openUrl(remoteControl.url);
return;
}
deps.stdout.write(
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
? formatReadyBanner(origin, parsed.host, {
@ -193,6 +263,9 @@ export async function handleWebCommand(
deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin);
}
},
onShutdown: async () => {
await remoteControl?.close();
},
});
}
@ -230,7 +303,7 @@ export async function startServerForeground(
options: ParsedServerOptions,
hooks: StartForegroundHooks = {},
): Promise<never> {
return runServerInProcess(options, hooks.onReady);
return runServerInProcess(options, hooks);
}
/**
@ -239,7 +312,7 @@ export async function startServerForeground(
*/
async function runServerInProcess(
options: ParsedServerOptions,
onReady?: (origin: string) => void,
hooks: StartForegroundHooks,
): Promise<never> {
const version = getVersion();
// Registers the telemetry provider for `track` / `shutdownTelemetry`; the
@ -253,6 +326,14 @@ async function runServerInProcess(
if (stopping) return;
stopping = true;
running?.logger.info({ reason }, 'server shutting down');
try {
await hooks.onShutdown?.(reason);
} catch (error) {
running?.logger.error(
{ err: error instanceof Error ? error : new Error(String(error)) },
'foreground shutdown hook error',
);
}
try {
await running?.close();
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
@ -296,7 +377,6 @@ async function runServerInProcess(
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
allowedHosts: options.allowedHosts,
disableAuth: options.dangerousBypassAuth,
webTitle: options.webTitle,
@ -324,7 +404,17 @@ async function runServerInProcess(
running.logger.info({ address: running.address }, 'server ready');
onReady?.(running.address);
try {
await hooks.onReady?.(running.address);
} catch (error) {
try {
await hooks.onShutdown?.('startup_failed');
} finally {
await running.close();
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
}
throw error;
}
return new Promise<never>(() => {
// Keeps the event loop alive; the process ends via shutdown()/process.exit.

View file

@ -40,8 +40,6 @@ export interface ParsedServerOptions {
insecureNoTls: boolean;
/** Allow `POST /api/v1/shutdown` on a non-loopback bind. */
allowRemoteShutdown: boolean;
/** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */
allowRemoteTerminals: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth: boolean;
/** Extra `Host` header values to allow through the DNS-rebinding check. */
@ -59,8 +57,6 @@ export interface ServerCliOptions {
insecureNoTls?: boolean;
/** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */
allowRemoteShutdown?: boolean;
/** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */
allowRemoteTerminals?: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth?: boolean;
/** Extra `Host` header values to allow (`--allowed-host`). */
@ -77,7 +73,6 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions
debugEndpoints: opts.debugEndpoints === true,
insecureNoTls: opts.insecureNoTls !== false,
allowRemoteShutdown: opts.allowRemoteShutdown === true,
allowRemoteTerminals: opts.allowRemoteTerminals === true,
dangerousBypassAuth: opts.dangerousBypassAuth === true,
allowedHosts: parseAllowedHostArgs(opts.allowedHost),
webTitle: opts.webTitle,

View file

@ -71,7 +71,7 @@ import {
import { handleSwarmCommand } from './swarm';
import { handleTowerCommand } from './tower';
import { handleUndoCommand } from './undo';
import { handleWebCommand } from './web';
import { handleRemoteControlCommand, handleWebCommand } from './web';
// ---------------------------------------------------------------------------
// Re-exports — keep existing consumers working
@ -110,7 +110,7 @@ export {
handleTitleCommand,
} from './session';
export { handleUndoCommand } from './undo';
export { handleWebCommand } from './web';
export { handleRemoteControlCommand, handleWebCommand } from './web';
// ---------------------------------------------------------------------------
// Host interface
@ -613,6 +613,9 @@ async function handleBuiltInSlashCommand(
case 'web':
await handleWebCommand(host);
return;
case 'remote-control':
await handleRemoteControlCommand(host);
return;
default:
host.showError(`Unknown slash command: /${String(name)}`);
return;

View file

@ -31,7 +31,7 @@ export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from
export { goalArgumentCompletions } from './registry';
export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session';
export { handleUndoCommand } from './undo';
export { handleWebCommand } from './web';
export { handleRemoteControlCommand, handleWebCommand } from './web';
export {
promptApiKey,
promptCatalogProviderSelection,

View file

@ -431,6 +431,14 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 40,
availability: 'always',
},
{
name: 'remote-control',
aliases: ['rc'],
description: 'Open the current session through Kimi Remote Control (experimental)',
priority: 40,
availability: 'always',
experimentalFlag: 'remote-control',
},
{
name: 'exit',
aliases: ['quit', 'q'],

View file

@ -1,10 +1,22 @@
import chalk from 'chalk';
import { splitTokenFragment } from '#/cli/sub/web/access-urls';
import {
buildRemoteControlUrl,
formatRemoteControlOutput,
formatRemoteControlStatus,
startRemoteControl,
type RemoteControlStatus,
} from '#/cli/sub/web/remote-control';
import {
formatRemoteControlAlreadyRunning,
inspectRemoteControlLock,
} from '#/cli/sub/web/remote-control-lock';
import { formatReadyBanner, startServerForeground } from '#/cli/sub/web/run';
import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/web/shared';
import { openUrl } from '#/utils/open-url';
import { getDataDir } from '#/utils/paths';
import { generateRemoteControlQr } from '#/utils/remote-control-qr';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { darkColors } from '../theme/colors';
@ -30,6 +42,65 @@ export async function handleWebCommand(host: SlashCommandHost): Promise<void> {
await host.stop();
}
export async function handleRemoteControlCommand(host: SlashCommandHost): Promise<void> {
await host.waitForLazyCreation();
const session = host.session;
const holder = await inspectRemoteControlLock(getDataDir());
if (holder !== undefined) {
host.showError(formatRemoteControlAlreadyRunning(holder));
return;
}
host.setExitForegroundTask(async () => {
const options = parseServerOptions({});
let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined;
try {
await startServerForeground(options, {
onReady: async (origin) => {
const dataDir = getDataDir();
const token = tryResolveServerToken(dataDir);
if (token === undefined) throw new Error('Unable to read the local server token.');
let outputReady = false;
const pendingStatuses: string[] = [];
const onStatus = (status: RemoteControlStatus): void => {
const line = formatRemoteControlStatus(status);
if (outputReady) process.stdout.write(line);
else pendingStatuses.push(line);
};
remoteControl = await startRemoteControl({
homeDir: dataDir,
localOrigin: origin,
localServerToken: token,
onStatus,
});
const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id);
const qrCode = await generateRemoteControlQr(url, dataDir);
process.stdout.write(
formatRemoteControlOutput({
url,
localOrigin: origin,
deviceName: remoteControl.deviceName,
qrCode: qrCode.terminal,
pngPath: qrCode.pngPath,
}),
);
outputReady = true;
for (const line of pendingStatuses) process.stdout.write(line);
openUrl(url);
},
onShutdown: async () => {
await remoteControl?.close();
},
});
} catch (error) {
process.stderr.write(`Failed to start Remote Control: ${formatErrorMessage(error)}\n`);
process.exit(1);
}
});
await host.stop();
}
/**
* Register the exit takeover that turns this process into the new server once
* the TUI has shut down (where `process.exit` would normally happen): the
@ -63,12 +134,12 @@ function startNewServerAfterExit(host: SlashCommandHost, sessionId: string): voi
/** Styled `Session:` line for the foreground handoff; the token fragment is
* dimmed like in the ready banner so the host/path stands out. */
function sessionLine(url: string): string {
function sessionLine(url: string, labelText = 'Session: '): string {
const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
const accent = (text: string): string => chalk.hex(darkColors.accent)(text);
const dim = (text: string): string => chalk.hex(darkColors.textDim)(text);
const [base, frag] = splitTokenFragment(url);
return `${label('Session: ')}${accent(base)}${frag === '' ? '' : dim(frag)}`;
return `${label(labelText)}${accent(base)}${frag === '' ? '' : dim(frag)}`;
}
/**

View file

@ -0,0 +1,62 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
getCapabilities,
getCellDimensions,
getPngDimensions,
renderImage,
} from '@moonshot-ai/pi-tui';
import * as QRCode from 'qrcode';
const TERMINAL_QR_MARGIN = 2;
const TERMINAL_QR_DARK = '0;0;0';
const TERMINAL_QR_LIGHT = '255;255;255';
const ANSI_RESET = '\u001B[0m';
const QR_PNG_MARGIN = 4;
const QR_IMAGE_MIN_PX_PER_MODULE = 4;
export async function generateRemoteControlQr(
url: string,
dataDir: string,
): Promise<{ terminal: string; pngPath: string }> {
await mkdir(dataDir, { recursive: true });
const pngPath = resolve(dataDir, 'rc-qrcode.png');
const png = await QRCode.toBuffer(url, { type: 'png', margin: QR_PNG_MARGIN });
await writeFile(pngPath, png);
const terminal = renderInlineImageQr(url, png) ?? renderTerminalQr(url);
return { terminal, pngPath };
}
function renderInlineImageQr(url: string, png: Buffer): string | null {
if (getCapabilities().images === null) return null;
const base64 = png.toString('base64');
const dimensions = getPngDimensions(base64);
if (dimensions === null) return null;
const moduleCount =
QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size + QR_PNG_MARGIN * 2;
const maxWidthCells = Math.ceil(
(moduleCount * QR_IMAGE_MIN_PX_PER_MODULE) / getCellDimensions().widthPx,
);
const rendered = renderImage(base64, dimensions, { maxWidthCells });
return rendered === null ? null : `${rendered.sequence}\n`;
}
export function renderTerminalQr(url: string): string {
const qr = QRCode.create(url, { errorCorrectionLevel: 'M' });
const size: number = qr.modules.size;
const data: Uint8Array = qr.modules.data;
const isDark = (x: number, y: number): boolean =>
x >= 0 && y >= 0 && x < size && y < size && data[y * size + x] === 1;
let output = '';
for (let y = -TERMINAL_QR_MARGIN; y < size + TERMINAL_QR_MARGIN; y += 2) {
for (let x = -TERMINAL_QR_MARGIN; x < size + TERMINAL_QR_MARGIN; x++) {
const top = isDark(x, y) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT;
const bottom = isDark(x, y + 1) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT;
output += `\u001B[38;2;${top}m\u001B[48;2;${bottom}m▀`;
}
output += `${ANSI_RESET}\n`;
}
return output + ANSI_RESET;
}

View file

@ -5,7 +5,7 @@
* Run: pnpm -C apps/kimi-code exec vitest run test/cli/options.test.ts
*/
import { describe, expect, it } from 'vitest';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { createProgram } from '#/cli/commands';
import type { CLIOptions } from '#/cli/options';
@ -572,13 +572,16 @@ describe('CLI options parsing', () => {
});
it('registers the visible sub-commands', () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0');
onTestFinished(() => { vi.unstubAllEnvs(); });
const program = createProgram(
'0.0.0',
() => {},
() => {},
);
const commandNames: string[] = program.commands
.filter((command) => !command.name().startsWith('__'))
.filter((command) => !command.name().startsWith('__') && !(command as unknown as { _hidden?: boolean })._hidden)
.map((command) => command.name());
expect(commandNames).toEqual([
'export',

View file

@ -0,0 +1,705 @@
import { createServer, type IncomingMessage } from 'node:http';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
FileTokenStorage,
KIMI_CODE_PROVIDER_NAME,
resolveKimiTokenStorageName,
type TokenInfo,
} from '@moonshot-ai/kimi-code-oauth';
import { afterEach, describe, expect, it } from 'vitest';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
import {
buildRemoteControlUrl,
filterForwardRequestHeaders,
formatRemoteControlOutput,
formatRemoteControlStatus,
isRemoteControlEnabled,
parseRawHttpRequest,
rewriteRemoteControlResponse,
startRemoteControl,
type RemoteControlHandle,
} from '#/cli/sub/web/remote-control';
import { remoteControlLockPath } from '#/cli/sub/web/remote-control-lock';
const TOKEN: TokenInfo = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 0,
scope: '',
tokenType: 'Bearer',
expiresIn: 0,
};
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!();
});
describe('Remote Control experimental flag', () => {
it('is off unless the per-feature env or the master switch is truthy', () => {
expect(isRemoteControlEnabled({})).toBe(false);
expect(isRemoteControlEnabled({ KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL: '0' })).toBe(false);
expect(isRemoteControlEnabled({ KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL: '1' })).toBe(true);
expect(isRemoteControlEnabled({ KIMI_CODE_EXPERIMENTAL_FLAG: 'true' })).toBe(true);
expect(
isRemoteControlEnabled({
KIMI_CODE_EXPERIMENTAL_FLAG: '0',
KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL: 'yes',
}),
).toBe(true);
});
});
describe('Remote Control URLs', () => {
it('builds the public device entry without a local token', () => {
const url = buildRemoteControlUrl('device/one');
expect(url).toBe(
'https://code-rc.kimi.com/devices/device%2Fone/?rc=1&from=kimi_code_cli',
);
expect(url).not.toContain('token');
});
it('builds an encoded session deep link before the query', () => {
expect(buildRemoteControlUrl('device-1', 'session/a b')).toBe(
'https://code-rc.kimi.com/devices/device-1/sessions/session%2Fa%20b?rc=1&from=kimi_code_cli',
);
});
});
describe('Remote Control output', () => {
it('keeps the full URL clickable while showing a short link and the setup contract', () => {
const url = 'https://example.test/devices/example-device/?rc=1&from=kimi_code_cli';
const output = formatRemoteControlOutput({
url,
localOrigin: 'http://127.0.0.1:1234',
deviceName: 'example-device',
qrCode: 'QR\n',
pngPath: '/tmp/example-qr.png',
});
expect(output).toContain('Use Kimi Code on this machine');
expect(output).toContain('1.');
expect(output).toContain('2.');
expect(output).toContain('3.');
expect(output).toContain('example.test/devices/exampl…vice/');
expect(output).toContain(`\u001B]8;;${url}`);
expect(output).toContain('Connected to example.test');
expect(output).toContain('This device:');
expect(output).toContain('max 5');
expect(output).toContain('PNG:');
expect(output).toContain('grants control of this machine');
expect(output).toContain('docs');
expect(output).toContain('feedback');
expect(output).toContain('Logs: off');
expect(output).not.toContain('stream-1');
});
it('formats relay and device lifecycle states', () => {
expect(formatRemoteControlStatus('relay_connected').toLowerCase()).toContain('connected');
expect(formatRemoteControlStatus('relay_disconnected')).toContain('disconnected');
expect(formatRemoteControlStatus('device_connected').toLowerCase()).toContain('connected');
expect(formatRemoteControlStatus('device_disconnected')).toContain('disconnected');
});
});
describe('Remote Control HTTP forwarding', () => {
it('parses raw requests and replaces relay credentials with local bearer auth', () => {
const parsed = parseRawHttpRequest(
Buffer.from(
'POST /api/v1/messages?q=1 HTTP/1.1\r\nHost: relay.example\r\nAuthorization: Bearer relay\r\nCookie: sid=1\r\nOrigin: https://relay.example\r\nConnection: keep-alive, X-Hop\r\nX-Hop: remove\r\nX-Keep: yes\r\nContent-Length: 4\r\n\r\ndata',
),
);
expect(parsed).toMatchObject({ method: 'POST', path: '/api/v1/messages?q=1' });
expect(parsed.body.toString()).toBe('data');
expect(filterForwardRequestHeaders(parsed.headers, 'local-token')).toEqual([
'X-Keep',
'yes',
'Content-Length',
'4',
'Authorization',
'Bearer local-token',
]);
});
it('rejects absolute-form and malformed request targets', () => {
expect(() =>
parseRawHttpRequest(Buffer.from('GET https://example.test/ HTTP/1.1\r\n\r\n')),
).toThrow(/request line/);
expect(() => parseRawHttpRequest(Buffer.from('GET //example.test/ HTTP/1.1\r\n\r\n'))).toThrow(
/request line/,
);
});
it('rewrites HTML, JavaScript, and CSS under the device prefix', () => {
const prefix = '/coding-relay/devices/device-1';
const html = rewriteRemoteControlResponse(
'text/html; charset=utf-8',
Buffer.from('<html><head></head><body><script src="/boot.js"></script><a href="/x">x</a></body></html>'),
prefix,
).toString();
expect(html).toContain(`src="${prefix}/boot.js"`);
expect(html).toContain(`href="${prefix}/x"`);
expect(html).toContain("sessionStorage.setItem('kimi-desktop-server-origin',location.origin+p)");
expect(html).toContain('history.pushState=w(history.pushState)');
const js = rewriteRemoteControlResponse(
'text/javascript',
Buffer.from(
'const a="/assets/a.js";const s="/sessions/";const p=function(e){return"/"+e};',
),
prefix,
).toString();
expect(js).toBe(
`const a="${prefix}/assets/a.js";const s="${prefix}/sessions/";const p=function(e){return"${prefix}/"+e};`,
);
const css = rewriteRemoteControlResponse(
'text/css',
Buffer.from('.x{background:url(/assets/x.png)}'),
prefix,
).toString();
expect(css).toBe(`.x{background:url(${prefix}/assets/x.png)}`);
});
});
describe('Remote Control tunnel', () => {
it('surfaces register_nak details', async () => {
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-nak-'));
cleanups.push(() => rmSync(homeDir, { recursive: true, force: true }));
await new FileTokenStorage(join(homeDir, 'credentials')).save(
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
TOKEN,
);
const managementServer = new WebSocketServer({ noServer: true });
const relayServer = createServer();
managementServer.on('connection', (ws) => {
ws.once('message', () => {
ws.send(
JSON.stringify({
type: 'register_nak',
payload: {
error_code: 'DEVICE_LIMIT_EXCEEDED',
error_message: 'membership allows 3 devices',
},
}),
);
});
});
relayServer.on('upgrade', (request, socket, head) => {
managementServer.handleUpgrade(request, socket, head, (ws) =>
managementServer.emit('connection', ws, request),
);
});
const relayPort = await listen(relayServer);
cleanups.push(() => closeServer(relayServer));
await expect(
startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:1',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`,
stderr: { write: () => true },
}),
).rejects.toThrow(/DEVICE_LIMIT_EXCEEDED.*membership allows 3 devices/);
});
it('uses only Authorization when the refresh token is not a valid subprotocol token', async () => {
const homeDir = await createRemoteControlHome('invalid/token=');
const relay = await startAuthRelay();
let handle: RemoteControlHandle | undefined;
cleanups.push(async () => handle?.close());
handle = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:1',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`,
stderr: { write: () => true },
});
expect(relay.requests).toHaveLength(2);
expect(relay.requests.every((request) => request.protocol === undefined)).toBe(true);
expect(relay.requests.every((request) => request.authorization === 'Bearer invalid/token=')).toBe(
true,
);
});
it('retries with only Authorization when the server does not echo the subprotocol', async () => {
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
const relay = await startAuthRelay({ echoProtocol: false });
let handle: RemoteControlHandle | undefined;
cleanups.push(async () => handle?.close());
handle = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:1',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`,
stderr: { write: () => true },
});
expect(relay.requests.some((request) => request.protocol?.startsWith('kimi-code.bearer.'))).toBe(
true,
);
expect(
relay.requests.some(
(request) =>
request.protocol === undefined &&
request.authorization === `Bearer ${TOKEN.refreshToken}`,
),
).toBe(true);
});
it('keeps the initial start pending through transient failures and recovers', async () => {
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
const relay = await startAuthRelay({ rejectUpgrades: 2 });
let handle: RemoteControlHandle | undefined;
cleanups.push(async () => handle?.close());
handle = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:1',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`,
stderr: { write: () => true },
});
expect(relay.requests.length).toBeGreaterThanOrEqual(4);
expect(handle.url).toContain('?rc=1&from=kimi_code_cli');
}, 6000);
it('reconnects when management closes during the HTTP tunnel handshake', async () => {
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
const relay = await startAuthRelay({ closeManagementDuringFirstHttpHandshake: true });
let handle: RemoteControlHandle | undefined;
cleanups.push(async () => handle?.close());
handle = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:1',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`,
stderr: { write: () => true },
});
expect(relay.requests.length).toBeGreaterThanOrEqual(4);
}, 6000);
it('registers, forwards HTTP and WS with local auth, then reconnects the pair', async () => {
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-'));
cleanups.push(() => rmSync(homeDir, { recursive: true, force: true }));
await new FileTokenStorage(join(homeDir, 'credentials')).save(
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
TOKEN,
);
let localHttpRequest: IncomingMessage | undefined;
let localWsRequest: IncomingMessage | undefined;
const localWsServer = new WebSocketServer({ noServer: true });
const localServer = createServer((request, response) => {
localHttpRequest = request;
response.writeHead(200, {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=31536000, immutable',
Connection: 'X-Remove',
'X-Remove': 'gone',
});
response.end('<html><head></head><script src="/boot.js"></script></html>');
});
localServer.on('upgrade', (request, socket, head) => {
localWsRequest = request;
localWsServer.handleUpgrade(request, socket, head, (ws) => localWsServer.emit('connection', ws, request));
});
const localPort = await listen(localServer);
cleanups.push(() => closeServer(localServer));
const managementServer = new WebSocketServer({ noServer: true });
const httpTunnelServer = new WebSocketServer({ noServer: true });
const streamServer = new WebSocketServer({ noServer: true });
const relayServer = createServer();
const managementConnections: WebSocket[] = [];
const httpConnections: WebSocket[] = [];
const streamConnections: WebSocket[] = [];
const registrations: unknown[] = [];
const managementMessages: unknown[] = [];
const streamMessages: string[] = [];
let localWs: WebSocket | undefined;
managementServer.on('connection', (ws) => {
managementConnections.push(ws);
ws.on('message', (data) => {
const message = JSON.parse(rawDataText(data)) as { type: string };
managementMessages.push(message);
if (message.type === 'register') {
registrations.push(message);
ws.send(JSON.stringify({ type: 'register_ack', payload: { success: true } }));
}
});
});
httpTunnelServer.on('connection', (ws) => httpConnections.push(ws));
streamServer.on('connection', (ws) => {
streamConnections.push(ws);
ws.on('message', (data) => streamMessages.push(rawDataText(data)));
});
localWsServer.on('connection', (ws) => {
localWs = ws;
ws.send('server-hello-frame');
});
relayServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url!, 'http://relay.test').pathname;
const target = pathname.endsWith('/v1/remote/create')
? managementServer
: pathname.endsWith('/v1/remote/http')
? httpTunnelServer
: streamServer;
target.handleUpgrade(request, socket, head, (ws) => target.emit('connection', ws, request));
});
const relayPort = await listen(relayServer);
cleanups.push(() => closeServer(relayServer));
let handle: RemoteControlHandle | undefined;
cleanups.push(async () => handle?.close());
handle = await startRemoteControl({
homeDir,
localOrigin: `http://127.0.0.1:${localPort}`,
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`,
stderr: { write: () => true },
});
expect(registrations).toHaveLength(1);
expect(handle.url).toContain('/coding-relay/devices/');
expect(handle.url).toContain('?rc=1&from=kimi_code_cli');
const rawRequest = Buffer.from(
'GET / HTTP/1.1\r\nHost: relay.test\r\nAuthorization: Bearer relay-token\r\nCookie: sid=1\r\nOrigin: https://relay.test\r\nConnection: X-Hop\r\nX-Hop: remove\r\nX-Keep: yes\r\n\r\n',
);
const splitAt = Math.floor(rawRequest.length / 2);
httpConnections[0]!.send(
JSON.stringify({
request_id: 'request-1',
type: 'request',
is_last: false,
body_base64: rawRequest.subarray(0, splitAt).toString('base64'),
}),
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(localHttpRequest).toBeUndefined();
const responsePromise = nextJsonMessage(httpConnections[0]!);
httpConnections[0]!.send(
JSON.stringify({
request_id: 'request-1',
type: 'request',
is_last: true,
body_base64: rawRequest.subarray(splitAt).toString('base64'),
}),
);
const responseMessage = await responsePromise;
const response = Buffer.from(responseMessage['body_base64'] as string, 'base64').toString();
expect(response).toContain('HTTP/1.1 200 OK');
expect(localHttpRequest?.headers.authorization).toBe('Bearer local-server-token');
expect(localHttpRequest?.headers.cookie).toBeUndefined();
expect(localHttpRequest?.headers.origin).toBeUndefined();
expect(localHttpRequest?.headers['x-hop']).toBeUndefined();
expect(localHttpRequest?.headers['x-keep']).toBe('yes');
expect(response).not.toContain('X-Remove');
expect(response).not.toContain('immutable');
expect(response).toContain('Cache-Control: no-cache');
expect(response).toContain(`/coding-relay/devices/${handle.deviceId}/boot.js`);
managementConnections[0]!.send(
JSON.stringify({
type: 'open_ws',
payload: {
stream_id: 'stream-1',
path: '/api/v1/ws',
headers: { Cookie: 'relay-cookie', Origin: 'https://relay.test', 'X-Keep': 'yes' },
},
}),
);
await waitFor(() => streamConnections.length === 1 && localWs !== undefined);
expect(localWsRequest?.headers['sec-websocket-protocol']).toBe(
'kimi-code.bearer.local-server-token',
);
expect(localWsRequest?.headers.authorization).toBeUndefined();
expect(localWsRequest?.headers.cookie).toBeUndefined();
expect(localWsRequest?.headers.origin).toBeUndefined();
expect(localWsRequest?.headers['x-keep']).toBe('yes');
await waitFor(() =>
managementMessages.some(
(value) =>
(value as { type?: string }).type === 'open_ws_result' &&
(value as { payload?: { success?: boolean } }).payload?.success === true,
),
);
await waitFor(() => streamMessages.includes('server-hello-frame'));
const localMessage = nextTextMessage(localWs!);
streamConnections[0]!.send('from-relay');
await expect(localMessage).resolves.toBe('from-relay');
const relayMessage = nextTextMessage(streamConnections[0]!);
localWs!.send('from-local');
await expect(relayMessage).resolves.toBe('from-local');
streamConnections[0]!.terminate();
await waitFor(() => localWs?.readyState === 3);
httpConnections[0]!.terminate();
await waitFor(() => registrations.length === 2 && httpConnections.length === 2, 4000);
await handle.close();
await waitFor(() =>
managementMessages.some(
(value) =>
(value as { type?: string; payload?: { reason?: string } }).type === 'disconnect' &&
(value as { payload?: { reason?: string } }).payload?.reason === 'local_server_stopped',
),
);
});
});
describe('Remote Control single-instance lock', () => {
async function deadPid(): Promise<number> {
const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' });
await new Promise<void>((resolve) => child.on('exit', () => resolve()));
return child.pid!;
}
it('refuses a second instance on the same home and reports the running link', async () => {
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
const relay = await startAuthRelay();
let first: RemoteControlHandle | undefined;
cleanups.push(async () => first?.close());
first = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:58627',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}`,
stderr: { write: () => true },
});
await expect(
startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:58628',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}`,
stderr: { write: () => true },
}),
).rejects.toThrow(/already running[\s\S]*127\.0\.0\.1:58627[\s\S]*\/devices\//);
expect(relay.requests).toHaveLength(2);
});
it('reaps a stale lock left by a dead process', async () => {
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
await mkdir(join(homeDir, 'server'), { recursive: true });
await writeFile(
remoteControlLockPath(homeDir),
JSON.stringify({
pid: await deadPid(),
nonce: 'stale',
local_origin: 'http://127.0.0.1:1',
device_id: 'dead-device',
url: 'https://code-rc.kimi.com/devices/dead-device/',
started_at: 0,
}),
);
const relay = await startAuthRelay();
let handle: RemoteControlHandle | undefined;
cleanups.push(async () => handle?.close());
handle = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:58627',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}`,
stderr: { write: () => true },
});
const lock = JSON.parse(await readFile(remoteControlLockPath(homeDir), 'utf8')) as {
pid: number;
};
expect(lock.pid).toBe(process.pid);
});
it('releases the lock on close so a new instance can start', async () => {
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
const relay = await startAuthRelay();
const options = {
homeDir,
localOrigin: 'http://127.0.0.1:58627',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}`,
stderr: { write: () => true },
};
const first = await startRemoteControl(options);
await first.close();
let second: RemoteControlHandle | undefined;
cleanups.push(async () => second?.close());
second = await startRemoteControl(options);
expect(second.url).toContain('/devices/');
});
it('does not remove a successor lock when closing', async () => {
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
const relay = await startAuthRelay();
const handle = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:58627',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}`,
stderr: { write: () => true },
});
cleanups.push(async () => handle?.close());
await writeFile(
remoteControlLockPath(homeDir),
JSON.stringify({
pid: process.pid,
nonce: 'successor',
local_origin: 'http://127.0.0.1:58628',
device_id: 'device-2',
url: 'https://code-rc.kimi.com/devices/device-2/',
started_at: Date.now(),
}),
);
await handle.close();
const lock = JSON.parse(await readFile(remoteControlLockPath(homeDir), 'utf8')) as {
nonce: string;
};
expect(lock.nonce).toBe('successor');
});
});
async function createRemoteControlHome(refreshToken: string): Promise<string> {
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-auth-'));
cleanups.push(() => rmSync(homeDir, { recursive: true, force: true }));
await new FileTokenStorage(join(homeDir, 'credentials')).save(
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
{
...TOKEN,
refreshToken,
},
);
return homeDir;
}
async function startAuthRelay(
options: {
echoProtocol?: boolean;
rejectUpgrades?: number;
closeManagementDuringFirstHttpHandshake?: boolean;
} = {},
): Promise<{
port: number;
requests: Array<{ authorization?: string; protocol?: string }>;
}> {
const handleProtocols = options.echoProtocol === false ? (): false => false : undefined;
const managementServer = new WebSocketServer({ noServer: true, handleProtocols });
const httpTunnelServer = new WebSocketServer({ noServer: true, handleProtocols });
const relayServer = createServer();
const requests: Array<{ authorization?: string; protocol?: string }> = [];
let remainingRejections = options.rejectUpgrades ?? 0;
let closeManagement = options.closeManagementDuringFirstHttpHandshake === true;
let delayHttpUpgrade = closeManagement;
managementServer.on('connection', (ws) => {
ws.on('error', () => {});
ws.on('message', (data) => {
const message = JSON.parse(rawDataText(data)) as { type?: string };
if (message.type === 'register') {
ws.send(JSON.stringify({ type: 'register_ack', payload: { success: true } }));
if (closeManagement) {
closeManagement = false;
setTimeout(() => ws.close(), 10);
}
}
});
});
httpTunnelServer.on('connection', (ws) => ws.on('error', () => {}));
relayServer.on('upgrade', (request, socket, head) => {
const authorization = request.headers.authorization;
const protocol = request.headers['sec-websocket-protocol'];
requests.push({
authorization: Array.isArray(authorization) ? authorization[0] : authorization,
protocol: Array.isArray(protocol) ? protocol[0] : protocol,
});
if (remainingRejections > 0) {
remainingRejections -= 1;
socket.end(
'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
);
return;
}
const pathname = new URL(request.url!, 'http://relay.test').pathname;
const target = pathname.endsWith('/v1/remote/create')
? managementServer
: httpTunnelServer;
const upgrade = (): void => {
target.handleUpgrade(request, socket, head, (ws) => target.emit('connection', ws, request));
};
if (target === httpTunnelServer && delayHttpUpgrade) {
delayHttpUpgrade = false;
setTimeout(upgrade, 50);
return;
}
upgrade();
});
const port = await listen(relayServer);
cleanups.push(() => closeServer(relayServer));
return { port, requests };
}
function listen(server: ReturnType<typeof createServer>): Promise<number> {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') reject(new Error('missing address'));
else resolve(address.port);
});
});
}
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error === undefined) resolve();
else reject(error);
});
});
}
function rawDataText(data: RawData): string {
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
return Buffer.from(data as ArrayBuffer).toString('utf8');
}
function nextJsonMessage(socket: WebSocket): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
socket.once('message', (data) => resolve(JSON.parse(rawDataText(data)) as Record<string, unknown>));
});
}
function nextTextMessage(socket: WebSocket): Promise<string> {
return new Promise((resolve) => {
socket.once('message', (data) => resolve(rawDataText(data)));
});
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('condition timed out');
await new Promise((resolve) => setTimeout(resolve, 10));
}
}

View file

@ -15,6 +15,8 @@ import chalk, { Chalk } from 'chalk';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetCapabilitiesCache, setCapabilities } from '@moonshot-ai/pi-tui';
import { registerWebCommand } from '#/cli/sub/web';
import type { LegacyKillDeps } from '#/cli/sub/web/legacy-kill';
import type { WebCommandDeps } from '#/cli/sub/web/run';
@ -50,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;
hooks?.onReady?.(origin);
await hooks?.onReady?.(origin);
return undefined as never;
};
return { runner, calls };
@ -99,11 +101,12 @@ describe('kimi web', () => {
expect(longs).toContain('--allowed-host');
expect(longs).toContain('--insecure-no-tls');
expect(longs).toContain('--allow-remote-shutdown');
expect(longs).toContain('--allow-remote-terminals');
expect(longs).toContain('--dangerous-bypass-auth');
expect(longs).toContain('--log-level');
expect(longs).toContain('--debug-endpoints');
expect(longs).toContain('--web-title');
const remoteControl = web!.options.find((option) => option.long === '--remote-control');
expect(remoteControl?.short).toBe('--rc');
// web opens the browser by default → the option is the negative --no-open.
expect(longs).toContain('--no-open');
// The background/daemon era flags are gone: the server always runs in the
@ -112,6 +115,7 @@ describe('kimi web', () => {
expect(longs).not.toContain('--keep-alive');
expect(longs).not.toContain('--daemon');
expect(longs).not.toContain('--idle-grace-ms');
expect(longs).not.toContain('--allow-remote-terminals');
});
it('routes `kimi server` and any legacy subcommand to a deprecation notice', async () => {
@ -339,6 +343,11 @@ describe('ready banner reflects the bind class', () => {
});
describe('`kimi web` opens the browser', () => {
afterEach(() => {
vi.unstubAllEnvs();
resetCapabilitiesCache();
});
it('opens the Web UI URL with the #token= fragment by default', async () => {
const { handleWebCommand } = await import('#/cli/sub/web/run');
const { runner } = makeRunner();
@ -392,6 +401,171 @@ describe('`kimi web` opens the browser', () => {
expect(openUrl).not.toHaveBeenCalled();
});
it('maps --remote-control and --rc to the same option', () => {
for (const flag of ['--remote-control', '--rc']) {
const program = makeProgram();
const web = program.commands.find((command) => command.name() === 'web')!;
web.parseOptions([flag]);
expect(web.opts()).toMatchObject({ remoteControl: true });
}
});
it('rejects Remote Control on a non-loopback host', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1');
const { handleWebCommand } = await import('#/cli/sub/web/run');
const { runner } = makeRunner();
const { stdout, stderr } = makeIo();
await expect(
handleWebCommand(
{ remoteControl: true, host: '0.0.0.0', open: false },
{ startServerForeground: runner, openUrl: vi.fn(), stdout, stderr },
),
).rejects.toThrow('--remote-control requires a loopback host.');
});
it('opens and saves only the public Remote Control URL without the local server token', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1');
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-qrcode-'));
const dataDir = join(tempRoot, 'custom-home');
vi.stubEnv('KIMI_CODE_HOME', dataDir);
const publicUrl =
'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli';
const pngPath = join(dataDir, 'rc-qrcode.png');
const { handleWebCommand } = await import('#/cli/sub/web/run');
const { generateRemoteControlQr, renderTerminalQr } = await import('#/utils/remote-control-qr');
const QRCode = await import('qrcode');
const { isAbsolute } = await import('node:path');
await generateRemoteControlQr('https://example.test/previous', dataDir);
const previousPng = readFileSync(pngPath);
const { runner } = makeRunner();
const { stdout, stderr, readStdout } = makeIo();
const openUrl = vi.fn();
const startRemoteControl = vi.fn(async () => ({
deviceId: 'device-1',
deviceName: 'example-device',
url: publicUrl,
close: vi.fn(async () => {}),
}));
try {
await handleWebCommand(
{ remoteControl: true, open: true },
{
startServerForeground: runner,
startRemoteControl,
resolveToken: () => 'local-server-token',
openUrl,
stdout,
stderr,
},
);
expect(startRemoteControl).toHaveBeenCalledWith(
expect.objectContaining({
homeDir: dataDir,
localOrigin: 'http://127.0.0.1:58627',
localServerToken: 'local-server-token',
}),
);
expect(openUrl).toHaveBeenCalledWith(publicUrl);
const written = readStdout();
expect(written).toContain('Kimi Remote Control ready');
expect(written).toContain(renderTerminalQr(publicUrl));
expect(written).not.toContain(renderTerminalQr('http://127.0.0.1:58627'));
expect(isAbsolute(pngPath)).toBe(true);
expect(written).toContain(`QR code PNG: ${pngPath}`);
const png = readFileSync(pngPath);
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
expect(png).not.toEqual(previousPng);
expect(png).toEqual(await QRCode.toBuffer(publicUrl));
expect(written).not.toContain('local-server-token');
expect(written).not.toContain('#token=');
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it('rejects --remote-control while the experimental flag is off', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0');
const { handleWebCommand } = await import('#/cli/sub/web/run');
const { runner } = makeRunner();
const { stdout, stderr } = makeIo();
await expect(
handleWebCommand(
{ remoteControl: true, open: false },
{ startServerForeground: runner, openUrl: vi.fn(), stdout, stderr },
),
).rejects.toThrow('--remote-control is experimental:');
});
it('hides --remote-control from help unless the experimental flag is on', () => {
const remoteControlOption = () =>
makeProgram()
.commands.find((command) => command.name() === 'web')!
.options.find((option) => option.long === '--remote-control');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0');
expect(remoteControlOption()?.hidden).toBe(true);
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1');
expect(remoteControlOption()?.hidden).toBe(false);
});
});
describe('kimi rc', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('registers `rc` with the `remote` alias and the web server options, without a --remote-control flag', () => {
const program = makeProgram();
const rc = program.commands.find((c) => c.name() === 'rc');
expect(rc).toBeDefined();
expect(rc!.alias()).toBe('remote');
const longs = rc!.options.map((o) => o.long).filter(Boolean);
expect(longs).toContain('--port');
expect(longs).toContain('--host');
expect(longs).toContain('--no-open');
expect(longs).not.toContain('--remote-control');
});
it('hides `rc` from help unless the experimental flag is on', () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0');
expect(makeProgram().helpInformation()).not.toContain('rc|remote');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1');
expect(makeProgram().helpInformation()).toContain('rc|remote');
});
it('forces Remote Control for both `rc` and `remote`', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0');
for (const name of ['rc', 'remote']) {
const program = makeProgram();
let stderr = '';
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
try {
await program.parseAsync(['node', 'kimi', name]);
} finally {
errSpy.mockRestore();
exitSpy.mockRestore();
}
// The flag-off experimental error proves remoteControl was forced before
// the runner could start.
expect(stderr).toContain('--remote-control is experimental:');
}
});
});
describe('`kimi web` option threading', () => {
@ -409,7 +583,6 @@ describe('`kimi web` option threading', () => {
dangerousBypassAuth: true,
debugEndpoints: true,
allowRemoteShutdown: true,
allowRemoteTerminals: true,
open: false,
},
{ startServerForeground: runner, openUrl: vi.fn(), stdout, stderr },
@ -422,7 +595,6 @@ describe('`kimi web` option threading', () => {
debugEndpoints: true,
insecureNoTls: true,
allowRemoteShutdown: true,
allowRemoteTerminals: true,
dangerousBypassAuth: true,
allowedHosts: ['.example.com'],
});

View file

@ -233,4 +233,12 @@ describe('built-in slash command registry', () => {
expect(resolveSlashCommandAvailability(command!, 'teardown')).toBe('always');
expect(resolveSlashCommandAvailability(command!, 'Ship feature X')).toBe('always');
});
it('gates remote-control behind the remote-control experiment, always available', () => {
const command = findBuiltInSlashCommand('remote-control');
expect(command).toBeDefined();
expect((command as KimiSlashCommand).experimentalFlag).toBe('remote-control');
expect(resolveSlashCommandAvailability(command!, '')).toBe('always');
});
});

View file

@ -64,6 +64,13 @@ describe('resolveSlashCommandInput', () => {
});
});
it('gates /remote-control behind the remote-control experimental flag', () => {
expect(resolve('/rc')).toEqual({ kind: 'message', input: '/rc' });
setExperimentalFeatures([{ id: 'remote-control', enabled: true }]);
expect(resolve('/rc')).toMatchObject({ kind: 'builtin', name: 'remote-control' });
expect(resolve('/remote-control')).toMatchObject({ kind: 'builtin', name: 'remote-control' });
});
it('blocks idle-only built-ins while streaming', () => {
expect(resolve('/new', { isStreaming: true })).toEqual({
kind: 'blocked',

View file

@ -1,16 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { setCapabilities } from '@moonshot-ai/pi-tui';
import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import { handleWebCommand, webSessionUrl } from '#/tui/commands/web';
import {
handleRemoteControlCommand,
handleWebCommand,
webSessionUrl,
} from '#/tui/commands/web';
import { renderTerminalQr } from '#/utils/remote-control-qr';
const mocks = vi.hoisted(() => ({
startServerForeground: vi.fn(),
startRemoteControl: vi.fn(),
tryResolveServerToken: vi.fn(),
getDataDir: vi.fn(() => '/tmp/kimi-home'),
openUrl: vi.fn(),
}));
vi.mock('#/cli/sub/web/remote-control', async (importOriginal) => {
const actual = await importOriginal<typeof import('#/cli/sub/web/remote-control')>();
return { ...actual, startRemoteControl: mocks.startRemoteControl };
});
vi.mock('#/cli/sub/web/run', async (importOriginal) => {
const actual = await importOriginal<typeof import('#/cli/sub/web/run')>();
return { ...actual, startServerForeground: mocks.startServerForeground };
@ -44,6 +57,7 @@ function makeHost() {
setExitOpenUrl: vi.fn(),
setExitForegroundTask: vi.fn(),
stop: vi.fn(async () => {}),
waitForLazyCreation: vi.fn(async () => {}),
} as unknown as SlashCommandHost & {
showStatus: ReturnType<typeof vi.fn>;
showError: ReturnType<typeof vi.fn>;
@ -52,6 +66,7 @@ function makeHost() {
setExitOpenUrl: ReturnType<typeof vi.fn>;
setExitForegroundTask: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
waitForLazyCreation: ReturnType<typeof vi.fn>;
};
return host;
}
@ -62,6 +77,13 @@ describe('web slash command', () => {
expect(command).toBeDefined();
expect(resolveSlashCommandAvailability(command!, '')).toBe('always');
});
it('registers /remote-control and /rc as the same always-available built-in', () => {
const command = findBuiltInSlashCommand('remote-control');
expect(command).toBeDefined();
expect(findBuiltInSlashCommand('rc')).toBe(command);
expect(resolveSlashCommandAvailability(command!, '')).toBe('always');
});
});
describe('handleWebCommand', () => {
@ -120,6 +142,175 @@ describe('handleWebCommand', () => {
});
});
describe('handleRemoteControlCommand', () => {
it('stays in the TUI with a readable error when another instance holds Remote Control', async () => {
vi.clearAllMocks();
const { mkdtempSync, mkdirSync, rmSync, writeFileSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-lock-'));
const dataDir = join(tempRoot, 'home');
mkdirSync(join(dataDir, 'server'), { recursive: true });
writeFileSync(
join(dataDir, 'server', 'rc.json'),
JSON.stringify({
pid: process.pid,
nonce: 'holder',
local_origin: 'http://127.0.0.1:58627',
device_id: 'device-1',
url: 'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli',
started_at: Date.now(),
}),
);
mocks.getDataDir.mockReturnValue(dataDir);
const host = makeHost();
try {
await handleRemoteControlCommand(host);
expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('already running'));
expect(host.showError).toHaveBeenCalledWith(
expect.stringContaining('/devices/device-1/'),
);
expect(host.setExitForegroundTask).not.toHaveBeenCalled();
expect(host.stop).not.toHaveBeenCalled();
expect(mocks.startServerForeground).not.toHaveBeenCalled();
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it('starts the tunnel and saves a token-free session QR code', async () => {
vi.clearAllMocks();
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
const { mkdtempSync, readFileSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { isAbsolute, join } = await import('node:path');
const QRCode = await import('qrcode');
const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-qrcode-'));
const dataDir = join(tempRoot, 'custom-home');
const entryUrl =
'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli';
const sessionUrl =
'https://code-rc.kimi.com/devices/device-1/sessions/ses-1?rc=1&from=kimi_code_cli';
const pngPath = join(dataDir, 'rc-qrcode.png');
mocks.getDataDir.mockReturnValue(dataDir);
mocks.tryResolveServerToken.mockReturnValue('local-server-token');
const close = vi.fn(async () => {});
mocks.startRemoteControl.mockResolvedValue({
deviceId: 'device-1',
deviceName: 'example-device',
url: entryUrl,
close,
});
mocks.startServerForeground.mockImplementation(
async (
_options: unknown,
hooks: {
onReady?: (origin: string) => void | Promise<void>;
onShutdown?: (reason: string) => void | Promise<void>;
},
) => {
await hooks.onReady?.('http://127.0.0.1:58627');
await hooks.onShutdown?.('SIGINT');
},
);
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const host = makeHost();
try {
await handleRemoteControlCommand(host);
const task = host.setExitForegroundTask.mock.calls[0]![0] as () => Promise<void>;
await task();
expect(mocks.startRemoteControl).toHaveBeenCalledWith(
expect.objectContaining({
homeDir: dataDir,
localOrigin: 'http://127.0.0.1:58627',
localServerToken: 'local-server-token',
}),
);
expect(mocks.openUrl).toHaveBeenCalledWith(sessionUrl);
const written = writeSpy.mock.calls.map((call) => String(call[0])).join('');
expect(written).toContain('Kimi Remote Control ready');
expect(written).toContain(renderTerminalQr(sessionUrl));
expect(written).not.toContain(renderTerminalQr(entryUrl));
expect(isAbsolute(pngPath)).toBe(true);
expect(written).toContain(`QR code PNG: ${pngPath}`);
const png = readFileSync(pngPath);
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
expect(png).toEqual(await QRCode.toBuffer(sessionUrl));
expect(written).not.toContain('local-server-token');
expect(written).not.toContain('#token=');
expect(close).toHaveBeenCalledOnce();
} finally {
writeSpy.mockRestore();
rmSync(tempRoot, { recursive: true, force: true });
}
});
it('opens the device entry URL without a session instead of creating one', async () => {
vi.clearAllMocks();
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
const { mkdtempSync, readFileSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const QRCode = await import('qrcode');
const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-entry-'));
const dataDir = join(tempRoot, 'custom-home');
const entryUrl =
'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli';
mocks.getDataDir.mockReturnValue(dataDir);
mocks.tryResolveServerToken.mockReturnValue('local-server-token');
const close = vi.fn(async () => {});
mocks.startRemoteControl.mockResolvedValue({
deviceId: 'device-1',
deviceName: 'example-device',
url: entryUrl,
close,
});
mocks.startServerForeground.mockImplementation(
async (
_options: unknown,
hooks: {
onReady?: (origin: string) => void | Promise<void>;
onShutdown?: (reason: string) => void | Promise<void>;
},
) => {
await hooks.onReady?.('http://127.0.0.1:58627');
await hooks.onShutdown?.('SIGINT');
},
);
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const host = makeHost();
host.session = undefined;
try {
await handleRemoteControlCommand(host);
expect(host.waitForLazyCreation).toHaveBeenCalledOnce();
expect(host.showError).not.toHaveBeenCalled();
expect(host.setExitForegroundTask).toHaveBeenCalledOnce();
expect(host.stop).toHaveBeenCalledOnce();
const task = host.setExitForegroundTask.mock.calls[0]![0] as () => Promise<void>;
await task();
expect(mocks.openUrl).toHaveBeenCalledWith(entryUrl);
const written = writeSpy.mock.calls.map((call) => String(call[0])).join('');
expect(written).toContain(renderTerminalQr(entryUrl));
expect(written).not.toContain('/sessions/');
expect(readFileSync(join(dataDir, 'rc-qrcode.png'))).toEqual(
await QRCode.toBuffer(entryUrl),
);
expect(close).toHaveBeenCalledOnce();
} finally {
writeSpy.mockRestore();
rmSync(tempRoot, { recursive: true, force: true });
}
});
});
describe('webSessionUrl', () => {
it('deep-links to the session under the origin', () => {
expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe(

View file

@ -0,0 +1,104 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resetCapabilitiesCache, setCapabilities } from '@moonshot-ai/pi-tui';
import { afterEach, describe, expect, it } from 'vitest';
import * as QRCode from 'qrcode';
import { generateRemoteControlQr, renderTerminalQr } from '#/utils/remote-control-qr';
const RESET = '\u001B[0m';
const WHITE_CELL = '\u001B[38;2;255;255;255m\u001B[48;2;255;255;255m▀';
describe('renderTerminalQr', () => {
it('renders truecolor black-on-white half blocks with a white quiet zone', () => {
const url = 'https://example.test/rc/entry';
const output = renderTerminalQr(url);
const size = QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size;
const width = size + 4;
expect(output).toContain('\u001B[38;2;0;0;0m');
expect(output).not.toContain('\u001B[40m');
expect(output).not.toContain('\u001B[47m');
expect(output).not.toContain('\u001B[30m');
expect(output).not.toContain('\u001B[37m');
expect(output.endsWith(RESET)).toBe(true);
const lines = output.split('\n');
expect(lines.at(-1)).toBe(RESET);
const rows = lines.slice(0, -1);
expect(rows.length).toBe(Math.ceil((size + 4) / 2));
for (const row of rows) {
expect(row.startsWith(WHITE_CELL.repeat(2))).toBe(true);
expect(row.endsWith(`${WHITE_CELL.repeat(2)}${RESET}`)).toBe(true);
expect(row.split('▀').length - 1).toBe(width);
}
expect(rows[0]).toBe(`${WHITE_CELL.repeat(width)}${RESET}`);
expect(rows.at(-1)).toBe(`${WHITE_CELL.repeat(width)}${RESET}`);
});
it('renders different output for different URLs', () => {
expect(renderTerminalQr('https://example.test/a')).not.toBe(
renderTerminalQr('https://example.test/b'),
);
});
});
describe('generateRemoteControlQr terminal rendering', () => {
afterEach(() => {
resetCapabilitiesCache();
});
async function generateInTempDir(url: string) {
const dir = mkdtempSync(join(tmpdir(), 'kimi-rc-qr-'));
try {
const result = await generateRemoteControlQr(url, dir);
return { ...result, dir };
} catch (error) {
rmSync(dir, { recursive: true, force: true });
throw error;
}
}
it('falls back to half-block rendering when the terminal has no image protocol', async () => {
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
const url = 'https://example.test/rc/entry';
const { terminal, pngPath, dir } = await generateInTempDir(url);
try {
expect(terminal).toBe(renderTerminalQr(url));
expect(readFileSync(pngPath)).toEqual(await QRCode.toBuffer(url));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('renders the PNG as a kitty image when the kitty protocol is available', async () => {
setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true });
const url = 'https://example.test/rc/entry';
const { terminal, pngPath, dir } = await generateInTempDir(url);
try {
const png = readFileSync(pngPath);
expect(terminal).toContain('\u001B_G');
expect(terminal).toContain(png.toString('base64'));
expect(terminal).not.toBe(renderTerminalQr(url));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('renders the PNG as an iterm2 inline image when the iterm2 protocol is available', async () => {
setCapabilities({ images: 'iterm2', trueColor: true, hyperlinks: true });
const url = 'https://example.test/rc/entry';
const { terminal, pngPath, dir } = await generateInTempDir(url);
try {
const png = readFileSync(pngPath);
expect(terminal).toContain('\u001B]1337;File=');
expect(terminal).toContain(png.toString('base64'));
expect(terminal).not.toBe(renderTerminalQr(url));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -162,7 +162,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-BZFvB+JuwKiUmg8+OIQHNv2hr0Pm4pkr3s63cr1cVio=";
hash = "sha256-NDcCQ5vxsGaSdJ3U0bvq2RkXKwrYTI7/8zZn/x1fvJ8=";
};
nativeBuildInputs = [

View file

@ -88,7 +88,9 @@
- V3 resume 期 signal 靠 `emitLive` 隐式压制skill/swarm——"这个 signal 发不发
得出去"取决于调用时相位,调用点看不出来。
- V4 `IEventService` payload 无类型、事件名裸字符串、同一事件两处发布者。
- V5 `prompt.submitted` 协议里存在但无人发;`AsyncEmitter/handleVetos` 是死代码。
- V5 `prompt.submitted` 曾长期只存在于协议而无人发,现已由 `AgentPromptService`
在提交时发出(排队/运行以 `status` 区分,启动时再发 `prompt.started`
`AsyncEmitter/handleVetos` 是死代码。
**回环与相位**
- L1 订阅者回写链真实存在且无统一约束turn.onEnded→goal 续跑→再 launch turn

View file

@ -106,6 +106,32 @@ export class PromptQueued extends AgentEvent2<PromptQueuedPayload> {
}
export interface PromptQueued extends PromptQueuedPayload {}
export interface PromptSubmittedPayload {
readonly agentId: string;
readonly promptId: string;
readonly userMessageId: string;
readonly status: 'running' | 'queued';
readonly content: ContentPart[];
readonly createdAt: string;
}
export class PromptSubmitted extends AgentEvent2<PromptSubmittedPayload> {
static override readonly type = 'prompt.submitted';
static override readonly observable = true;
}
export interface PromptSubmitted extends PromptSubmittedPayload {}
export interface PromptStartedPayload {
readonly agentId: string;
readonly promptId: string;
}
export class PromptStarted extends AgentEvent2<PromptStartedPayload> {
static override readonly type = 'prompt.started';
static override readonly observable = true;
}
export interface PromptStarted extends PromptStartedPayload {}
interface Deferred<T> { readonly promise: Promise<T>; resolve(value: T): void; reject(reason: unknown): void }
interface Record extends PromptSnapshot {
state: PromptState;
@ -236,16 +262,15 @@ export class AgentPromptService implements IAgentPromptService {
completion: completionDeferred.promise,
};
this.pending.push(record);
if (this.active === undefined && !this.launching) {
if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') {
this.publishQueued(record);
return record.handle;
}
void this.startNext();
await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]);
} else {
const idle = this.active === undefined && !this.launching;
const queued = !idle || (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running');
this.publishSubmitted(record, queued ? 'queued' : 'running');
if (queued) {
this.publishQueued(record);
return record.handle;
}
void this.startNext();
await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]);
return record.handle;
}
@ -421,6 +446,7 @@ export class AgentPromptService implements IAgentPromptService {
const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminder())).assigned).turn;
if (turn === undefined) { this.pending.unshift(item); return; }
item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn });
this.publishStarted(item);
void turn.result.then((result) => this.settle(item, result));
} catch {
item.state = 'failed';
@ -491,6 +517,14 @@ export class AgentPromptService implements IAgentPromptService {
if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return;
void this.dispatcher.dispatch(new PromptQueued({ agentId: this.scopeContext.agentId, promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length }));
}
private publishSubmitted(record: Record, status: 'running' | 'queued'): void {
if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return;
void this.dispatcher.dispatch(new PromptSubmitted({ agentId: this.scopeContext.agentId, promptId: record.id, userMessageId: record.userMessageId, status, content: stripBundledSkillBlocks(record.message), createdAt: record.createdAt }));
}
private publishStarted(record: Record): void {
if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return;
void this.dispatcher.dispatch(new PromptStarted({ agentId: this.scopeContext.agentId, promptId: record.id }));
}
private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ agentId: this.scopeContext.agentId, promptId, abortedAt: new Date().toISOString() })); }
}

View file

@ -0,0 +1,16 @@
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
export const REMOTE_CONTROL_FLAG_ID = 'remote-control';
export const REMOTE_CONTROL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL';
export const remoteControlFlag: FlagDefinitionInput = {
id: REMOTE_CONTROL_FLAG_ID,
title: 'Remote Control',
description:
'Expose the local web UI through Kimi Remote Control (`kimi web --remote-control`, `/remote-control`).',
env: REMOTE_CONTROL_FLAG_ENV,
default: false,
surface: 'both',
};
registerFlagDefinition(remoteControlFlag);

View file

@ -179,6 +179,7 @@ export * from '#/kosong/provider/providerService';
export * from '#/kosong/provider/providerDefinition';
export * from '#/kosong/provider/protocolAdapterRegistry';
import '#/features/skill/catalog/configSection';
import '#/app/remoteControl/flag';
import '#/app/agentIdentity/configSection';
export * from '#/app/agentIdentity/configSection';
export * from '#/app/agentIdentity/agentIdentity';

View file

@ -69,10 +69,12 @@ describe('Agent loop', () => {
[wire] tools.set_active_tools { "agentId": "main", "names": [], "time": "<time>" }
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Hello" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Hello" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
[wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" }
[emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
@ -127,10 +129,12 @@ describe('Agent loop', () => {
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Hello" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Hello" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
[wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" }
[emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
@ -350,10 +354,12 @@ describe('Agent loop', () => {
[wire] tools.set_active_tools { "agentId": "main", "names": [ "Lookup" ], "time": "<time>" }
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up moon" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up moon" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
[wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" }
[emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" }

View file

@ -12,7 +12,7 @@ import type { ContentPart } from '#/kosong/contract/message';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { AgentPromptService, PromptQueued, PromptSteered } from '#/agent/prompt/promptService';
import { AgentPromptService, PromptQueued, PromptStarted, PromptSteered, PromptSubmitted } from '#/agent/prompt/promptService';
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { wrapSystemReminder } from '#/features/reminder/systemReminder';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
@ -157,6 +157,32 @@ describe('AgentPromptService', () => {
expect(queued).toEqual([{ promptId: 'waiting', queueLength: 1 }]);
});
it('publishes prompt.submitted for every user prompt and prompt.started on launch', async () => {
const { prompt, eventBus } = harness();
const submitted: Array<{ promptId: string; userMessageId: string; status: string; content: ContentPart[] }> = [];
const started: string[] = [];
eventBus.subscribe(PromptSubmitted, (e) => {
submitted.push({ promptId: e.promptId, userMessageId: e.userMessageId, status: e.status, content: e.content });
});
eventBus.subscribe(PromptStarted, (e) => {
started.push(e.promptId);
});
const active = await prompt.enqueue({ id: 'active', message: message('active') });
expect(submitted).toEqual([
{ promptId: 'active', userMessageId: 'active', status: 'running', content: [{ type: 'text', text: 'active' }] },
]);
await active.launched;
expect(started).toEqual(['active']);
await prompt.enqueue({ id: 'waiting', message: message('waiting') });
expect(submitted).toEqual([
{ promptId: 'active', userMessageId: 'active', status: 'running', content: [{ type: 'text', text: 'active' }] },
{ promptId: 'waiting', userMessageId: 'waiting', status: 'queued', content: [{ type: 'text', text: 'waiting' }] },
]);
expect(started).toEqual(['active']);
});
it('atomically rejects steer when any id is not pending', async () => {
const { prompt } = harness();
await prompt.enqueue({ message: message('active') });

View file

@ -321,10 +321,12 @@ describe('Agent config', () => {
expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(`
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up before config changes" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up before config changes" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up before config changes" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up before config changes" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up before config changes" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
[wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" }
[emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
@ -415,10 +417,12 @@ describe('Agent config', () => {
[emit] prompt.completed { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "finishedAt": "<time>", "reason": "completed" }
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Start a fresh turn" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "userMessageId": "<msg-2>", "status": "running", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Start a fresh turn" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "origin": { "kind": "user" }, "prompt": "Start a fresh turn" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-2>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" }, "time": "<time>" }
[emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 1, "step": 1, "stepId": "<uuid-6>" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }

View file

@ -715,10 +715,12 @@ describe('Plan service', () => {
[emit] agent.status.updated { "time": "<time>", "agentId": "main", "planMode": true }
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Inspect without mutating files" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Inspect without mutating files" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Inspect without mutating files" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
[wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ] }
@ -796,10 +798,12 @@ describe('Plan service', () => {
[emit] agent.status.updated { "time": "<time>", "agentId": "main", "planMode": true }
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Remove forbidden.txt" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Remove forbidden.txt" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
[wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ] }

View file

@ -298,7 +298,7 @@ function normalizeObjectField(key: string, value: unknown, labels: SnapshotLabel
) {
return '<time>';
}
if ((key === 'finishedAt' || key === 'abortedAt' || key === 'steeredAt') && typeof value === 'string') return '<time>';
if ((key === 'finishedAt' || key === 'abortedAt' || key === 'steeredAt' || key === 'createdAt') && typeof value === 'string') return '<time>';
if (key === 'protocol_version' && value === WIRE_PROTOCOL_VERSION) {
return '<protocol-version>';
}

View file

@ -3872,10 +3872,12 @@ describe('Agent tools', () => {
[wire] tools.register_user_tool { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false }, "agentId": "main", "time": "<time>" }
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up moon" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up moon" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } } ] }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } }, "time": "<time>" }
@ -3951,10 +3953,12 @@ describe('Agent tools', () => {
[emit] prompt.completed { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "finishedAt": "<time>", "reason": "completed" }
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "userMessageId": "<msg-2>", "status": "running", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Can you still use Lookup?" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "origin": { "kind": "user" }, "prompt": "Can you still use Lookup?" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-2>" }
[wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" }, "time": "<time>" }
[emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 1, "step": 1, "stepId": "<uuid-6>" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }

View file

@ -24,7 +24,9 @@ import type { WarningIssued } from '@moonshot-ai/agent-core-v2/agent/profile/pro
import type {
PromptAborted,
PromptCompleted,
PromptStarted,
PromptSteered,
PromptSubmitted,
} from '@moonshot-ai/agent-core-v2/agent/prompt/promptService';
import type { PromptAccepted } from '@moonshot-ai/agent-core-v2/agent/prompt/promptOps';
import type { PromptQueued } from '@moonshot-ai/agent-core-v2/agent/prompt/promptService';
@ -91,6 +93,8 @@ type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision;
type AgentActivityUpdatedEvent = { readonly type: 'agent.activity.updated' } & AgentActivityUpdated;
type PromptAcceptedEvent = { readonly type: 'prompt.accepted' } & PromptAccepted;
type PromptQueuedEvent = { readonly type: 'prompt.queued' } & PromptQueued;
type PromptSubmittedEvent = { readonly type: 'prompt.submitted' } & PromptSubmitted;
type PromptStartedEvent = { readonly type: 'prompt.started' } & PromptStarted;
type PromptCompletedEvent = { readonly type: 'prompt.completed' } & PromptCompleted;
type PromptAbortedEvent = { readonly type: 'prompt.aborted' } & PromptAborted;
type PromptSteeredEvent = { readonly type: 'prompt.steered' } & PromptSteered;
@ -125,6 +129,8 @@ export type ProjectorBusEvent =
| AgentActivityUpdatedEvent
| PromptAcceptedEvent
| PromptQueuedEvent
| PromptSubmittedEvent
| PromptStartedEvent
| PromptCompletedEvent
| PromptAbortedEvent
| PromptSteeredEvent
@ -140,15 +146,6 @@ export type ProjectorBusEvent =
| ({ readonly type: 'error' } & AgentErrorEvent)
| ({ readonly type: 'warning' } & WarningIssued);
export interface ProjectorPromptSubmittedEvent {
readonly type: 'prompt.submitted';
readonly promptId: string;
readonly userMessageId: string;
readonly status: 'running' | 'queued' | 'blocked';
readonly content?: unknown;
readonly createdAt: string;
}
export type ProjectorFrameLookup = (
turnId: string,
stepId: string,
@ -226,7 +223,7 @@ export class AgentTranscriptProjector {
private readonly lookups?: ProjectorLookups,
) {}
map(event: ProjectorBusEvent | ProjectorPromptSubmittedEvent): TranscriptOperation[] {
map(event: ProjectorBusEvent): TranscriptOperation[] {
switch (event.type) {
case 'plan.revision':
return this.onPlanRevision(event);
@ -284,6 +281,8 @@ export class AgentTranscriptProjector {
return this.onPromptQueued(event);
case 'prompt.submitted':
return this.onPromptSubmitted(event);
case 'prompt.started':
return this.onPromptStarted(event);
case 'prompt.completed':
return this.onPromptCompleted(event);
case 'prompt.aborted':
@ -1233,13 +1232,28 @@ export class AgentTranscriptProjector {
return [{ op: 'prompt.upsert', prompt }];
}
private onPromptSubmitted(event: ProjectorPromptSubmittedEvent): TranscriptOperation[] {
const prompt = this.upsertPrompt(event.promptId, () => ({
private onPromptSubmitted(event: PromptSubmittedEvent): TranscriptOperation[] {
const prompt = this.upsertPrompt(event.promptId, (prev) => ({
promptId: event.promptId,
status: event.status,
status: prev !== undefined && isTerminalPromptStatus(prev.status) ? prev.status : event.status,
userMessageId: event.userMessageId,
content: event.content,
createdAt: event.createdAt,
content: projectPromptContentParts(event.content),
createdAt: prev?.createdAt ?? event.createdAt,
finishedAt: prev?.finishedAt,
steeredAt: prev?.steeredAt,
}));
return [{ op: 'prompt.upsert', prompt }];
}
private onPromptStarted(event: PromptStartedEvent): TranscriptOperation[] {
const prompt = this.upsertPrompt(event.promptId, (prev) => ({
promptId: event.promptId,
status: 'running',
userMessageId: prev?.userMessageId,
content: prev?.content,
createdAt: prev?.createdAt ?? new Date().toISOString(),
finishedAt: prev?.finishedAt,
steeredAt: prev?.steeredAt,
}));
return [{ op: 'prompt.upsert', prompt }];
}
@ -1345,6 +1359,10 @@ function nowIso(): string {
return new Date().toISOString();
}
function isTerminalPromptStatus(status: TranscriptPrompt['status']): boolean {
return status === 'completed' || status === 'failed' || status === 'aborted' || status === 'blocked';
}
function epochMsToIso(value: number): string {
return new Date(value).toISOString();
}

View file

@ -112,7 +112,6 @@ export interface ServerStartOptions {
readonly disableHostCheck?: boolean;
readonly insecureNoTls?: boolean;
readonly allowRemoteShutdown?: boolean;
readonly allowRemoteTerminals?: boolean;
readonly authTokenService?: IAuthTokenService;
readonly disableAuth?: boolean;
readonly webTitle?: string;
@ -161,7 +160,7 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
);
}
const enableShutdown = exposureClass === 'loopback' || opts.allowRemoteShutdown === true;
const enableTerminals = exposureClass === 'loopback' || opts.allowRemoteTerminals === true;
const enableTerminals = exposureClass === 'loopback';
const debugEndpoints = exposureClass === 'loopback' && opts.debugEndpoints === true;
const logger = opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' });
const onUnhandledRejection = (reason: unknown): void => {

View file

@ -884,7 +884,7 @@ export class SessionEventBroadcaster {
promptAttachments?: unknown;
};
wireEvent = Object.assign({}, wireFields, { agentId, sessionId }) as unknown as Event;
} else if (event.type === 'prompt.steered' || event.type === 'prompt.queued') {
} else if (event.type === 'prompt.steered' || event.type === 'prompt.queued' || event.type === 'prompt.submitted') {
const content = (event as unknown as { content: Parameters<typeof projectPromptContentParts>[0] }).content;
wireEvent = Object.assign({}, event, {
content: projectPromptContentParts(content),
@ -1149,6 +1149,7 @@ const TRANSCRIPT_PROJECTED_EVENT_TYPES: ReadonlySet<string> = new Set([
'agent.status.updated',
'hook.result',
'prompt.submitted',
'prompt.started',
'prompt.completed',
'prompt.aborted',
'prompt.steered',

View file

@ -114,24 +114,4 @@ describe('server-v2 exposure hardening hooks', () => {
});
expect(terminals.statusCode).toBe(404);
});
it('can explicitly re-enable terminal routes on non-loopback', async () => {
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '0.0.0.0',
port: 0,
homeDir: home,
logLevel: 'silent',
insecureNoTls: true,
allowRemoteTerminals: true,
});
const token = server.authTokenService.getToken();
const res = await server.app.inject({
method: 'GET',
url: '/api/v1/sessions/missing/terminals',
headers: { authorization: `Bearer ${token}` },
});
const body = res.json() as Record<string, unknown>;
expect(body['code']).toBe(40401);
});
});

View file

@ -1702,6 +1702,14 @@ describe('AgentTranscriptProjector', () => {
expect(tx.getPrompt('p1')).toMatchObject({ status: 'running', userMessageId: 'm1' });
expect(tx.getPrompt('p2')).toMatchObject({ status: 'queued' });
feed(ev({ type: 'prompt.started', promptId: 'p2' }));
expect(tx.getPrompt('p2')).toMatchObject({
status: 'running',
userMessageId: 'm2',
content: [{ type: 'text', text: 'second' }],
createdAt: '2026-01-01T00:00:01.000Z',
});
feed(
ev({
type: 'prompt.steered',

View file

@ -969,7 +969,7 @@ describe('SessionEventBroadcaster', () => {
);
});
it.each(['prompt.steered', 'prompt.queued'])(
it.each(['prompt.steered', 'prompt.queued', 'prompt.submitted'])(
'projects %s content without leaking daemon refs (live + tail replay)',
async (type) => {
const lc = new FakeLifecycle();
@ -981,7 +981,9 @@ describe('SessionEventBroadcaster', () => {
const ids =
type === 'prompt.steered'
? { activePromptId: 'p1', promptIds: ['p2'], steeredAt: '2026-01-01T00:00:02.000Z' }
: { promptId: 'p2', queueLength: 1 };
: type === 'prompt.submitted'
? { promptId: 'p2', userMessageId: 'p2', status: 'queued', createdAt: '2026-01-01T00:00:01.000Z' }
: { promptId: 'p2', queueLength: 1 };
main.bus.emit(
agentEvent(type, {
...ids,

View file

@ -62,6 +62,14 @@ interface TranscriptContract {
state: string;
[key: string]: unknown;
}[];
prompts: {
promptId: string;
status: string;
userMessageId?: string;
content?: unknown;
createdAt?: string;
[key: string]: unknown;
}[];
meta: Record<string, unknown>;
agents: { agentId: string; type?: string }[];
pending_interactions: string[];
@ -322,6 +330,55 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => {
);
});
it('exposes the prompt queue entities in the live transcript response', async () => {
const id = await createSession();
await ensureMainAgent(id);
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
const bus = mainAgentBus(id);
bus.publish(
serverEvent({
type: 'prompt.submitted',
promptId: 'p1',
userMessageId: 'p1',
status: 'running',
content: [{ type: 'text', text: 'first' }],
createdAt: '2026-01-01T00:00:00.000Z',
}),
);
bus.publish(
serverEvent({
type: 'prompt.submitted',
promptId: 'p2',
userMessageId: 'p2',
status: 'queued',
content: [{ type: 'text', text: 'second' }],
createdAt: '2026-01-01T00:00:01.000Z',
}),
);
let { body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
expect(body.data.prompts).toContainEqual(
expect.objectContaining({
promptId: 'p1',
status: 'running',
userMessageId: 'p1',
content: [{ type: 'text', text: 'first' }],
}),
);
expect(body.data.prompts).toContainEqual(expect.objectContaining({ promptId: 'p2', status: 'queued' }));
bus.publish(serverEvent({ type: 'prompt.started', promptId: 'p2' }));
({ body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`));
expect(body.data.prompts).toContainEqual(
expect.objectContaining({
promptId: 'p2',
status: 'running',
content: [{ type: 'text', text: 'second' }],
}),
);
});
it('paginates live turns with page_size and before_turn', async () => {
const id = await createSession();
await ensureMainAgent(id);

View file

@ -39,6 +39,7 @@ const DROPPED_DOMAIN_EVENT_TYPES: ReadonlySet<string> = new Set([
'prompt.submitted',
'prompt.completed',
'prompt.aborted',
'prompt.started',
'prompt.steered',
]);

135
pnpm-lock.yaml generated
View file

@ -73,6 +73,13 @@ importers:
version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
apps/kimi-code:
dependencies:
qrcode:
specifier: ^1.5.4
version: 1.5.4
ws:
specifier: ^8.18.0
version: 8.20.0
devDependencies:
'@moonshot-ai/acp-adapter':
specifier: workspace:^
@ -110,9 +117,15 @@ importers:
'@moonshot-ai/vis-web':
specifier: workspace:*
version: link:../vis/web
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/semver':
specifier: ^7.7.0
version: 7.7.1
'@types/ws':
specifier: ^8.18.0
version: 8.18.1
'@types/yazl':
specifier: ^2.4.6
version: 2.4.6
@ -447,11 +460,11 @@ importers:
version: 1.13.1
vitepress-plugin-mermaid:
specifier: ^2.0.17
version: 2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2))
version: 2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2))
devDependencies:
vitepress:
specifier: ^1.5.0
version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2)
version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2)
packages/acp-adapter:
dependencies:
@ -4460,6 +4473,9 @@ packages:
'@types/proper-lockfile@4.1.4':
resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@ -5064,6 +5080,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
@ -5164,6 +5184,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@ -5541,6 +5564,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decamelize@4.0.0:
resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==}
engines: {node: '>=10'}
@ -5639,6 +5666,9 @@ packages:
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@ -7856,6 +7886,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@6.0.0:
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
engines: {node: '>=12.13.0'}
@ -7967,6 +8001,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
@ -8188,6 +8227,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
@ -8391,6 +8433,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@ -9499,6 +9544,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.20:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
@ -9521,6 +9569,10 @@ packages:
workerpool@9.3.4:
resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -9577,6 +9629,9 @@ packages:
xstate@5.32.5:
resolution: {integrity: sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -9600,6 +9655,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@ -9612,6 +9671,10 @@ packages:
resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==}
engines: {node: '>=10'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
@ -13184,6 +13247,10 @@ snapshots:
dependencies:
'@types/retry': 0.12.0
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.17
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@ -13554,7 +13621,7 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/integrations@12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(typescript@6.0.2)':
'@vueuse/integrations@12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(qrcode@1.5.4)(typescript@6.0.2)':
dependencies:
'@vueuse/core': 12.8.2(typescript@6.0.2)
'@vueuse/shared': 12.8.2(typescript@6.0.2)
@ -13562,6 +13629,7 @@ snapshots:
optionalDependencies:
focus-trap: 7.8.0
fuse.js: 7.5.0
qrcode: 1.5.4
transitivePeerDependencies:
- typescript
@ -13891,6 +13959,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
camelcase@6.3.0: {}
caniuse-lite@1.0.30001788: {}
@ -14000,6 +14070,12 @@ snapshots:
cli-width@4.1.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@7.0.4:
dependencies:
string-width: 4.2.3
@ -14394,6 +14470,8 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
decamelize@1.2.0: {}
decamelize@4.0.0: {}
decimal.js@10.6.0:
@ -14468,6 +14546,8 @@ snapshots:
diff@9.0.0: {}
dijkstrajs@1.0.3: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@ -17146,6 +17226,8 @@ snapshots:
pluralize@8.0.0: {}
pngjs@5.0.0: {}
pngjs@6.0.0: {}
pngjs@7.0.0: {}
@ -17273,6 +17355,12 @@ snapshots:
punycode@2.3.1:
optional: true
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.1:
dependencies:
side-channel: 1.1.0
@ -17647,6 +17735,8 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
reselect@5.2.0: {}
resize-observer-polyfill@1.5.1: {}
@ -17935,6 +18025,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.1.2: {}
@ -18880,14 +18972,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
vitepress-plugin-mermaid@2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2)):
vitepress-plugin-mermaid@2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2)):
dependencies:
mermaid: 11.15.0
vitepress: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2)
vitepress: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2)
optionalDependencies:
'@mermaid-js/mermaid-mindmap': 9.3.0
vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2):
vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2):
dependencies:
'@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3)
@ -18900,7 +18992,7 @@ snapshots:
'@vue/devtools-api': 7.7.9
'@vue/shared': 3.5.35
'@vueuse/core': 12.8.2(typescript@6.0.2)
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(typescript@6.0.2)
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(qrcode@1.5.4)(typescript@6.0.2)
focus-trap: 7.8.0
mark.js: 8.11.1
minisearch: 7.2.0
@ -19080,6 +19172,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.20:
dependencies:
available-typed-arrays: 1.0.7
@ -19105,6 +19199,12 @@ snapshots:
workerpool@9.3.4: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@ -19153,6 +19253,8 @@ snapshots:
xstate@5.32.5: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
@ -19165,6 +19267,11 @@ snapshots:
yaml@2.8.3: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
@ -19176,6 +19283,20 @@ snapshots:
flat: 5.0.2
is-plain-obj: 2.1.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@16.2.0:
dependencies:
cliui: 7.0.4