Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/lan-remote-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/vis-server": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove ignored vis-server from the changeset

This repo's changeset config ignores @moonshot-ai/vis-server, and Changesets fails publishing when an ignored package is mentioned in the same changeset as a non-ignored package. Because this file includes both publishable @moonshot-ai/kimi-code and ignored @moonshot-ai/vis-server, the release PR's pnpm changeset version/publish path will fail instead of cutting the CLI patch; keep only the publishable package and describe the bundled vis-server behavior there.

Useful? React with 👍 / 👎.

---

feat(vis): show LAN URLs when binding to 0.0.0.0 for remote control

When vis-server binds to 0.0.0.0 or :: (all interfaces), the startup
banner and CLI output now display the actual LAN IP addresses that
other devices on the same network can use to connect. This enables
lan-range remote control from phones, tablets, or other machines.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/cli/sub/vis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly lanUrls?: string[];
readonly close: () => Promise<void>;
}

Expand Down Expand Up @@ -86,6 +87,12 @@ export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void>
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;

deps.stdout.write(`kimi vis is running at ${server.url}\n`);
if (server.lanUrls !== undefined && server.lanUrls.length > 0) {
deps.stdout.write(`LAN access:\n`);
for (const lanUrl of server.lanUrls) {
deps.stdout.write(` ${lanUrl}\n`);
}
}
deps.stdout.write('Press Ctrl-C to stop.\n');

if (opts.open) {
Expand Down
17 changes: 17 additions & 0 deletions apps/vis/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import os from 'node:os';

export function isAllInterfaces(host: string): boolean {
return host === '0.0.0.0' || host === '::';
}

export function getLocalNetworkAddresses(port: number): string[] {
const addresses: string[] = [];
for (const [, ifaceList] of Object.entries(os.networkInterfaces())) {
for (const iface of ifaceList ?? []) {
if (!iface.internal && iface.family === 'IPv4') {
addresses.push(`http://${iface.address}:${port}/`);
}
}
}
return addresses;
}

/** Resolve KIMI_CODE_HOME (env > ~/.kimi-code). */
export function resolveKimiCodeHome(): string {
Expand Down
4 changes: 2 additions & 2 deletions apps/vis/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { formatStartupBanner } from './startup-banner';
async function main(): Promise<void> {
const host = resolveHost();
const authToken = resolveVisAuthToken(host);
const { port } = await startVisServer({ host, authToken });
const { port, lanUrls } = await startVisServer({ host, authToken });
process.stdout.write(
formatStartupBanner({ authToken, host, kimiCodeHome: KIMI_CODE_HOME, port }),
formatStartupBanner({ authToken, host, kimiCodeHome: KIMI_CODE_HOME, port, lanUrls }),
);
}

Expand Down
4 changes: 3 additions & 1 deletion apps/vis/server/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { serve } from '@hono/node-server';

import { createApp } from './app';
import { hostForUrl, resolveHost, resolveKimiCodeHome, resolvePort, resolveVisAuthToken } from './config';
import { getLocalNetworkAddresses, hostForUrl, isAllInterfaces, resolveHost, resolveKimiCodeHome, resolvePort, resolveVisAuthToken } from './config';
import type { WebAsset } from './lib/web-asset';

export interface StartVisServerOptions {
Expand All @@ -18,6 +18,7 @@ export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly lanUrls?: string[];
readonly close: () => Promise<void>;
}

Expand All @@ -36,6 +37,7 @@ export async function startVisServer(
port: info.port,
host,
url: `http://${hostForUrl(host)}:${info.port}/`,
lanUrls: isAllInterfaces(host) ? getLocalNetworkAddresses(info.port) : undefined,
close: () =>
new Promise<void>((done, fail) => {
server.close((err?: Error) => (err ? fail(err) : done()));
Expand Down
13 changes: 10 additions & 3 deletions apps/vis/server/src/startup-banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ export interface StartupBannerOptions {
readonly host: string;
readonly kimiCodeHome: string;
readonly port: number;
readonly lanUrls?: string[];
}

export function formatStartupBanner(options: StartupBannerOptions): string {
const authStatus = options.authToken === undefined ? 'auth=disabled' : 'auth=required';
return (
let banner =
`[vis-server] listening on http://${hostForUrl(options.host)}:${String(options.port)} ` +
`(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n`
);
`(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n`;
if (options.lanUrls !== undefined && options.lanUrls.length > 0) {
banner +=
`[vis-server] LAN access:\n` +
options.lanUrls.map((url) => ` - ${url}`).join('\n') +
'\n';
}
return banner;
}
38 changes: 37 additions & 1 deletion apps/vis/server/test/lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { hostForUrl } from '../../src/config';
import { hostForUrl, isAllInterfaces, getLocalNetworkAddresses } from '../../src/config';

describe('hostForUrl', () => {
it('brackets a bare IPv6 literal for use in a URL', () => {
Expand All @@ -18,3 +18,39 @@ describe('hostForUrl', () => {
expect(hostForUrl('[::1]')).toBe('[::1]');
});
});

describe('isAllInterfaces', () => {
it('returns true for 0.0.0.0', () => {
expect(isAllInterfaces('0.0.0.0')).toBe(true);
});

it('returns true for ::', () => {
expect(isAllInterfaces('::')).toBe(true);
});

it('returns false for loopback hosts', () => {
expect(isAllInterfaces('127.0.0.1')).toBe(false);
expect(isAllInterfaces('localhost')).toBe(false);
expect(isAllInterfaces('::1')).toBe(false);
});
});

describe('getLocalNetworkAddresses', () => {
it('returns non-empty array of IPv4 URLs for a valid port', () => {
const addresses = getLocalNetworkAddresses(3001);
expect(addresses.length).toBeGreaterThan(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mock host interfaces in the new config test

On CI or developer machines with only loopback interfaces, or with no non-internal IPv4 interface, getLocalNetworkAddresses() legitimately returns an empty array, so this new assertion fails for host setup rather than product behavior. Since os.networkInterfaces() is environment-dependent, mock it for this test and cover the empty-interface case separately instead of requiring length > 0.

Useful? React with 👍 / 👎.

for (const addr of addresses) {
expect(addr).toMatch(/^http:\/\/\d+\.\d+\.\d+\.\d+:3001\/$/);
}
});

it('returns different URLs for different ports', () => {
const addrs3001 = getLocalNetworkAddresses(3001);
const addrs8080 = getLocalNetworkAddresses(8080);
expect(addrs3001.length).toBe(addrs8080.length);
for (let i = 0; i < addrs3001.length; i++) {
expect(addrs3001[i]).toContain(':3001/');
expect(addrs8080[i]).toContain(':8080/');
}
});
});
Loading