mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat(kimi-code): add kimi web command and daemon background mode
- add kimi web subcommand to open the daemon-hosted browser UI\n- refactor kimi daemon to start in background by default; add --foreground flag\n- serve web assets from daemon via new registerWebAssetRoutes\n- copy kimi-web build output into CLI bundle at build time\n- update docs for daemon and web commands\n- add unit tests for daemon and web command handlers
This commit is contained in:
parent
6027ceca26
commit
be89ce7419
63 changed files with 1230 additions and 264 deletions
5
.changeset/kimi-web-daemon.md
Normal file
5
.changeset/kimi-web-daemon.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add the daemon-hosted web UI command and background daemon startup flow.
|
||||
3
.github/workflows/pkg-pr-new.yml
vendored
3
.github/workflows/pkg-pr-new.yml
vendored
|
|
@ -36,6 +36,9 @@ jobs:
|
|||
- name: Build package dependencies
|
||||
run: pnpm run build:packages
|
||||
|
||||
- name: Build Kimi web assets
|
||||
run: pnpm --filter @moonshot-ai/kimi-web run build
|
||||
|
||||
- name: Generate Kimi Code built-in catalog
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist-web/
|
||||
dist-native/
|
||||
.tmp-api-extractor/
|
||||
coverage/
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-web",
|
||||
"scripts/postinstall.mjs",
|
||||
"scripts/postinstall",
|
||||
"README.md"
|
||||
|
|
@ -44,7 +45,7 @@
|
|||
"provenance": true
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"build": "tsdown && node scripts/copy-web-assets.mjs",
|
||||
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"build:native:js": "node scripts/native/01-bundle.mjs",
|
||||
|
|
@ -56,7 +57,7 @@
|
|||
"test:native:smoke": "node scripts/native/smoke.mjs",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
|
||||
"dev:daemon": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts daemon",
|
||||
"dev:daemon": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts daemon --foreground",
|
||||
"dev:daemon:restart": "node scripts/dev-daemon-restart.mjs",
|
||||
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
|
||||
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
|
||||
|
|
@ -81,10 +82,11 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@moonshot-ai/acp-adapter": "workspace:^",
|
||||
"@moonshot-ai/daemon": "workspace:^",
|
||||
"@moonshot-ai/kimi-code-oauth": "workspace:^",
|
||||
"@moonshot-ai/kimi-code-sdk": "workspace:^",
|
||||
"@moonshot-ai/daemon": "workspace:^",
|
||||
"@moonshot-ai/kimi-telemetry": "workspace:^",
|
||||
"@moonshot-ai/kimi-web": "workspace:^",
|
||||
"@moonshot-ai/migration-legacy": "workspace:^",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/yazl": "^2.4.6",
|
||||
|
|
@ -95,4 +97,4 @@
|
|||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
apps/kimi-code/scripts/copy-web-assets.mjs
Normal file
39
apps/kimi-code/scripts/copy-web-assets.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { cp, rm, stat } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const source = resolve(repoRoot, 'apps/kimi-web/dist');
|
||||
const target = resolve(appRoot, 'dist-web');
|
||||
const swaggerUiSource = resolve(
|
||||
dirname(require.resolve('@fastify/swagger-ui/package.json', {
|
||||
paths: [resolve(repoRoot, 'packages/daemon')],
|
||||
})),
|
||||
'static',
|
||||
);
|
||||
const swaggerUiTarget = resolve(appRoot, 'dist/static');
|
||||
|
||||
async function assertBuiltWeb() {
|
||||
try {
|
||||
const info = await stat(resolve(source, 'index.html'));
|
||||
if (!info.isFile()) {
|
||||
throw new Error('index.html is not a file');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await assertBuiltWeb();
|
||||
await rm(target, { recursive: true, force: true });
|
||||
await cp(source, target, { recursive: true });
|
||||
await rm(swaggerUiTarget, { recursive: true, force: true });
|
||||
await cp(swaggerUiSource, swaggerUiTarget, { recursive: true });
|
||||
|
||||
console.log(`Copied Kimi web assets to ${target}`);
|
||||
console.log(`Copied Swagger UI assets to ${swaggerUiTarget}`);
|
||||
|
|
@ -7,6 +7,8 @@ import { promisify } from 'node:util';
|
|||
const execFileAsync = promisify(execFile);
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const bundlePath = resolve(appRoot, 'dist', 'main.mjs');
|
||||
const swaggerUiLogoPath = resolve(appRoot, 'dist', 'static', 'logo.svg');
|
||||
const webIndexPath = resolve(appRoot, 'dist-web', 'index.html');
|
||||
const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8'));
|
||||
const expectedVersion = packageJson.version;
|
||||
|
||||
|
|
@ -23,6 +25,16 @@ async function ensureBundleExists() {
|
|||
}
|
||||
}
|
||||
|
||||
async function ensureRuntimeAssetsExist() {
|
||||
for (const filePath of [swaggerUiLogoPath, webIndexPath]) {
|
||||
try {
|
||||
await stat(filePath);
|
||||
} catch {
|
||||
fail(`Runtime asset not found at ${filePath}. Run \`pnpm build\` first.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runBundle(args) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], {
|
||||
|
|
@ -45,6 +57,7 @@ function assertIncludes(output, expected, command) {
|
|||
}
|
||||
|
||||
await ensureBundleExists();
|
||||
await ensureRuntimeAssetsExist();
|
||||
|
||||
const versionOutput = await runBundle(['--version']);
|
||||
assertIncludes(versionOutput, expectedVersion, '--version');
|
||||
|
|
@ -55,4 +68,7 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help');
|
|||
const exportHelpOutput = await runBundle(['export', '--help']);
|
||||
assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help');
|
||||
|
||||
const webHelpOutput = await runBundle(['web', '--help']);
|
||||
assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help');
|
||||
|
||||
console.log(`Bundle smoke passed: ${bundlePath}`);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { registerDaemonCommand } from './sub/daemon';
|
|||
import { registerExportCommand } from './sub/export';
|
||||
import { registerLoginCommand } from './sub/login';
|
||||
import { registerProviderCommand } from './sub/provider';
|
||||
import { registerWebCommand } from './sub/web';
|
||||
|
||||
export type MainCommandHandler = (opts: CLIOptions) => void;
|
||||
export type MigrateCommandHandler = () => void;
|
||||
|
|
@ -80,6 +81,7 @@ export function createProgram(
|
|||
registerProviderCommand(program);
|
||||
registerAcpCommand(program);
|
||||
registerDaemonCommand(program);
|
||||
registerWebCommand(program);
|
||||
registerLoginCommand(program);
|
||||
registerDoctorCommand(program);
|
||||
registerMigrateCommand(program, onMigrate);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
/**
|
||||
* `kimi daemon` sub-command.
|
||||
*
|
||||
* Boots the local REST + WebSocket daemon. Walking-skeleton scope: only
|
||||
* `GET /v1/healthz`. SDK / WS / DI wiring lives in later commits.
|
||||
* Default mode starts the daemon as a detached background process and exits
|
||||
* after the daemon becomes healthy. `--foreground` keeps the daemon attached
|
||||
* to the current terminal.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { startDaemon, type DaemonLogLevel } from '@moonshot-ai/daemon';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { createKimiCodeHostIdentity, getVersion } from '../version';
|
||||
import {
|
||||
DaemonLockedError,
|
||||
DEFAULT_LOCK_PATH,
|
||||
startDaemon,
|
||||
type DaemonLogLevel,
|
||||
} from '@moonshot-ai/daemon';
|
||||
import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
const DEFAULT_PORT = 7878;
|
||||
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../version';
|
||||
|
||||
export const DEFAULT_DAEMON_HOST = '127.0.0.1';
|
||||
export const DEFAULT_DAEMON_PORT = 7878;
|
||||
export const DEFAULT_DAEMON_ORIGIN = daemonOrigin(DEFAULT_DAEMON_HOST, DEFAULT_DAEMON_PORT);
|
||||
const DEFAULT_LOG_LEVEL: DaemonLogLevel = 'info';
|
||||
const WEB_ASSETS_DIR = 'dist-web';
|
||||
const VALID_LOG_LEVELS: readonly DaemonLogLevel[] = [
|
||||
'fatal',
|
||||
'error',
|
||||
|
|
@ -24,80 +37,369 @@ const VALID_LOG_LEVELS: readonly DaemonLogLevel[] = [
|
|||
'silent',
|
||||
];
|
||||
|
||||
interface DaemonCliOptions {
|
||||
export interface DaemonCliOptions {
|
||||
host?: string;
|
||||
port?: string;
|
||||
logLevel?: string;
|
||||
debugEndpoints?: boolean;
|
||||
foreground?: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedDaemonOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
logLevel: DaemonLogLevel;
|
||||
debugEndpoints: boolean;
|
||||
}
|
||||
|
||||
export type EnsureDaemonResult =
|
||||
| {
|
||||
status: 'already-running';
|
||||
origin: string;
|
||||
pid?: number;
|
||||
logPath?: string;
|
||||
}
|
||||
| {
|
||||
status: 'started';
|
||||
origin: string;
|
||||
pid: number;
|
||||
logPath: string;
|
||||
};
|
||||
|
||||
export interface EnsureDaemonRunningDeps {
|
||||
isDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean>;
|
||||
waitForDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean>;
|
||||
readLiveDaemonLock():
|
||||
| {
|
||||
pid: number;
|
||||
started_at: string;
|
||||
port: number;
|
||||
}
|
||||
| undefined;
|
||||
startDaemonBackground(options: ParsedDaemonOptions): { pid: number; logPath: string };
|
||||
daemonLogPath(): string;
|
||||
}
|
||||
|
||||
export interface DaemonCommandDeps {
|
||||
ensureDaemonRunning(options: ParsedDaemonOptions): Promise<EnsureDaemonResult>;
|
||||
startDaemonForeground(options: ParsedDaemonOptions): Promise<void>;
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
export function registerDaemonCommand(parent: Command): void {
|
||||
parent
|
||||
.command('daemon')
|
||||
.description('Run the local kimi-code daemon (REST + WebSocket).')
|
||||
.option('--host <host>', `Bind host (default ${DEFAULT_HOST})`, DEFAULT_HOST)
|
||||
.option('--port <port>', `Bind port (default ${DEFAULT_PORT})`, String(DEFAULT_PORT))
|
||||
.option(
|
||||
'--host <host>',
|
||||
`Bind host for the daemon (default ${DEFAULT_DAEMON_HOST})`,
|
||||
DEFAULT_DAEMON_HOST,
|
||||
)
|
||||
.option(
|
||||
'--port <port>',
|
||||
`Bind port for the daemon (default ${DEFAULT_DAEMON_PORT})`,
|
||||
String(DEFAULT_DAEMON_PORT),
|
||||
)
|
||||
.option(
|
||||
'--log-level <level>',
|
||||
`Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`,
|
||||
DEFAULT_LOG_LEVEL,
|
||||
)
|
||||
.option(
|
||||
'--foreground',
|
||||
'Run the daemon in the foreground instead of starting a background daemon.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--debug-endpoints',
|
||||
'Mount /api/v1/debug/* routes for test introspection (per-session shadow + dispatch log). OFF by default; production callers leave this unset.',
|
||||
false,
|
||||
)
|
||||
.action(async (opts: DaemonCliOptions) => {
|
||||
const host = opts.host ?? DEFAULT_HOST;
|
||||
const port = parsePort(opts.port);
|
||||
const logLevel = parseLogLevel(opts.logLevel);
|
||||
|
||||
const version = getVersion();
|
||||
const running = await startDaemon({
|
||||
host,
|
||||
port,
|
||||
logLevel,
|
||||
debugEndpoints: opts.debugEndpoints === true,
|
||||
coreProcessOptions: {
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
},
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
running.logger.info({ signal }, 'daemon shutting down');
|
||||
try {
|
||||
await running.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
running.logger.error(
|
||||
{ err: error instanceof Error ? error : new Error(String(error)) },
|
||||
'daemon shutdown error',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
try {
|
||||
await handleDaemonCommand(opts);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parsePort(raw: string | undefined): number {
|
||||
if (raw === undefined) return DEFAULT_PORT;
|
||||
export async function handleDaemonCommand(
|
||||
opts: DaemonCliOptions,
|
||||
deps: DaemonCommandDeps = DEFAULT_DAEMON_COMMAND_DEPS,
|
||||
): Promise<void> {
|
||||
const parsed = parseDaemonOptions(opts);
|
||||
if (opts.foreground === true) {
|
||||
try {
|
||||
await deps.startDaemonForeground(parsed);
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonLockedError) {
|
||||
deps.stdout.write(formatAlreadyRunning(error.existing.port, error.existing.pid));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deps.ensureDaemonRunning(parsed);
|
||||
if (result.status === 'already-running') {
|
||||
deps.stdout.write(
|
||||
`Kimi daemon already running at ${result.origin}${formatPid(result.pid)}.\n`,
|
||||
);
|
||||
if (result.logPath !== undefined) {
|
||||
deps.stdout.write(`Logs: ${result.logPath}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deps.stdout.write(
|
||||
`Kimi daemon started in background at ${result.origin} (pid ${result.pid}).\n`,
|
||||
);
|
||||
deps.stdout.write(`Logs: ${result.logPath}\n`);
|
||||
}
|
||||
|
||||
export function parseDaemonOptions(opts: DaemonCliOptions): ParsedDaemonOptions {
|
||||
return {
|
||||
host: opts.host ?? DEFAULT_DAEMON_HOST,
|
||||
port: parsePort(opts.port, '--port', DEFAULT_DAEMON_PORT),
|
||||
logLevel: parseLogLevel(opts.logLevel),
|
||||
debugEndpoints: opts.debugEndpoints === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePort(raw: string | undefined, label: string, fallback: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 65535) {
|
||||
process.stderr.write(`error: invalid --port value: ${raw}\n`);
|
||||
process.exit(1);
|
||||
throw new Error(`error: invalid ${label} value: ${raw}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseLogLevel(raw: string | undefined): DaemonLogLevel {
|
||||
export function parseLogLevel(raw: string | undefined): DaemonLogLevel {
|
||||
if (raw === undefined) return DEFAULT_LOG_LEVEL;
|
||||
if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) {
|
||||
return raw as DaemonLogLevel;
|
||||
}
|
||||
process.stderr.write(
|
||||
`error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})\n`,
|
||||
throw new Error(
|
||||
`error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export async function ensureDaemonRunning(
|
||||
options: ParsedDaemonOptions,
|
||||
deps: EnsureDaemonRunningDeps = DEFAULT_ENSURE_DAEMON_RUNNING_DEPS,
|
||||
): Promise<EnsureDaemonResult> {
|
||||
const origin = daemonOrigin(options.host, options.port);
|
||||
if (await deps.isDaemonHealthy(origin, 1000)) {
|
||||
const lock = deps.readLiveDaemonLock();
|
||||
return {
|
||||
status: 'already-running',
|
||||
origin,
|
||||
pid: lock?.pid,
|
||||
logPath: deps.daemonLogPath(),
|
||||
};
|
||||
}
|
||||
|
||||
const lock = deps.readLiveDaemonLock();
|
||||
if (lock !== undefined) {
|
||||
const lockOrigin = daemonOrigin(options.host, lock.port);
|
||||
if (await deps.waitForDaemonHealthy(lockOrigin, 5000)) {
|
||||
return {
|
||||
status: 'already-running',
|
||||
origin: lockOrigin,
|
||||
pid: lock.pid,
|
||||
logPath: deps.daemonLogPath(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const started = deps.startDaemonBackground(options);
|
||||
const ready = await deps.waitForDaemonHealthy(origin, 15_000);
|
||||
if (!ready) {
|
||||
throw new Error(
|
||||
`Kimi daemon did not become healthy at ${origin}. Check logs: ${started.logPath}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 'started',
|
||||
origin,
|
||||
pid: started.pid,
|
||||
logPath: started.logPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startDaemonForeground(options: ParsedDaemonOptions): Promise<void> {
|
||||
const version = getVersion();
|
||||
const running = await startDaemon({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
logLevel: options.logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
webAssetsDir: daemonWebAssetsDir(),
|
||||
coreProcessOptions: {
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
},
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
running.logger.info({ signal }, 'daemon shutting down');
|
||||
try {
|
||||
await running.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
running.logger.error(
|
||||
{ err: error instanceof Error ? error : new Error(String(error)) },
|
||||
'daemon shutdown error',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
const handleSignal = (signal: NodeJS.Signals): void => {
|
||||
void shutdown(signal);
|
||||
};
|
||||
process.once('SIGINT', handleSignal);
|
||||
process.once('SIGTERM', handleSignal);
|
||||
}
|
||||
|
||||
export function daemonOrigin(host: string, port: number): string {
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function startDaemonBackground(options: ParsedDaemonOptions): { pid: number; logPath: string } {
|
||||
const entry = process.argv[1];
|
||||
if (entry === undefined || entry.length === 0) {
|
||||
throw new Error('Cannot start daemon: current CLI entry path is unavailable.');
|
||||
}
|
||||
|
||||
const logPath = daemonLogPath();
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
const logFd = openSync(logPath, 'a');
|
||||
const args = [
|
||||
...process.execArgv,
|
||||
entry,
|
||||
'daemon',
|
||||
'--foreground',
|
||||
'--host',
|
||||
options.host,
|
||||
'--port',
|
||||
String(options.port),
|
||||
'--log-level',
|
||||
options.logLevel,
|
||||
];
|
||||
if (options.debugEndpoints) {
|
||||
args.push('--debug-endpoints');
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
return { pid: child.pid ?? -1, logPath };
|
||||
} finally {
|
||||
closeSync(logFd);
|
||||
}
|
||||
}
|
||||
|
||||
function daemonLogPath(): string {
|
||||
return join(resolveKimiHome(), 'daemon', 'daemon.log');
|
||||
}
|
||||
|
||||
function daemonWebAssetsDir(): string {
|
||||
return join(getHostPackageRoot(), WEB_ASSETS_DIR);
|
||||
}
|
||||
|
||||
async function waitForDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
do {
|
||||
if (await isDaemonHealthy(origin, 500)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
} while (Date.now() < deadline);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${origin}/api/v1/healthz`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const body = (await response.json()) as { code?: unknown };
|
||||
return body.code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
interface DaemonLockContents {
|
||||
pid: number;
|
||||
started_at: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
function readLiveDaemonLock(): DaemonLockContents | undefined {
|
||||
if (!existsSync(DEFAULT_LOCK_PATH)) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(DEFAULT_LOCK_PATH, 'utf-8')) as unknown;
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as DaemonLockContents).pid !== 'number' ||
|
||||
typeof (parsed as DaemonLockContents).port !== 'number' ||
|
||||
typeof (parsed as DaemonLockContents).started_at !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const lock = parsed as DaemonLockContents;
|
||||
return pidAlive(lock.pid) ? lock : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAlreadyRunning(port: number, pid: number): string {
|
||||
return `Kimi daemon already running at ${daemonOrigin(DEFAULT_DAEMON_HOST, port)} (pid ${pid}).\n`;
|
||||
}
|
||||
|
||||
function formatPid(pid: number | undefined): string {
|
||||
return pid === undefined ? '' : ` (pid ${pid})`;
|
||||
}
|
||||
|
||||
const DEFAULT_DAEMON_COMMAND_DEPS: DaemonCommandDeps = {
|
||||
ensureDaemonRunning,
|
||||
startDaemonForeground,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
|
||||
const DEFAULT_ENSURE_DAEMON_RUNNING_DEPS: EnsureDaemonRunningDeps = {
|
||||
isDaemonHealthy,
|
||||
waitForDaemonHealthy,
|
||||
readLiveDaemonLock,
|
||||
startDaemonBackground,
|
||||
daemonLogPath,
|
||||
};
|
||||
|
|
|
|||
142
apps/kimi-code/src/cli/sub/web.ts
Normal file
142
apps/kimi-code/src/cli/sub/web.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* `kimi web` sub-command.
|
||||
*
|
||||
* Opens the daemon-hosted Kimi Code web UI. By default it ensures the local
|
||||
* daemon is running in the background first. When `--daemon-host` is provided,
|
||||
* that daemon is treated as the complete web/API host and is opened directly.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
|
||||
|
||||
import {
|
||||
DEFAULT_DAEMON_HOST,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
DEFAULT_DAEMON_PORT,
|
||||
ensureDaemonRunning,
|
||||
parsePort,
|
||||
type EnsureDaemonResult,
|
||||
} from './daemon';
|
||||
|
||||
export interface WebCliOptions {
|
||||
host?: string;
|
||||
port?: string;
|
||||
daemonHost?: string;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export interface WebCommandDeps {
|
||||
ensureDaemonRunning(options: {
|
||||
host: string;
|
||||
port: number;
|
||||
logLevel: 'info';
|
||||
debugEndpoints: false;
|
||||
}): Promise<EnsureDaemonResult>;
|
||||
ensureDaemonWebReady(origin: string): Promise<void>;
|
||||
openUrl(url: string): void;
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
export function registerWebCommand(parent: Command): void {
|
||||
parent
|
||||
.command('web')
|
||||
.description('Open the daemon-hosted Kimi Code web UI.')
|
||||
.option(
|
||||
'--host <host>',
|
||||
`Bind host when starting the local daemon (default ${DEFAULT_DAEMON_HOST})`,
|
||||
)
|
||||
.option(
|
||||
'--port <port>',
|
||||
`Port when starting the local daemon (default ${DEFAULT_DAEMON_PORT})`,
|
||||
)
|
||||
.option(
|
||||
'--daemon-host <url>',
|
||||
`Daemon URL to open instead of starting the local daemon (default ${DEFAULT_DAEMON_ORIGIN})`,
|
||||
)
|
||||
.option('--no-open', 'Do not open the web UI in the default browser.')
|
||||
.action(async (opts: WebCliOptions) => {
|
||||
try {
|
||||
await handleWebCommand(opts);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleWebCommand(
|
||||
opts: WebCliOptions,
|
||||
deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS,
|
||||
): Promise<void> {
|
||||
let webOrigin: string;
|
||||
if (opts.daemonHost !== undefined) {
|
||||
webOrigin = normalizeDaemonOrigin(opts.daemonHost);
|
||||
} else {
|
||||
const host = opts.host ?? DEFAULT_DAEMON_HOST;
|
||||
const port = parsePort(opts.port, '--port', DEFAULT_DAEMON_PORT);
|
||||
const daemon = await deps.ensureDaemonRunning({
|
||||
host,
|
||||
port,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
});
|
||||
webOrigin = daemon.origin;
|
||||
if (daemon.status === 'started') {
|
||||
deps.stdout.write(
|
||||
`Kimi daemon started in background at ${daemon.origin} (pid ${daemon.pid}).\n`,
|
||||
);
|
||||
deps.stdout.write(`Logs: ${daemon.logPath}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await deps.ensureDaemonWebReady(webOrigin);
|
||||
deps.stdout.write(`Kimi web: ${webOrigin}\n`);
|
||||
|
||||
if (opts.open !== false) {
|
||||
deps.openUrl(webOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDaemonOrigin(value: string): string {
|
||||
const url = new URL(value);
|
||||
url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export async function ensureDaemonWebReady(origin: string): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${origin}/`, {
|
||||
headers: { accept: 'text/html' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.text();
|
||||
if (!body.includes('<div id="app"')) {
|
||||
throw new Error('missing app root');
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? ` (${error.message})` : '';
|
||||
throw new Error(
|
||||
`Daemon at ${origin} does not serve the Kimi web UI${reason}. Stop the existing daemon and rerun \`kimi web\`, or use --daemon-host to open a compatible daemon.`,
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_WEB_COMMAND_DEPS: WebCommandDeps = {
|
||||
ensureDaemonRunning,
|
||||
ensureDaemonWebReady,
|
||||
openUrl: defaultOpenUrl,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
139
apps/kimi-code/test/cli/daemon.test.ts
Normal file
139
apps/kimi-code/test/cli/daemon.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_DAEMON_HOST,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
DEFAULT_DAEMON_PORT,
|
||||
handleDaemonCommand,
|
||||
ensureDaemonRunning,
|
||||
registerDaemonCommand,
|
||||
type DaemonCommandDeps,
|
||||
type EnsureDaemonRunningDeps,
|
||||
} from '#/cli/sub/daemon';
|
||||
|
||||
function makeDeps(overrides: Partial<DaemonCommandDeps> = {}): {
|
||||
deps: DaemonCommandDeps;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
} {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
return {
|
||||
deps: {
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'started' as const,
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 1234,
|
||||
logPath: '/tmp/kimi-daemon.log',
|
||||
})),
|
||||
startDaemonForeground: vi.fn(async () => undefined),
|
||||
stdout: { write: (chunk) => stdout.push(String(chunk)) > 0 },
|
||||
stderr: { write: (chunk) => stderr.push(String(chunk)) > 0 },
|
||||
...overrides,
|
||||
},
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('kimi daemon', () => {
|
||||
it('registers daemon as a subcommand with a foreground option', () => {
|
||||
const program = new Command('kimi');
|
||||
registerDaemonCommand(program);
|
||||
|
||||
const daemon = program.commands.find((cmd) => cmd.name() === 'daemon');
|
||||
expect(daemon).toBeDefined();
|
||||
expect(daemon?.options.some((option) => option.long === '--foreground')).toBe(true);
|
||||
});
|
||||
|
||||
it('starts the daemon in the background by default', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps();
|
||||
|
||||
await handleDaemonCommand({}, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith({
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
});
|
||||
expect(deps.startDaemonForeground).not.toHaveBeenCalled();
|
||||
expect(stderr.join('')).toBe('');
|
||||
expect(stdout.join('')).toContain('Kimi daemon started in background');
|
||||
expect(stdout.join('')).toContain('/tmp/kimi-daemon.log');
|
||||
});
|
||||
|
||||
it('does not start another background daemon when one is already running', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps({
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'already-running' as const,
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 4321,
|
||||
})),
|
||||
});
|
||||
|
||||
await handleDaemonCommand({}, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledTimes(1);
|
||||
expect(deps.startDaemonForeground).not.toHaveBeenCalled();
|
||||
expect(stderr.join('')).toBe('');
|
||||
expect(stdout.join('')).toContain('Kimi daemon already running');
|
||||
expect(stdout.join('')).toContain('4321');
|
||||
});
|
||||
|
||||
it('keeps the daemon in the foreground when --foreground is set', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps();
|
||||
|
||||
await handleDaemonCommand({ foreground: true, port: '7879', logLevel: 'debug' }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).not.toHaveBeenCalled();
|
||||
expect(deps.startDaemonForeground).toHaveBeenCalledWith({
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: 7879,
|
||||
logLevel: 'debug',
|
||||
debugEndpoints: false,
|
||||
});
|
||||
expect(stdout.join('')).toBe('');
|
||||
expect(stderr.join('')).toBe('');
|
||||
});
|
||||
|
||||
it('does not treat a live lock as running until health check succeeds', async () => {
|
||||
const parsedOptions = {
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info' as const,
|
||||
debugEndpoints: false,
|
||||
};
|
||||
const deps: EnsureDaemonRunningDeps = {
|
||||
isDaemonHealthy: vi.fn(async () => false),
|
||||
waitForDaemonHealthy: vi.fn(async () => false),
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: 8787,
|
||||
})),
|
||||
startDaemonBackground: vi.fn(() => ({
|
||||
pid: 9876,
|
||||
logPath: '/tmp/kimi-daemon.log',
|
||||
})),
|
||||
daemonLogPath: vi.fn(() => '/tmp/kimi-daemon.log'),
|
||||
};
|
||||
|
||||
await expect(ensureDaemonRunning(parsedOptions, deps)).rejects.toThrow(
|
||||
'Kimi daemon did not become healthy',
|
||||
);
|
||||
|
||||
expect(deps.waitForDaemonHealthy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://127.0.0.1:8787',
|
||||
5000,
|
||||
);
|
||||
expect(deps.startDaemonBackground).toHaveBeenCalledWith(parsedOptions);
|
||||
expect(deps.waitForDaemonHealthy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
15_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -290,6 +290,7 @@ describe('CLI options parsing', () => {
|
|||
'provider',
|
||||
'acp',
|
||||
'daemon',
|
||||
'web',
|
||||
'login',
|
||||
'doctor',
|
||||
'migrate',
|
||||
|
|
|
|||
122
apps/kimi-code/test/cli/web.test.ts
Normal file
122
apps/kimi-code/test/cli/web.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_DAEMON_HOST,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
DEFAULT_DAEMON_PORT,
|
||||
} from '#/cli/sub/daemon';
|
||||
import {
|
||||
handleWebCommand,
|
||||
registerWebCommand,
|
||||
type WebCommandDeps,
|
||||
} from '#/cli/sub/web';
|
||||
|
||||
function makeDeps(overrides: Partial<WebCommandDeps> = {}): {
|
||||
deps: WebCommandDeps;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
} {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
return {
|
||||
deps: {
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'already-running' as const,
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 1234,
|
||||
})),
|
||||
ensureDaemonWebReady: vi.fn(async () => undefined),
|
||||
openUrl: vi.fn(),
|
||||
stdout: { write: (chunk) => stdout.push(String(chunk)) > 0 },
|
||||
stderr: { write: (chunk) => stderr.push(String(chunk)) > 0 },
|
||||
...overrides,
|
||||
},
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('kimi web', () => {
|
||||
it('registers web as a subcommand', () => {
|
||||
const program = new Command('kimi');
|
||||
registerWebCommand(program);
|
||||
|
||||
const web = program.commands.find((cmd) => cmd.name() === 'web');
|
||||
expect(web).toBeDefined();
|
||||
expect(web?.options.some((option) => option.long === '--daemon-host')).toBe(true);
|
||||
});
|
||||
|
||||
it('ensures the local daemon and opens the daemon-hosted web UI by default', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps();
|
||||
|
||||
await handleWebCommand({}, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith({
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
});
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith(DEFAULT_DAEMON_ORIGIN);
|
||||
expect(deps.openUrl).toHaveBeenCalledWith(DEFAULT_DAEMON_ORIGIN);
|
||||
expect(stderr.join('')).toBe('');
|
||||
expect(stdout.join('')).toContain(`Kimi web: ${DEFAULT_DAEMON_ORIGIN}`);
|
||||
});
|
||||
|
||||
it('opens the provided daemon host directly without starting local services', async () => {
|
||||
const { deps } = makeDeps();
|
||||
|
||||
await handleWebCommand({ daemonHost: 'http://192.168.97.91:7878' }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).not.toHaveBeenCalled();
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith('http://192.168.97.91:7878');
|
||||
expect(deps.openUrl).toHaveBeenCalledWith('http://192.168.97.91:7878');
|
||||
});
|
||||
|
||||
it('uses --port as the local daemon start port when --daemon-host is absent', async () => {
|
||||
const { deps } = makeDeps({
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'already-running' as const,
|
||||
origin: 'http://127.0.0.1:8899',
|
||||
pid: 1234,
|
||||
})),
|
||||
});
|
||||
|
||||
await handleWebCommand({ host: '0.0.0.0', port: '8899' }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith({
|
||||
host: '0.0.0.0',
|
||||
port: 8899,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
});
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith('http://127.0.0.1:8899');
|
||||
expect(deps.openUrl).toHaveBeenCalledWith('http://127.0.0.1:8899');
|
||||
});
|
||||
|
||||
it('respects --no-open while still printing the daemon-hosted web URL', async () => {
|
||||
const { deps, stdout } = makeDeps();
|
||||
|
||||
await handleWebCommand({ daemonHost: 'http://example.test:9000/api/v1/', open: false }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).not.toHaveBeenCalled();
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith('http://example.test:9000');
|
||||
expect(deps.openUrl).not.toHaveBeenCalled();
|
||||
expect(stdout.join('')).toContain('Kimi web: http://example.test:9000');
|
||||
});
|
||||
|
||||
it('does not open a daemon that does not serve the web UI', async () => {
|
||||
const { deps, stdout } = makeDeps({
|
||||
ensureDaemonWebReady: vi.fn(async () => {
|
||||
throw new Error('Daemon at http://127.0.0.1:7878 does not serve the Kimi web UI.');
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(handleWebCommand({}, deps)).rejects.toThrow('does not serve the Kimi web UI');
|
||||
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith(DEFAULT_DAEMON_ORIGIN);
|
||||
expect(deps.openUrl).not.toHaveBeenCalled();
|
||||
expect(stdout.join('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -395,14 +395,6 @@ function delay(ms) {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function scheduleSteps(steps) {
|
||||
let p = Promise.resolve();
|
||||
for (const [ms, fn] of steps) {
|
||||
p = p.then(() => delay(ms)).then(() => fn());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
async function streamMarkdown(sessionId, msgId, contentIndex, text, chunkSize = 40) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < text.length; i += chunkSize) {
|
||||
|
|
@ -1478,7 +1470,6 @@ const server = http.createServer((req, res) => {
|
|||
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
|
||||
const b = json();
|
||||
const reqPath = b.path || 'README.md';
|
||||
const modifiedNow = now();
|
||||
|
||||
// Tiny 1×1 transparent PNG (base64)
|
||||
const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
|
@ -1702,7 +1693,6 @@ const server = http.createServer((req, res) => {
|
|||
if (stripped === '/files' && method === 'POST') {
|
||||
const fileId = ulid('file_');
|
||||
// Try to extract filename from Content-Disposition if possible; fall back to generic name.
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const nameMatch = body.match(/filename="([^"]+)"/);
|
||||
const fileName = nameMatch ? nameMatch[1] : 'upload.png';
|
||||
const fileMeta = {
|
||||
|
|
|
|||
|
|
@ -613,14 +613,26 @@ export function createAgentProjector(): AgentProjector {
|
|||
const info = (p?.info ?? {}) as Record<string, unknown>;
|
||||
const startedAt =
|
||||
typeof info.startedAt === 'number' ? new Date(info.startedAt).toISOString() : undefined;
|
||||
const taskId =
|
||||
typeof info.taskId === 'string'
|
||||
? info.taskId
|
||||
: typeof info.taskId === 'number'
|
||||
? String(info.taskId)
|
||||
: ulid('task_');
|
||||
const description =
|
||||
typeof info.description === 'string'
|
||||
? info.description
|
||||
: typeof info.command === 'string'
|
||||
? info.command
|
||||
: i18n.global.t('tasks.defaultDescription');
|
||||
out.push({
|
||||
type: 'taskCreated',
|
||||
sessionId,
|
||||
task: {
|
||||
id: String(info.taskId ?? ulid('task_')),
|
||||
id: taskId,
|
||||
sessionId,
|
||||
kind: 'bash',
|
||||
description: String(info.description ?? info.command ?? i18n.global.t('tasks.defaultDescription')),
|
||||
description,
|
||||
status: 'running',
|
||||
createdAt: startedAt ?? new Date().toISOString(),
|
||||
startedAt,
|
||||
|
|
@ -637,7 +649,12 @@ export function createAgentProjector(): AgentProjector {
|
|||
out.push({
|
||||
type: 'taskCompleted',
|
||||
sessionId,
|
||||
taskId: String(info.taskId ?? ''),
|
||||
taskId:
|
||||
typeof info.taskId === 'string'
|
||||
? info.taskId
|
||||
: typeof info.taskId === 'number'
|
||||
? String(info.taskId)
|
||||
: '',
|
||||
status: failed ? 'failed' : 'completed',
|
||||
outputPreview: typeof info.command === 'string' ? `$ ${info.command}` : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ function diffLines(code: string): { cls: string; text: string }[] {
|
|||
return code.split('\n').map((line) => {
|
||||
if (/^\+(?!\+\+)/.test(line)) return { cls: 'diff-add', text: line };
|
||||
if (/^-(?!--)/.test(line)) return { cls: 'diff-del', text: line };
|
||||
if (/^@@/.test(line)) return { cls: 'diff-hunk', text: line };
|
||||
if (line.startsWith('@@')) return { cls: 'diff-hunk', text: line };
|
||||
return { cls: 'diff-ctx', text: line };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type { ApprovalBlock, ChatTurn, DiffLine, ToolCall, TurnBlock } from '../
|
|||
* of dumping raw `[{"type":"text",...}]` JSON into the UI.
|
||||
*/
|
||||
function normalizeToolOutput(output: unknown): string[] | undefined {
|
||||
if (output == null) return undefined;
|
||||
if (output === null || output === undefined) return undefined;
|
||||
if (typeof output === 'string') return output.split('\n');
|
||||
if (Array.isArray(output)) {
|
||||
const lines: string[] = [];
|
||||
|
|
@ -137,7 +137,7 @@ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock {
|
|||
const items = rawItems.map((item: unknown) => {
|
||||
const it = (item ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
title: typeof it['title'] === 'string' ? it['title'] : String(it['title'] ?? ''),
|
||||
title: typeof it['title'] === 'string' ? it['title'] : '',
|
||||
status: typeof it['status'] === 'string' ? it['status'] : 'pending',
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -678,7 +678,7 @@ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock {
|
|||
const items = rawItems.map((item: unknown) => {
|
||||
const it = (item ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
title: typeof it.title === 'string' ? it.title : String(it.title ?? ''),
|
||||
title: typeof it.title === 'string' ? it.title : '',
|
||||
status: typeof it.status === 'string' ? it.status : 'pending',
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export interface UseResizable {
|
|||
function readStored(key: string): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw == null) return null;
|
||||
if (raw === null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ In `stream-json` mode, regular replies produce an Assistant message; when the mo
|
|||
|
||||
## Subcommands
|
||||
|
||||
`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers).
|
||||
`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `daemon` (local REST and WebSocket service), `web` (local browser UI), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers).
|
||||
|
||||
### `kimi login`
|
||||
|
||||
|
|
@ -141,6 +141,48 @@ Switch Kimi Code CLI to ACP (Agent Client Protocol) mode, communicating with an
|
|||
kimi acp
|
||||
```
|
||||
|
||||
### `kimi daemon`
|
||||
|
||||
Run the local daemon that exposes Kimi Code over REST and WebSocket, and serves the web UI from the same origin. By default, the command starts the daemon in the background, waits until it is healthy, prints the endpoint and log path, then exits. If a daemon is already running, the command reports the existing process instead of starting another one.
|
||||
|
||||
```sh
|
||||
kimi daemon
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--host <host>` | Bind host for the daemon; defaults to `127.0.0.1` |
|
||||
| `--port <port>` | Bind port for the daemon; defaults to `7878` |
|
||||
| `--log-level <level>` | Daemon log level; defaults to `info` |
|
||||
| `--foreground` | Keep the daemon attached to the current terminal instead of starting it in the background |
|
||||
|
||||
Use foreground mode when you want logs in the current terminal or when another process manager owns the daemon lifecycle:
|
||||
|
||||
```sh
|
||||
kimi daemon --foreground
|
||||
```
|
||||
|
||||
### `kimi web`
|
||||
|
||||
Open the daemon-hosted browser UI. Without `--daemon-host`, `kimi web` checks the local daemon at `http://127.0.0.1:7878`; if it is not running, it starts `kimi daemon` in the background first, then opens the daemon URL. The web assets and `/api/v1/*` routes are served by the daemon from the same origin.
|
||||
|
||||
```sh
|
||||
kimi web
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--host <host>` | Bind host when starting the local daemon; defaults to `127.0.0.1` |
|
||||
| `--port <port>` | Port when starting the local daemon; defaults to `7878` |
|
||||
| `--daemon-host <url>` | Open an existing daemon directly instead of starting the local daemon |
|
||||
| `--no-open` | Do not open the browser automatically |
|
||||
|
||||
Point the web UI at an existing daemon when the daemon runs on another machine or is managed by a separate process. In this mode, `kimi web` does not start any local service:
|
||||
|
||||
```sh
|
||||
kimi web --daemon-host=http://daemon.example.test:7878
|
||||
```
|
||||
|
||||
### `kimi doctor`
|
||||
|
||||
Validate `config.toml` and `tui.toml` without starting the TUI or modifying either file. By default, the command checks the files under `KIMI_CODE_HOME` (or `~/.kimi-code` when the environment variable is unset). Missing default files are reported as skipped because built-in defaults can apply.
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ kimi -p "List changed files" --output-format stream-json
|
|||
|
||||
## 子命令
|
||||
|
||||
`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。
|
||||
`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`daemon`(本地 REST 与 WebSocket 服务)、`web`(本地浏览器界面)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。
|
||||
|
||||
### `kimi login`
|
||||
|
||||
|
|
@ -141,6 +141,48 @@ kimi login
|
|||
kimi acp
|
||||
```
|
||||
|
||||
### `kimi daemon`
|
||||
|
||||
运行本地 daemon,通过 REST 与 WebSocket 暴露 Kimi Code 能力,并从同一 origin 托管 web UI。默认情况下,该命令会在后台启动 daemon,等待健康检查通过,打印访问端点和日志路径后退出。如果 daemon 已经在运行,命令会报告现有进程,而不是重复启动。
|
||||
|
||||
```sh
|
||||
kimi daemon
|
||||
```
|
||||
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| `--host <host>` | daemon 绑定地址,默认 `127.0.0.1` |
|
||||
| `--port <port>` | daemon 绑定端口,默认 `7878` |
|
||||
| `--log-level <level>` | daemon 日志级别,默认 `info` |
|
||||
| `--foreground` | 保持 daemon 运行在当前终端中,而不是后台启动 |
|
||||
|
||||
需要在当前终端查看日志,或由其他进程管理 daemon 生命周期时,使用前台模式:
|
||||
|
||||
```sh
|
||||
kimi daemon --foreground
|
||||
```
|
||||
|
||||
### `kimi web`
|
||||
|
||||
打开由 daemon 托管的浏览器界面。不传 `--daemon-host` 时,`kimi web` 会先检查本地 daemon `http://127.0.0.1:7878`;如果尚未运行,则先在后台启动 `kimi daemon`,然后打开 daemon URL。web 资源和 `/api/v1/*` 路由由 daemon 在同一 origin 上提供。
|
||||
|
||||
```sh
|
||||
kimi web
|
||||
```
|
||||
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| `--host <host>` | 启动本地 daemon 时使用的绑定地址,默认 `127.0.0.1` |
|
||||
| `--port <port>` | 启动本地 daemon 时使用的端口,默认 `7878` |
|
||||
| `--daemon-host <url>` | 直接打开已有 daemon,而不是启动本地 daemon |
|
||||
| `--no-open` | 不自动打开浏览器 |
|
||||
|
||||
当 daemon 运行在另一台机器上,或由单独的进程管理时,让 web UI 指向已有 daemon。此模式不会启动任何本地服务:
|
||||
|
||||
```sh
|
||||
kimi web --daemon-host=http://daemon.example.test:7878
|
||||
```
|
||||
|
||||
### `kimi doctor`
|
||||
|
||||
校验 `config.toml` 和 `tui.toml`,不会启动 TUI,也不会修改任一文件。默认检查 `KIMI_CODE_HOME` 下的文件;未设置该环境变量时检查 `~/.kimi-code`。默认路径缺失时会显示为跳过,因为内置默认值仍可生效。
|
||||
|
|
|
|||
|
|
@ -64,14 +64,17 @@
|
|||
workspacePaths = [
|
||||
./packages/acp-adapter
|
||||
./packages/agent-core
|
||||
./packages/daemon
|
||||
./packages/kaos
|
||||
./packages/kosong
|
||||
./packages/migration-legacy
|
||||
./packages/node-sdk
|
||||
./packages/oauth
|
||||
./packages/protocol
|
||||
./packages/services
|
||||
./packages/telemetry
|
||||
./apps/kimi-code
|
||||
./apps/kimi-web
|
||||
./apps/vis
|
||||
./apps/vis/server
|
||||
./apps/vis/web
|
||||
|
|
@ -81,14 +84,17 @@
|
|||
workspaceNames = [
|
||||
"@moonshot-ai/acp-adapter"
|
||||
"@moonshot-ai/agent-core"
|
||||
"@moonshot-ai/daemon"
|
||||
"@moonshot-ai/kaos"
|
||||
"@moonshot-ai/kosong"
|
||||
"@moonshot-ai/migration-legacy"
|
||||
"@moonshot-ai/kimi-code-sdk"
|
||||
"@moonshot-ai/kimi-code-oauth"
|
||||
"@moonshot-ai/protocol"
|
||||
"@moonshot-ai/services"
|
||||
"@moonshot-ai/kimi-telemetry"
|
||||
"@moonshot-ai/kimi-code"
|
||||
"@moonshot-ai/kimi-web"
|
||||
"@moonshot-ai/vis"
|
||||
"@moonshot-ai/vis-server"
|
||||
"@moonshot-ai/vis-web"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function registerSingleton<T, Services extends BrandedService[]>(
|
|||
ctor: new (...services: Services) => T,
|
||||
instantiationType?: InstantiationType,
|
||||
): void;
|
||||
export function registerSingleton<T, Services extends BrandedService[]>(
|
||||
export function registerSingleton<T>(
|
||||
id: ServiceIdentifier<T>,
|
||||
descriptor: SyncDescriptor<any>,
|
||||
): void;
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import type { ServiceIdentifier } from './instantiation';
|
|||
|
||||
export class ServiceCollection {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly _entries = new Map<ServiceIdentifier<any>, SyncDescriptor<any> | any>();
|
||||
private readonly _entries = new Map<ServiceIdentifier<any>, unknown>();
|
||||
|
||||
constructor(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...entries: ReadonlyArray<readonly [ServiceIdentifier<any>, SyncDescriptor<any> | any]>
|
||||
...entries: ReadonlyArray<readonly [ServiceIdentifier<any>, unknown]>
|
||||
) {
|
||||
for (const [id, value] of entries) {
|
||||
this._entries.set(id, value);
|
||||
|
|
@ -31,7 +31,7 @@ export class ServiceCollection {
|
|||
): T | SyncDescriptor<T> | undefined {
|
||||
const prev = this._entries.get(id);
|
||||
this._entries.set(id, instanceOrDescriptor);
|
||||
return prev;
|
||||
return prev as T | SyncDescriptor<T> | undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -40,7 +40,7 @@ export class ServiceCollection {
|
|||
}
|
||||
|
||||
get<T>(id: ServiceIdentifier<T>): T | SyncDescriptor<T> | undefined {
|
||||
return this._entries.get(id);
|
||||
return this._entries.get(id) as T | SyncDescriptor<T> | undefined;
|
||||
}
|
||||
|
||||
/** Iterate all entries. Order is insertion-order (Map semantics). */
|
||||
|
|
@ -48,8 +48,7 @@ export class ServiceCollection {
|
|||
callback: (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
id: ServiceIdentifier<any>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: SyncDescriptor<any> | any,
|
||||
value: unknown,
|
||||
) => void,
|
||||
): void {
|
||||
this._entries.forEach((value, id) => callback(id, value));
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ export class GlobalIdleValue<T> {
|
|||
this._executor();
|
||||
}
|
||||
if (this._error) {
|
||||
throw this._error;
|
||||
if (this._error instanceof Error) {
|
||||
throw this._error;
|
||||
}
|
||||
throw new Error('Lazy value initialization failed');
|
||||
}
|
||||
return this._value!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ export function registerApprovalsRoutes(
|
|||
tags: ['approvals'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const { approval_id } = req.params;
|
||||
const body = req.body;
|
||||
|
||||
|
|
@ -175,10 +174,6 @@ export function registerApprovalsRoutes(
|
|||
resolved_at: new Date().toISOString(),
|
||||
};
|
||||
reply.send(okEnvelope(result, req.id));
|
||||
} catch (err) {
|
||||
// Unknown errors → 50001 via the global error handler.
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ interface FilesRouteHost {
|
|||
handler: (
|
||||
req: FastifyRequestLike,
|
||||
reply: FilesReply,
|
||||
) => Promise<unknown> | unknown,
|
||||
) => unknown,
|
||||
): unknown;
|
||||
get(
|
||||
path: string,
|
||||
|
|
@ -40,7 +40,7 @@ interface FilesRouteHost {
|
|||
handler: (
|
||||
req: FastifyRequestLike,
|
||||
reply: FilesReply,
|
||||
) => Promise<unknown> | unknown,
|
||||
) => unknown,
|
||||
): unknown;
|
||||
delete(
|
||||
path: string,
|
||||
|
|
@ -48,7 +48,7 @@ interface FilesRouteHost {
|
|||
handler: (
|
||||
req: FastifyRequestLike,
|
||||
reply: FilesReply,
|
||||
) => Promise<unknown> | unknown,
|
||||
) => unknown,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
|
|
@ -299,7 +299,7 @@ function readFieldNumber(field: unknown): number | undefined {
|
|||
}
|
||||
|
||||
function buildContentDisposition(name: string): string {
|
||||
if (/^[\w. \-()+\[\]]+$/.test(name)) {
|
||||
if (/^[\w. ()+[\]-]+$/.test(name)) {
|
||||
return `attachment; filename="${name}"`;
|
||||
}
|
||||
return 'attachment';
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ interface FsRouteHost {
|
|||
raw: { on(event: string, cb: () => void): unknown };
|
||||
},
|
||||
reply: FsDownloadReply,
|
||||
) => Promise<unknown> | unknown,
|
||||
) => unknown,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface ApiV1AppHost {
|
|||
register(
|
||||
plugin: (apiV1: ApiV1RouteHost) => Promise<void> | void,
|
||||
opts: { prefix: string },
|
||||
): Promise<unknown> | unknown;
|
||||
): unknown;
|
||||
}
|
||||
|
||||
interface ApiV1RouteHost {
|
||||
|
|
@ -33,7 +33,7 @@ interface ApiV1RouteHost {
|
|||
handler: (
|
||||
req: { id: string },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => Promise<unknown> | unknown,
|
||||
) => unknown,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
131
packages/daemon/src/routes/webAssets.ts
Normal file
131
packages/daemon/src/routes/webAssets.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { extname, join, normalize, resolve, sep } from 'node:path';
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
interface WebAssetRouteHost {
|
||||
get(
|
||||
path: string,
|
||||
handler: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export async function registerWebAssetRoutes(
|
||||
app: WebAssetRouteHost,
|
||||
assetsDir: string,
|
||||
): Promise<void> {
|
||||
await assertWebAssets(assetsDir);
|
||||
|
||||
app.get('/', async (req, reply) => serveWebAsset(req, reply, assetsDir));
|
||||
app.get('/*', async (req, reply) => serveWebAsset(req, reply, assetsDir));
|
||||
}
|
||||
|
||||
async function assertWebAssets(assetsDir: string): Promise<void> {
|
||||
try {
|
||||
const info = await stat(join(assetsDir, 'index.html'));
|
||||
if (!info.isFile()) {
|
||||
throw new Error('index.html is not a file');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Kimi web assets were not found at ${assetsDir}. Run the package build before starting the daemon.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function serveWebAsset(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
assetsDir: string,
|
||||
): Promise<unknown> {
|
||||
const requestUrl = new URL(req.url, 'http://kimi-web.local');
|
||||
if (isReservedPath(requestUrl.pathname)) {
|
||||
return reply.callNotFound();
|
||||
}
|
||||
|
||||
const filePath = await resolveStaticFile(assetsDir, requestUrl.pathname);
|
||||
if (filePath === undefined) {
|
||||
return reply.code(404).type('text/plain; charset=utf-8').send('Not found');
|
||||
}
|
||||
|
||||
const fileInfo = await stat(filePath).catch(() => undefined);
|
||||
if (fileInfo === undefined || !fileInfo.isFile()) {
|
||||
return reply.code(404).type('text/plain; charset=utf-8').send('Not found');
|
||||
}
|
||||
|
||||
return reply
|
||||
.type(mimeType(filePath))
|
||||
.header('Content-Length', String(fileInfo.size))
|
||||
.send(createReadStream(filePath));
|
||||
}
|
||||
|
||||
async function resolveStaticFile(
|
||||
assetsDir: string,
|
||||
pathname: string,
|
||||
): Promise<string | undefined> {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = normalize(decoded).replace(/^(\.\.(?:[/\\]|$))+/, '');
|
||||
const relative = normalized === sep ? 'index.html' : normalized.replace(/^[/\\]/, '');
|
||||
const root = resolve(assetsDir);
|
||||
const candidate = resolve(
|
||||
root,
|
||||
relative.endsWith(sep) ? join(relative, 'index.html') : relative,
|
||||
);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const info = await stat(candidate).catch(() => undefined);
|
||||
if (info?.isFile() === true) {
|
||||
return candidate;
|
||||
}
|
||||
if (extname(pathname) !== '') {
|
||||
return undefined;
|
||||
}
|
||||
return join(root, 'index.html');
|
||||
}
|
||||
|
||||
function isReservedPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/api' ||
|
||||
pathname.startsWith('/api/') ||
|
||||
pathname === '/documentation' ||
|
||||
pathname.startsWith('/documentation/')
|
||||
);
|
||||
}
|
||||
|
||||
function mimeType(filePath: string): string {
|
||||
switch (extname(filePath)) {
|
||||
case '.html':
|
||||
return 'text/html; charset=utf-8';
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'text/javascript; charset=utf-8';
|
||||
case '.css':
|
||||
return 'text/css; charset=utf-8';
|
||||
case '.json':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.woff2':
|
||||
return 'font/woff2';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ export class ApprovalService extends Disposable implements IApprovalService {
|
|||
'approval requested',
|
||||
);
|
||||
|
||||
return await new Promise<ApprovalResponse>((resolve, reject) => {
|
||||
return new Promise<ApprovalResponse>((resolve, reject) => {
|
||||
const timer = setTimeout(() => this._expire(approvalId), this._timeoutMs);
|
||||
timer.unref?.();
|
||||
this._pending.set(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
* can exit cleanly.
|
||||
*/
|
||||
|
||||
import { Disposable, createDecorator } from '@moonshot-ai/agent-core';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
|
||||
import type { WsConnection } from '#/ws/connection';
|
||||
|
||||
|
|
@ -48,4 +48,3 @@ export interface IConnectionRegistry {
|
|||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const IConnectionRegistry = createDecorator<IConnectionRegistry>('connectionRegistry');
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
|
||||
import { Disposable, createDecorator } from '@moonshot-ai/agent-core';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
|
||||
/**
|
||||
* Minimum shape we need from a Fastify instance. Avoids the strict-generic
|
||||
|
|
@ -53,4 +53,3 @@ export interface IRestGateway {
|
|||
export const IRestGateway = createDecorator<IRestGateway>('restGateway');
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class FastifyRestGateway extends Disposable implements IRestGateway {
|
|||
}
|
||||
|
||||
async listen(host: string, port: number): Promise<string> {
|
||||
return await this.app.listen({ host, port });
|
||||
return this.app.listen({ host, port });
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
|
||||
|
||||
import { Disposable, createDecorator } from '@moonshot-ai/agent-core';
|
||||
|
||||
import { ILogService } from '@moonshot-ai/services';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
import type { WsConnection } from '#/ws/connection';
|
||||
|
||||
export interface ISessionClientsService {
|
||||
|
|
@ -23,4 +20,3 @@ export interface ISessionClientsService {
|
|||
export const ISessionClientsService = createDecorator<ISessionClientsService>(
|
||||
'sessionClientsService',
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,6 @@
|
|||
|
||||
|
||||
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
import { Disposable, createDecorator } from '@moonshot-ai/agent-core';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
|
||||
import { IConnectionRegistry } from './connectionRegistry';
|
||||
import { ILogService } from '@moonshot-ai/services';
|
||||
import { IRestGateway } from './restGateway';
|
||||
import { ISessionClientsService } from './sessionClients';
|
||||
import { WsConnection, type AbortHandler, type FsWatchHandler } from '#/ws/connection';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
import type { AbortHandler, FsWatchHandler } from '#/ws/connection';
|
||||
|
||||
export const WS_PATH = '/api/v1/ws';
|
||||
|
||||
|
|
@ -33,4 +23,3 @@ export interface WSGatewayOptions {
|
|||
|
||||
pongTimeoutMs?: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export class QuestionService extends Disposable implements IQuestionService {
|
|||
'question requested',
|
||||
);
|
||||
|
||||
return await new Promise<QuestionResult>((resolve, reject) => {
|
||||
return new Promise<QuestionResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => this._expire(questionId), this._timeoutMs);
|
||||
timer.unref?.();
|
||||
this._pending.set(
|
||||
|
|
|
|||
|
|
@ -53,13 +53,17 @@ import {
|
|||
} from './services/pinoLoggerService';
|
||||
import { resolveRequestId } from './request-id';
|
||||
import { registerApiV1Routes } from './routes/registerApiV1Routes';
|
||||
import { IConnectionRegistry } from '#/services/gateway';
|
||||
import { IRestGateway } from '#/services/gateway';
|
||||
import { ISessionClientsService } from '#/services/gateway';
|
||||
import {
|
||||
IConnectionRegistry,
|
||||
IRestGateway,
|
||||
ISessionClientsService,
|
||||
IWSBroadcastService,
|
||||
IWSGateway,
|
||||
type WSGatewayOptions,
|
||||
} from '#/services/gateway';
|
||||
import { createDaemonServiceCollection } from '#/services/serviceCollection';
|
||||
import { IWSGateway, type WSGatewayOptions } from '#/services/gateway';
|
||||
import { IWSBroadcastService } from '#/services/gateway';
|
||||
import { getDaemonVersion } from './version';
|
||||
import { registerWebAssetRoutes } from './routes/webAssets';
|
||||
|
||||
export interface DaemonStartOptions {
|
||||
host: string;
|
||||
|
|
@ -76,6 +80,8 @@ export interface DaemonStartOptions {
|
|||
|
||||
debugEndpoints?: boolean;
|
||||
|
||||
webAssetsDir?: string;
|
||||
|
||||
serviceOverrides?: ReadonlyArray<readonly [ServiceIdentifier<unknown>, unknown]>;
|
||||
}
|
||||
|
||||
|
|
@ -172,11 +178,15 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<RunningDaem
|
|||
},
|
||||
});
|
||||
|
||||
if (opts.webAssetsDir !== undefined) {
|
||||
await registerWebAssetRoutes(app, opts.webAssetsDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await app.ready();
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
lockHandle.release();
|
||||
throw err;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let coreProcess: ICoreProcessService;
|
||||
|
|
@ -266,8 +276,8 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<RunningDaem
|
|||
watched_paths: wire,
|
||||
current_count: fsWatcher.countForConnection(connectionId),
|
||||
};
|
||||
} catch (err) {
|
||||
return mapFsWatchError(err);
|
||||
} catch (error) {
|
||||
return mapFsWatchError(error);
|
||||
}
|
||||
},
|
||||
async remove(sessionId: string, connectionId: string, wirePaths: readonly string[]) {
|
||||
|
|
@ -288,8 +298,8 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<RunningDaem
|
|||
watched_paths: wire,
|
||||
current_count: fsWatcher.countForConnection(connectionId),
|
||||
};
|
||||
} catch (err) {
|
||||
return mapFsWatchError(err);
|
||||
} catch (error) {
|
||||
return mapFsWatchError(error);
|
||||
}
|
||||
},
|
||||
cleanupConnection(connectionId: string) {
|
||||
|
|
@ -306,7 +316,7 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<RunningDaem
|
|||
|
||||
return built;
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
|
||||
try {
|
||||
ix.dispose();
|
||||
|
|
@ -314,33 +324,33 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<RunningDaem
|
|||
|
||||
}
|
||||
lockHandle.release();
|
||||
throw err;
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await coreProcess.ready();
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
try {
|
||||
ix.dispose();
|
||||
} catch {
|
||||
|
||||
}
|
||||
lockHandle.release();
|
||||
throw err;
|
||||
throw error;
|
||||
}
|
||||
pinoLogger.info('core process ready');
|
||||
|
||||
let address: string;
|
||||
try {
|
||||
address = await ix.invokeFunction((a) => a.get(IRestGateway).listen(opts.host, opts.port));
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
try {
|
||||
ix.dispose();
|
||||
} catch {
|
||||
|
||||
}
|
||||
lockHandle.release();
|
||||
throw err;
|
||||
throw error;
|
||||
}
|
||||
pinoLogger.info({ address, lockPath: lockHandle.lockPath }, 'daemon listening');
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
buildServerHello,
|
||||
type EventEnvelope,
|
||||
} from './protocol';
|
||||
import { rawDataToString } from './rawData';
|
||||
|
||||
export interface BufferReplaySource {
|
||||
getBufferedSince(
|
||||
|
|
@ -142,7 +143,7 @@ export class WsConnection {
|
|||
if (this.closed) return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(String(data));
|
||||
parsed = JSON.parse(rawDataToString(data));
|
||||
} catch {
|
||||
this.logger.warn('non-json ws frame; ignoring');
|
||||
return;
|
||||
|
|
|
|||
8
packages/daemon/src/ws/rawData.ts
Normal file
8
packages/daemon/src/ws/rawData.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { RawData } from 'ws';
|
||||
|
||||
export function rawDataToString(data: RawData): string {
|
||||
if (typeof data === 'string') return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
|
||||
return Buffer.from(data).toString('utf8');
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ describe('packages/daemon/src anti-corruption', () => {
|
|||
|
||||
it('imports shared filesystem, file store, logger, and workspace services from @moonshot-ai/services', () => {
|
||||
const out = execSync(
|
||||
`grep -rE "['\\\"]#/services/(fileStore|fs|logger|workspace)(/|['\\\"])" "${daemonSrc}" || true`,
|
||||
`grep -rE '["'"'"']#/services/(fileStore|fs|logger|workspace)(/|["'"'"'])' "${daemonSrc}" || true`,
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
expect(out).toBe('');
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from '@moonshot-ai/services';
|
||||
|
||||
import { IRestGateway, startDaemon, type RunningDaemon } from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import {
|
||||
ApprovalExpiredError,
|
||||
ApprovalService,
|
||||
|
|
@ -126,7 +127,7 @@ async function openSubscriber(
|
|||
const sock = new WebSocket(wsUrl);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(String(data)) as Record<string, unknown>);
|
||||
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,14 +278,14 @@ describe('defineRoute', () => {
|
|||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Handler type inference (compile-time only — no runtime assertion needed)
|
||||
// Handler type inference
|
||||
// -------------------------------------------------------------------------
|
||||
it('infers body/params/query types for the handler (compile-time)', () => {
|
||||
const bodySchema = z.object({ name: z.string() });
|
||||
const paramsSchema = z.object({ id: z.string() });
|
||||
const querySchema = z.object({ page: z.string() });
|
||||
|
||||
defineRoute(
|
||||
const route = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/{id}',
|
||||
|
|
@ -306,5 +306,6 @@ describe('defineRoute', () => {
|
|||
void _page;
|
||||
},
|
||||
);
|
||||
expect(route.path).toBe('/items/:id');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
startDaemon,
|
||||
type RunningDaemon,
|
||||
} from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -141,7 +142,7 @@ function openConn(url: string): Promise<Conn> {
|
|||
ws.on('message', (data) => {
|
||||
let parsed: WsFrame;
|
||||
try {
|
||||
parsed = JSON.parse(String(data)) as WsFrame;
|
||||
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from '@moonshot-ai/services';
|
||||
|
||||
import { IRestGateway, startDaemon, type RunningDaemon } from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import {
|
||||
QuestionService,
|
||||
QuestionExpiredError,
|
||||
|
|
@ -117,7 +118,7 @@ async function openSubscriber(
|
|||
const sock = new WebSocket(wsUrl);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(String(data)) as Record<string, unknown>);
|
||||
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@
|
|||
* service, wrong ctor args) would surface as a startDaemon reject.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
|
|
@ -105,6 +112,39 @@ describe('startDaemon — lock + healthz smoke', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('startDaemon — web assets', () => {
|
||||
it('serves web assets from the daemon root without shadowing API routes', async () => {
|
||||
const assetsDir = join(tmpDir, 'web-assets');
|
||||
rmSync(assetsDir, { recursive: true, force: true });
|
||||
mkdirSync(assetsDir);
|
||||
writeFileSync(join(assetsDir, 'index.html'), '<html><div id="app"></div></html>', 'utf8');
|
||||
writeFileSync(join(assetsDir, 'app.js'), 'console.log("kimi web");', 'utf8');
|
||||
|
||||
const r = await startDaemon({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: silentLogger(),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
webAssetsDir: assetsDir,
|
||||
});
|
||||
running.push(r);
|
||||
|
||||
await expect(fetch(`${r.address}/`).then((res) => res.text())).resolves.toContain(
|
||||
'<div id="app"></div>',
|
||||
);
|
||||
await expect(fetch(`${r.address}/sessions/abc`).then((res) => res.text())).resolves.toContain(
|
||||
'<div id="app"></div>',
|
||||
);
|
||||
await expect(fetch(`${r.address}/app.js`).then((res) => res.text())).resolves.toBe(
|
||||
'console.log("kimi web");',
|
||||
);
|
||||
|
||||
const health = await fetch(`${r.address}/api/v1/healthz`);
|
||||
await expect(health.json()).resolves.toMatchObject({ code: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDaemon — DI container wiring', () => {
|
||||
it('exposes all DI services through running.services', async () => {
|
||||
const r = await spawn();
|
||||
|
|
@ -136,4 +176,3 @@ describe('startDaemon — DI container wiring', () => {
|
|||
await expect(bridge.rpc.getCoreInfo({})).rejects.toThrow(/disposed/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { WebSocket } from 'ws';
|
|||
import { IPromptService, PromptService } from '@moonshot-ai/services';
|
||||
|
||||
import { IRestGateway, startDaemon, type RunningDaemon } from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -131,7 +132,7 @@ async function openSubscriber(r: RunningDaemon, sid: string): Promise<Subscriber
|
|||
const sock = new WebSocket(wsUrl);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(String(data)) as Record<string, unknown>);
|
||||
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
startDaemon,
|
||||
type RunningDaemon,
|
||||
} from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -96,7 +97,7 @@ function openConn(url: string): Promise<Conn> {
|
|||
ws.on('message', (data) => {
|
||||
let parsed: WsFrame;
|
||||
try {
|
||||
parsed = JSON.parse(String(data)) as WsFrame;
|
||||
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
startDaemon,
|
||||
type RunningDaemon,
|
||||
} from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -104,7 +105,7 @@ function openConn(url: string): Promise<Conn> {
|
|||
ws.on('message', (data) => {
|
||||
let parsed: WsFrame;
|
||||
try {
|
||||
parsed = JSON.parse(String(data)) as WsFrame;
|
||||
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
startDaemon,
|
||||
type RunningDaemon,
|
||||
} from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -108,7 +109,7 @@ function openConn(url: string): Promise<Conn> {
|
|||
ws.on('message', (data) => {
|
||||
let parsed: WsFrame;
|
||||
try {
|
||||
parsed = JSON.parse(String(data)) as WsFrame;
|
||||
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,10 @@
|
|||
|
||||
|
||||
import { createWriteStream, promises as fsp } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import {
|
||||
Disposable,
|
||||
createDecorator,
|
||||
resolveKimiHome,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
|
||||
import type { FileMeta } from '@moonshot-ai/protocol';
|
||||
|
||||
import { ILogService } from '../logger/logger';
|
||||
|
||||
export const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export class FileNotFoundError extends Error {
|
||||
|
|
@ -65,9 +53,3 @@ export interface IFileStore {
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const IFileStore = createDecorator<IFileStore>('fileStore');
|
||||
|
||||
interface IndexFile {
|
||||
version: 1;
|
||||
files: FileMeta[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createDecorator,
|
||||
Disposable,
|
||||
type IDisposable,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
ISessionService,
|
||||
SessionNotFoundError,
|
||||
} from '../session/session';
|
||||
import { createDecorator, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
FsEntry,
|
||||
FsListManyRequest,
|
||||
|
|
@ -24,12 +12,6 @@ import type {
|
|||
FsStatManyResponse,
|
||||
FsStatRequest,
|
||||
} from '@moonshot-ai/protocol';
|
||||
import ignore, { type Ignore } from 'ignore';
|
||||
|
||||
import {
|
||||
FsPathEscapesError,
|
||||
resolveSafePath,
|
||||
} from './fsPathSafety';
|
||||
|
||||
export class FsPathNotFoundError extends Error {
|
||||
readonly inputPath: string;
|
||||
|
|
@ -119,5 +101,3 @@ export interface FsDownloadResolved {
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const IFsService = createDecorator<IFsService>('fsService');
|
||||
|
||||
void SessionNotFoundError;
|
||||
|
|
|
|||
|
|
@ -1,29 +1,13 @@
|
|||
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createDecorator,
|
||||
Disposable,
|
||||
type IDisposable,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
ISessionService,
|
||||
SessionNotFoundError,
|
||||
} from '../session/session';
|
||||
import { createDecorator, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
FsGitStatus,
|
||||
FsGitStatusRequest,
|
||||
FsGitStatusResponse,
|
||||
} from '@moonshot-ai/protocol';
|
||||
|
||||
import {
|
||||
FsPathEscapesError,
|
||||
resolveSafePath,
|
||||
} from './fsPathSafety';
|
||||
|
||||
export class FsGitUnavailableError extends Error {
|
||||
readonly cwd: string;
|
||||
readonly detail: string;
|
||||
|
|
@ -146,6 +130,3 @@ function collapseXY(xy: string): FsGitStatus {
|
|||
function posix(p: string): string {
|
||||
return p.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
void FsPathEscapesError;
|
||||
void SessionNotFoundError;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { promises as fs } from 'node:fs';
|
|||
|
||||
import { Disposable, InstantiationType, registerSingleton } from '@moonshot-ai/agent-core';
|
||||
import type { FsGitStatusRequest, FsGitStatusResponse } from '@moonshot-ai/protocol';
|
||||
import { ISessionService, SessionNotFoundError } from '../session/session';
|
||||
import { ISessionService } from '../session/session';
|
||||
|
||||
import { IFsGitService, FsGitUnavailableError, parsePorcelain } from './fsGit';
|
||||
import { FsPathEscapesError, resolveSafePath } from './fsPathSafety';
|
||||
import { resolveSafePath } from './fsPathSafety';
|
||||
|
||||
export class FsGitService extends Disposable implements IFsGitService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
|
@ -70,7 +70,7 @@ async function runCommand(
|
|||
args: readonly string[],
|
||||
cwd: string,
|
||||
): Promise<RunResult> {
|
||||
return await new Promise<RunResult>((resolve) => {
|
||||
return new Promise<RunResult>((resolve) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
|
|
|||
|
|
@ -1,34 +1,11 @@
|
|||
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createDecorator,
|
||||
Disposable,
|
||||
type IDisposable,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
ISessionService,
|
||||
SessionNotFoundError,
|
||||
} from '../session/session';
|
||||
import { createDecorator, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
FsGrepFileHit,
|
||||
FsGrepMatch,
|
||||
FsGrepRequest,
|
||||
FsGrepResponse,
|
||||
FsSearchHit,
|
||||
FsSearchRequest,
|
||||
FsSearchResponse,
|
||||
} from '@moonshot-ai/protocol';
|
||||
import ignore, { type Ignore } from 'ignore';
|
||||
|
||||
import { ILogService } from '../logger/logger';
|
||||
import {
|
||||
FsPathEscapesError,
|
||||
resolveSafePath,
|
||||
} from './fsPathSafety';
|
||||
|
||||
export class FsGrepTimeoutError extends Error {
|
||||
readonly elapsedMs: number;
|
||||
|
|
@ -53,12 +30,3 @@ export interface IFsSearchService extends IDisposable {
|
|||
export const IFsSearchService = createDecorator<IFsSearchService>(
|
||||
'fsSearchService',
|
||||
);
|
||||
|
||||
const SEARCH_HARD_CAP = 500;
|
||||
|
||||
const GREP_TIMEOUT_MS = 30_000;
|
||||
|
||||
const WALK_MAX_DEPTH = 64;
|
||||
|
||||
void FsPathEscapesError;
|
||||
void SessionNotFoundError;
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@ import type {
|
|||
} from '@moonshot-ai/protocol';
|
||||
import ignore, { type Ignore } from 'ignore';
|
||||
|
||||
import { ISessionService, SessionNotFoundError } from '../session/session';
|
||||
import { ISessionService } from '../session/session';
|
||||
|
||||
import { ILogService } from '../logger/logger';
|
||||
import { IFsSearchService, FsGrepTimeoutError } from './fsSearch';
|
||||
import { FsPathEscapesError, resolveSafePath } from './fsPathSafety';
|
||||
|
||||
const SEARCH_HARD_CAP = 500;
|
||||
|
||||
|
|
@ -242,6 +241,11 @@ export class FsSearchService
|
|||
buf.pending.shift();
|
||||
}
|
||||
} else if (t === 'match') {
|
||||
if (totalMatches >= req.max_total_matches) {
|
||||
truncated = true;
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
const p = rgPath(rec.data?.path);
|
||||
if (p === undefined) continue;
|
||||
const buf = fileBuf.get(p);
|
||||
|
|
@ -295,6 +299,17 @@ export class FsSearchService
|
|||
child.once('error', () => resolve());
|
||||
});
|
||||
|
||||
for (const [p, buf] of fileBuf) {
|
||||
if (buf.matches.length > 0 && buf.pending.length > 0) {
|
||||
const last = buf.matches[buf.matches.length - 1]!;
|
||||
last.after = buf.pending.slice(0, req.context_lines);
|
||||
}
|
||||
if (buf.matches.length > 0) {
|
||||
files.push({ path: p, matches: buf.matches });
|
||||
}
|
||||
}
|
||||
fileBuf.clear();
|
||||
|
||||
if (signal.aborted) {
|
||||
|
||||
if (totalMatches === 0 && filesScanned === 0) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
* `McpServerNotFoundError`. The route maps it to envelope code 40408.
|
||||
*
|
||||
* **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the
|
||||
* `createDecorator` / `Disposable` values and the `McpServerInfo` type.
|
||||
* `createDecorator` value and the `McpServerInfo` type.
|
||||
*
|
||||
* **MCP status mapping** (`McpServerInfo.status` → `McpServer.status`):
|
||||
* agent-core 'pending' → wire 'connecting'
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
* name-as-id at the wire boundary. Both are 1:1 within a daemon process.
|
||||
*/
|
||||
|
||||
import { createDecorator, Disposable } from '@moonshot-ai/agent-core';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
import type { McpServerInfo } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
McpServer,
|
||||
|
|
|
|||
|
|
@ -43,12 +43,8 @@
|
|||
* at the route layer. This impl receives a fully-validated query.
|
||||
*/
|
||||
|
||||
import { createDecorator, Disposable } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
AgentContextData,
|
||||
ContextMessage,
|
||||
SessionSummary,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
import type { ContextMessage } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
CursorQuery,
|
||||
Message,
|
||||
|
|
@ -58,8 +54,6 @@ import type {
|
|||
ToolUseContent,
|
||||
} from '@moonshot-ai/protocol';
|
||||
|
||||
import { SessionNotFoundError } from '../session/session';
|
||||
|
||||
/**
|
||||
* Listing query — `before_id`/`after_id` + `page_size` mutex is enforced
|
||||
* by `cursorQuerySchema`. The service layer adds an optional role filter.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { SessionNotFoundError } from '../session/session';
|
|||
import {
|
||||
IMessageService,
|
||||
MessageNotFoundError,
|
||||
deriveMessageId,
|
||||
parseMessageId,
|
||||
toProtocolMessage,
|
||||
type MessageListQuery,
|
||||
|
|
@ -105,7 +104,7 @@ export class MessageService extends Disposable implements IMessageService {
|
|||
try {
|
||||
await this.core.rpc.resumeSession({ sessionId: sid });
|
||||
return await this.core.rpc.getContext({ sessionId: sid, agentId: MAIN_AGENT_ID });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
throw new SessionNotFoundError(sid);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ import { createDecorator } from '@moonshot-ai/agent-core';
|
|||
import type {
|
||||
OAuthFlowSnapshot,
|
||||
OAuthFlowStart,
|
||||
OAuthFlowStatus,
|
||||
OAuthLoginCancelResponse,
|
||||
OAuthLogoutResponse,
|
||||
} from '@moonshot-ai/protocol';
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
* terminal status (completed/failed/cancelled/timed_out/killed/lost).
|
||||
*
|
||||
* **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the
|
||||
* `createDecorator` / `Disposable` values and the `BackgroundTaskInfo` type.
|
||||
* `createDecorator` value and the `BackgroundTaskInfo` type.
|
||||
*
|
||||
* Reference table (task kind + status):
|
||||
*
|
||||
|
|
@ -38,12 +38,10 @@
|
|||
* lost → failed (lossy)
|
||||
*/
|
||||
|
||||
import { createDecorator, Disposable } from '@moonshot-ai/agent-core';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
import type { BackgroundTaskInfo } from '@moonshot-ai/agent-core';
|
||||
import type { BackgroundTask, BackgroundTaskKind, BackgroundTaskStatus } from '@moonshot-ai/protocol';
|
||||
|
||||
import { SessionNotFoundError } from '../session/session';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter helpers (moved from adapter/task-adapter.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
*/
|
||||
|
||||
import { Disposable, InstantiationType, registerSingleton } from '@moonshot-ai/agent-core';
|
||||
import type { BackgroundTaskInfo } from '@moonshot-ai/agent-core';
|
||||
import type { BackgroundTask } from '@moonshot-ai/protocol';
|
||||
|
||||
import { ICoreProcessService } from '../coreProcess/coreProcess';
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
* per-session knob. Documented gap in `ToolService`.
|
||||
*
|
||||
* **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the
|
||||
* `createDecorator` / `Disposable` values.
|
||||
* `createDecorator` value.
|
||||
*/
|
||||
|
||||
import { createDecorator, Disposable } from '@moonshot-ai/agent-core';
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
import type { ToolDescriptor, ToolSource } from '@moonshot-ai/protocol';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -115,6 +115,9 @@ importers:
|
|||
'@moonshot-ai/kimi-telemetry':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/telemetry
|
||||
'@moonshot-ai/kimi-web':
|
||||
specifier: workspace:^
|
||||
version: link:../kimi-web
|
||||
'@moonshot-ai/migration-legacy':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/migration-legacy
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue