feat(kimi-inspect): add kap-server web inspector with dev-only /api/v1/debug RPC surface (#1806)

* feat(kimi-inspect): add web inspector for kap-server /api/v2 surface

- new apps/kimi-inspect app: connect screen (server URL + optional bearer
  token, persisted in localStorage, deep-linkable via ?url=/?token=),
  workspace/session browser sidebar, per-session chat view, and live
  Service panels with data and trigger buttons for Session/Agent scopes
- built on @moonshot-ai/klient (HTTP for calls, /api/v2/ws for events);
  Vite dev server proxies /api to a running kap-server
- register the workspace in AGENTS.md project map and flake.nix
  workspacePaths/workspaceNames

* fix(kimi-inspect): align dependency versions with the workspace (sherif)

* feat: dev /api/v1/debug RPC surface and kimi-inspect channel rework

- kimi-inspect: replace @moonshot-ai/klient with an in-app old-klient-style
  channel layer (service-bound IChannel, HTTP ProxyChannel, shared /api/v2/ws
  socket with ref-counted event listens), typed by agent-core-v2 interfaces;
  /channels descriptors + serviceByName keep every wire protocol loaded 1:1
- kimi-inspect: local server auto-discovery (Vite middleware over the
  kap-server instance registry + home token), zero-config startup connect,
  and a header switcher for runtime server switching
- kap-server: wire the dormant --debug-endpoints flag to a new
  whitelist-free /api/v1/debug dispatcher (every scoped service callable),
  gated to loopback binds; repo dev scripts pass the flag
- kimi-inspect: probe the debug surface at connect, falling back to /api/v2
  on servers without it
- tests: channel + discovery unit tests in kimi-inspect; debug RPC and
  loopback-gating coverage in the kap-server rpc/debugNonloopback suites

* chore(changesets): ignore @moonshot-ai/kimi-inspect

The private dev app never ships, so it should never appear in a changeset.
Add it to the changeset config ignore list (next to vis*) and note the rule
in the gen-changesets skill.
This commit is contained in:
Haozhe 2026-07-17 15:56:53 +08:00 committed by GitHub
parent a76d54bd8a
commit 9b496946dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 4239 additions and 112 deletions

View file

@ -21,7 +21,7 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`.
- **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output.
5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump.
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled.
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. `@moonshot-ai/kimi-inspect` (a private dev app that never ships) is likewise ignored and must never appear in a changeset frontmatter.
## Workflow

View file

@ -9,7 +9,8 @@
"ignore": [
"@moonshot-ai/vis",
"@moonshot-ai/vis-server",
"@moonshot-ai/vis-web"
"@moonshot-ai/vis-web",
"@moonshot-ai/kimi-inspect"
],
"snapshot": {
"useCalculatedVersion": true,

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Mount the dev-only /api/v1/debug RPC surface behind the --debug-endpoints flag, exposing every scoped service for local debugging on loopback binds. Pass --debug-endpoints to kimi server run to enable it.

View file

@ -17,13 +17,14 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`).
- `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`.
- `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays.
- `apps/kimi-inspect`: web inspector for the v2 (kap-server) `/api/v2` surface — workspace/session browser, per-session chat, and live Service panels (data + trigger buttons) for the Session and Agent scopes. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v2`, `WsChannel` over the shared `/api/v2/ws` socket for events), typed by `agent-core-v2` Service interfaces; `GET {rpcBasePath}/channels` loads every wire protocol 1:1 — probing `/api/v1/debug` first (dev, whitelist-free) and falling back to `/api/v2`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime.
- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities.
- `packages/node-sdk`: the public TypeScript SDK and harness.
- `packages/kosong`: the LLM / provider abstraction layer.
- `packages/kaos`: the execution environment and file/process abstractions.
- `packages/oauth`: Kimi OAuth and managed auth utilities.
- `packages/telemetry`: shared client-side telemetry infrastructure.
- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`.
- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. With `--debug-endpoints` on a loopback bind it additionally mounts `/api/v1/debug/*` — the same reflection dispatcher as `/api/v2` but without the channel whitelist (every scoped Service callable, `src/transport/registerDebugRoutes.ts`); internal only, repo dev scripts pass the flag.
- `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/http|ipc|memory`); all three return the same `Klient`. The package also hosts the e2e suites: dual-backend session/agent suites (`test/e2e/dual/`, in-memory + in-process server), `/api/v2` wire tests (`test/e2e/v2/`), the legacy `/api/v1` live suites (`test/e2e/legacy/`), and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`.
## Environment Requirements

View file

@ -63,9 +63,9 @@
"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:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
"dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
"dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints",
"dev:server:restart": "node scripts/dev-server-restart.mjs",
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kimi Inspect</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,40 @@
{
"name": "@moonshot-ai/kimi-inspect",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"imports": {
"#/*": {
"types": [
"./src/*.ts",
"./src/*.tsx",
"./src/*/index.ts",
"./src/*/index.tsx"
],
"default": "./src/*"
}
},
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@moonshot-ai/agent-core-v2": "workspace:^",
"@tanstack/react-query": "^5.74.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.4",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"tailwindcss": "^4.1.4",
"typescript": "6.0.2",
"vite": "^6.3.3",
"vitest": "4.1.4"
}
}

View file

@ -0,0 +1,126 @@
/**
* App shell selection state, session resume, and the three WS event
* subscriptions that feed the live bus:
* core `events` process-wide domain events
* session `interactions` pending approvals / questions
* agent `events` the active agent's live event stream
* Layout: header / left sidebar (workspaces + sessions) / chat / inspector.
*/
import { useEffect, useRef, useState } from 'react';
import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle';
import { ChatView } from './components/ChatView';
import { Inspector } from './components/Inspector';
import { ServerSwitcher } from './components/ServerSwitcher';
import { Sidebar } from './components/Sidebar';
import { useConnection } from './connection';
import { LiveBusProvider, type Emit, type LiveEvent } from './live';
import { Badge, errorMessage } from './ui';
export function App() {
const { klient, wsState, baseUrl, disconnect } = useConnection();
const [sessionId, setSessionId] = useState<string | null>(null);
const [agentId, setAgentId] = useState('main');
const [ready, setReady] = useState(false);
const [resumeError, setResumeError] = useState<unknown>(null);
const emitRef = useRef<Emit | null>(null);
const publish = (source: LiveEvent['source'], data: unknown) =>
emitRef.current?.({ source, data, at: Date.now() });
// Core events — for the lifetime of the connection.
useEffect(() => {
const sub = klient.ws().listen('events', (data) => publish('core', data));
return () => sub.dispose();
}, [klient]);
// Session interactions — re-subscribe when the session changes.
useEffect(() => {
if (sessionId === null || !ready) return;
const session = klient.ws().session(sessionId);
const a = session.listen('interactions', (data) => publish('session', data));
const b = session.listen('interactions:resolved', (data) => publish('session', data));
return () => {
a.dispose();
b.dispose();
};
}, [klient, sessionId, ready]);
// Agent events — re-subscribe when session or agent changes.
useEffect(() => {
if (sessionId === null || !ready) return;
const sub = klient
.ws()
.session(sessionId)
.agent(agentId)
.listen('events', (data) => publish('agent', data));
return () => sub.dispose();
}, [klient, sessionId, agentId, ready]);
// Resume (materialize) the session on the server when it is selected, so
// session / agent scoped Services become reachable.
useEffect(() => {
if (sessionId === null) return;
let cancelled = false;
setReady(false);
setResumeError(null);
klient
.core(ISessionLifecycleService)
.resume(sessionId)
.then(() => {
if (!cancelled) setReady(true);
})
.catch((error: unknown) => {
if (!cancelled) setResumeError(error);
});
return () => {
cancelled = true;
};
}, [klient, sessionId]);
// Switching servers invalidates every session/agent selection: sessions
// belong to the server they were listed from. The client and its WS
// subscriptions rebuild from the new config on their own.
useEffect(() => {
setSessionId(null);
setAgentId('main');
}, [baseUrl]);
return (
<LiveBusProvider busRef={emitRef}>
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-neutral-800 px-4 py-1.5">
<span className="text-[12px] font-bold tracking-widest text-neutral-200">KIMI INSPECT</span>
<ServerSwitcher />
<Badge tone={wsState === 'open' ? 'green' : wsState === 'connecting' ? 'amber' : 'red'}>
ws: {wsState}
</Badge>
<div className="flex-1" />
<button
className="text-[11px] text-neutral-500 hover:text-neutral-300"
onClick={disconnect}
>
Disconnect
</button>
</header>
<div className="flex min-h-0 flex-1">
<Sidebar activeSessionId={sessionId} onSelectSession={setSessionId} />
{resumeError !== null ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-[12px] text-red-400">
Failed to open session: {errorMessage(resumeError)}
</div>
) : (
<ChatView sessionId={sessionId} agentId={agentId} ready={ready} />
)}
<Inspector
sessionId={sessionId}
agentId={agentId}
onAgentChange={setAgentId}
ready={ready}
/>
</div>
</div>
</LiveBusProvider>
);
}

View file

@ -0,0 +1,195 @@
/**
* Channel layer unit tests `ProxyChannel` URL/envelope semantics, `makeProxy`
* routing, and `WsChannel`'s ref-counted `listen`. The WS wire protocol itself
* is covered by the kap-server contract and klient's e2e suites.
*/
import { describe, expect, it, vi } from 'vitest';
import type { Event, IChannel } from './channel';
import { RPCError } from './errors';
import { makeProxy } from './proxy';
import { ProxyChannel } from './proxyChannel';
import { WsChannel } from './wsChannel';
import type { WsSocket } from './wsSocket';
const ok = (data: unknown) => ({ code: 0, msg: 'success', data, request_id: 'r1' });
function fakeFetch(envelope: unknown) {
const calls: { url: string; init?: RequestInit }[] = [];
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
calls.push({ url: String(url), init });
return { json: async () => envelope };
}) as unknown as typeof fetch;
return { calls, fetchImpl };
}
function stubSocket() {
const state = {
listens: 0,
disposed: 0,
handler: undefined as ((data: unknown) => void) | undefined,
};
const raw = {
call: vi.fn(async () => 'ws-ret'),
listen: (
_scope: string,
_event: string,
_ids: unknown,
handler: (data: unknown) => void,
_service?: string,
) => {
state.listens += 1;
state.handler = handler;
return {
dispose: () => {
state.disposed += 1;
},
};
},
};
return { raw, state, socket: raw as unknown as WsSocket };
}
describe('ProxyChannel.call', () => {
it('POSTs the command to the service base URL; no body and no header without args/token', async () => {
const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' }));
const channel = new ProxyChannel({
baseUrl: 'http://h:1/api/v2/session/s%201/agent/main/agentRPCService',
fetch: fetchImpl,
});
const result = await channel.call('getModel', []);
expect(result).toEqual({ id: 's1' });
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe('http://h:1/api/v2/session/s%201/agent/main/agentRPCService/getModel');
expect(calls[0]!.init?.method).toBe('POST');
expect(calls[0]!.init?.body).toBeUndefined();
});
it('sends the complete argument array as the JSON body, plus the bearer token', async () => {
const { calls, fetchImpl } = fakeFetch(ok(null));
const channel = new ProxyChannel({
baseUrl: 'http://h:2/api/v2/configService',
token: 'tok',
fetch: fetchImpl,
});
await channel.call('set', ['workspace', { theme: 'dark' }]);
expect(calls[0]!.init?.body).toBe(JSON.stringify(['workspace', { theme: 'dark' }]));
expect(calls[0]!.init?.headers).toEqual({
'content-type': 'application/json',
authorization: 'Bearer tok',
});
});
it('unwraps the envelope and throws RPCError on a non-zero code', async () => {
const { fetchImpl } = fakeFetch({
code: 40401,
msg: 'session not found',
data: null,
request_id: 'r2',
details: { id: 's9' },
});
const channel = new ProxyChannel({ baseUrl: 'http://h:3/api/v2/sessionIndex', fetch: fetchImpl });
const err: unknown = await channel.call('get', ['s9']).catch((error: unknown) => error);
expect(err).toBeInstanceOf(RPCError);
expect((err as RPCError).code).toBe(40401);
expect((err as RPCError).message).toBe('session not found');
expect((err as RPCError).details).toEqual({ id: 's9' });
});
});
describe('makeProxy', () => {
interface DemoService {
read(id: string, n: number): Promise<string>;
onDidChangeMetadata: Event<{ title: string }>;
}
it('routes methods to call and onXxx members to listen', async () => {
const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] };
const channel: IChannel = {
call: async <T,>(command: string, args?: unknown[]): Promise<T> => {
seen.calls.push([command, args ?? []]);
return 'ret' as T;
},
listen: <T,>(event: string): Event<T> => {
seen.listens.push(event);
return () => ({ dispose: () => {} });
},
};
const svc = makeProxy<DemoService>(channel);
await expect(svc.read('a', 1)).resolves.toBe('ret');
expect(seen.calls).toEqual([['read', ['a', 1]]]);
const d = svc.onDidChangeMetadata(() => {});
d.dispose();
expect(seen.listens).toEqual(['onDidChangeMetadata']);
});
});
describe('WsChannel', () => {
it('forwards calls over the socket with scope + service + ids', async () => {
const { raw, socket } = stubSocket();
const channel = new WsChannel({
socket,
scope: 'agent',
service: 'agentRPCService',
sessionId: 's1',
agentId: 'main',
});
const result = await channel.call('getModel', [{}]);
expect(result).toBe('ws-ret');
expect(raw.call).toHaveBeenCalledWith('agent', 'agentRPCService', 'getModel', [{}], {
sessionId: 's1',
agentId: 'main',
});
});
it('multiplexes local listeners onto one remote subscription', () => {
const { state, socket } = stubSocket();
const channel = new WsChannel({
socket,
scope: 'session',
service: 'sessionMetadata',
sessionId: 's1',
});
const onDidChange = channel.listen<unknown>('onDidChangeMetadata');
const seen: unknown[] = [];
const sub1 = onDidChange((e) => seen.push(['l1', e]));
const sub2 = onDidChange((e) => seen.push(['l2', e]));
expect(state.listens).toBe(1);
state.handler!({ title: 't' });
expect(seen).toEqual([
['l1', { title: 't' }],
['l2', { title: 't' }],
]);
sub1.dispose();
expect(state.disposed).toBe(0);
sub2.dispose();
expect(state.disposed).toBe(1);
});
});
describe('ProxyChannel.listen', () => {
it('throws without a WS binding', () => {
const channel = new ProxyChannel({
baseUrl: 'http://h:4/api/v2/configService',
fetch: fakeFetch(ok(null)).fetchImpl,
});
expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/);
});
it('delegates to one lazily-created WsChannel when a WS binding is provided', () => {
const { state, socket } = stubSocket();
const factory = vi.fn(() => new WsChannel({ socket, scope: 'core', service: 'configService' }));
const channel = new ProxyChannel(
{ baseUrl: 'http://h:5/api/v2/configService', fetch: fakeFetch(ok(null)).fetchImpl },
factory,
);
const sub = channel.listen<unknown>('onDidChangeConfiguration')(() => {});
expect(factory).toHaveBeenCalledTimes(1);
expect(state.listens).toBe(1);
channel.listen('onDidSectionChange');
expect(factory).toHaveBeenCalledTimes(1);
sub.dispose();
expect(state.disposed).toBe(1);
});
});

View file

@ -0,0 +1,42 @@
/**
* Transport-agnostic channel contract for the `/api/v2` client the
* old-klient / VS Code `ProxyChannel` model: the channel is bound to one
* Service (the URL carries the scope + the Service's decorator id) and
* `command` is the method name, invoked by reflection on the server.
* `listen` is for the Service's `onXxx` emitter events over the persistent
* `/api/v2/ws` transport.
*/
import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
export interface IDisposable {
dispose(): void;
}
export interface Event<T> {
(listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable;
}
/** The client-facing channel contract. Calls always carry the complete argument array. */
export interface IChannel {
call<T>(command: string, args?: unknown[]): Promise<T>;
listen<T>(event: string, arg?: unknown): Event<T>;
}
/** A wire Service reference: a DI decorator (stringifies to the wire channel
* name) or the raw channel name as a string. */
export type ServiceRef<T> = ServiceIdentifier<T> | string;
/**
* Remote view of a Service contract: every method becomes an async wire call;
* `onXxx` event members (`Event<T>` callables returning `IDisposable`) stay
* subscribable events; plain non-function members become zero-arg property
* reads (the `/api/v2` dispatcher returns non-function members as-is).
*/
export type ServiceProxy<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? R extends IDisposable
? T[K]
: (...args: A) => Promise<Awaited<R>>
: () => Promise<Awaited<T[K]>>;
};

View file

@ -0,0 +1,112 @@
/**
* Protocol loading the server's `{rpcBasePath}/channels` endpoint is its
* self-description of every wire-callable Service (name, scope, domain,
* methods + properties). On dev servers that's the whitelist-free
* `/api/v1/debug`; older/production servers fall back to the `/api/v2`
* whitelist set (`probeRpcBasePath` decides). Paired with `serviceByName`,
* each descriptor materializes 1:1 into a typed proxy of the channel layer:
* same channel name, same scope route, methods invoked by reflection.
*/
import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
import { RPCError } from './errors';
import type { InspectClient } from './client';
import type { ServiceProxy } from './channel';
/** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */
export type ChannelScope = 'app' | 'session' | 'agent';
/** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v2/channels`). */
export interface ChannelDescriptor {
readonly name: string;
readonly scope: ChannelScope;
readonly domain: string;
readonly methods: readonly {
readonly name: string;
readonly kind: 'method' | 'property';
readonly arity: number;
readonly params: string;
}[];
}
/** Fetch the dynamic channel list (unwrapped from the project envelope),
* from whichever RPC surface the connection probed (`rpcBasePath`). */
export async function fetchChannelDescriptors(
client: InspectClient,
): Promise<readonly ChannelDescriptor[]> {
const headers: Record<string, string> = {};
if (client.token !== undefined && client.token !== '') {
headers['authorization'] = `Bearer ${client.token}`;
}
const res = await fetch(`${client.baseUrl}${client.rpcBasePath}/channels`, { headers });
const envelope = (await res.json()) as {
code: number;
msg: string;
data: readonly ChannelDescriptor[];
};
if (envelope.code !== 0) throw new RPCError(envelope.code, envelope.msg);
return envelope.data;
}
/** The dev server's whitelist-free debug surface (`--debug-endpoints`). */
export const DEBUG_RPC_BASE = '/api/v1/debug' as const;
/** The stable whitelist RPC surface — fallback when debug is not mounted. */
export const V2_RPC_BASE = '/api/v2' as const;
export type RpcBasePath = typeof DEBUG_RPC_BASE | typeof V2_RPC_BASE;
/**
* Probe which RPC surface a server offers: dev servers started with
* `--debug-endpoints` answer `/api/v1/debug/channels`; older/production
* servers only the whitelisted `/api/v2`. Always resolves (fallback `/api/v2`).
*/
export async function probeRpcBasePath(options: {
readonly baseUrl: string;
readonly token?: string;
}): Promise<RpcBasePath> {
try {
const headers: Record<string, string> = {};
if (options.token !== undefined && options.token !== '') {
headers['authorization'] = `Bearer ${options.token}`;
}
const res = await fetch(
`${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`,
{ headers },
);
if (res.ok) {
const envelope = (await res.json()) as { code?: number };
if (envelope.code === 0) return DEBUG_RPC_BASE;
}
} catch {
// fall through to the v2 fallback
}
return V2_RPC_BASE;
}
export interface ServiceTarget {
readonly scope: ChannelScope;
readonly sessionId?: string;
readonly agentId?: string;
}
/**
* Resolve a Service proxy by wire channel name. The DI decorator registry keys
* identifiers by name, so re-creating the decorator resolves to the same token
* the server channel registry created the name is the wire channel, which is
* all the proxy uses. Returns `undefined` when the target scope needs a
* session/agent id that isn't available.
*/
export function serviceByName<T extends object>(
client: InspectClient,
name: string,
target: ServiceTarget,
): ServiceProxy<T> | undefined {
const id = createDecorator<T>(name);
if (target.scope === 'app') return client.core(id);
if (target.sessionId === undefined) return undefined;
const base = client.session(target.sessionId);
if (target.scope === 'session') return base.service(id);
if (target.agentId === undefined) return undefined;
return base.agent(target.agentId).service(id);
}

View file

@ -0,0 +1,149 @@
/**
* Inspect client the app's `/api/v2` entry point, in the old-klient VS Code
* `ProxyChannel` model: a three-level scope entry (`core` / `session` /
* `agent`) whose every Service handle is a `makeProxy`-materialized typed
* proxy over a service-bound channel, plus the one shared `/api/v2/ws` socket
* for scope event streams and connection state.
*
* const client = createInspectClient({ url: 'http://127.0.0.1:58627' });
* await client.core(ISessionIndex).list({});
* await client.session('s1').service(ISessionMetadata).read();
* await client.session('s1').agent('main').service(IAgentRPCService).getModel();
*
* The `agent-core-v2` service token is the whole key: its type parameter `T`
* types the returned proxy, and its decorator id (`String(id)`) is the channel
* name in the URL. Calls ride HTTP (`ProxyChannel`); the proxy's `onXxx`
* emitter events and the scope streams (`events` / `interactions` /
* `interactions:resolved`) ride the one shared `WsSocket`.
*/
import type { ServiceProxy, ServiceRef } from './channel';
import { makeProxy } from './proxy';
import { ProxyChannel } from './proxyChannel';
import { WsChannel } from './wsChannel';
import {
WsSocket,
type WsScopeIds,
type WsScopeKind,
type WsSocketState,
type WsSubscription,
} from './wsSocket';
export interface InspectAgentHandle {
service<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
}
export interface InspectSessionHandle extends InspectAgentHandle {
agent(agentId: string): InspectAgentHandle;
}
export interface InspectWsAgent {
listen(stream: string, handler: (data: unknown) => void): WsSubscription;
}
export interface InspectWsSession extends InspectWsAgent {
agent(agentId: string): InspectWsAgent;
}
/** The one owned WebSocket: connection state plus per-scope stream subscriptions. */
export interface InspectWs {
readonly state: WsSocketState;
onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription;
listen(stream: string, handler: (data: unknown) => void): WsSubscription;
session(sessionId: string): InspectWsSession;
close(): void;
}
export interface InspectClient {
/** Absolute server base URL, e.g. `http://127.0.0.1:58627`. */
readonly baseUrl: string;
/** Bearer token in use, when any. */
readonly token?: string;
/** RPC base path for calls and `/channels`: `/api/v1/debug` on dev servers
* (whitelist-free), `/api/v2` otherwise. Resolved by the connection probe. */
readonly rpcBasePath: string;
core<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
session(sessionId: string): InspectSessionHandle;
ws(): InspectWs;
}
export interface InspectClientOptions {
/** Base URL of the server, e.g. `http://127.0.0.1:58627`. */
readonly url: string;
/** Optional bearer token. */
readonly token?: string;
/** RPC base path for service calls + `/channels` introspection. Default
* `/api/v2`; the connection layer probes and passes `/api/v1/debug` when
* the server mounts the dev debug surface (`--debug-endpoints`). */
readonly rpcBasePath?: string;
}
export function createInspectClient(options: InspectClientOptions): InspectClient {
const url = options.url.replace(/\/$/, '');
const rpcBasePath = options.rpcBasePath ?? '/api/v2';
const socket = new WsSocket(options);
/** Materialize a typed proxy for one Service on one scope binding. */
function proxy<T extends object>(
scopePath: string,
scope: WsScopeKind,
ids: WsScopeIds,
id: ServiceRef<T>,
): ServiceProxy<T> {
const service = String(id);
return makeProxy<T>(
new ProxyChannel(
{ baseUrl: `${url}${rpcBasePath}${scopePath}/${service}`, token: options.token },
() => new WsChannel({ socket, scope, service, ...ids }),
),
);
}
function wsListen(ids: WsScopeIds): InspectWsAgent {
return {
listen: (stream, handler) =>
socket.listen(
ids.agentId !== undefined ? 'agent' : ids.sessionId !== undefined ? 'session' : 'core',
stream,
ids,
handler,
),
};
}
const ws: InspectWs = {
get state() {
return socket.currentState;
},
onDidChangeState: (listener) => socket.onDidChangeState(listener),
...wsListen({}),
session: (sessionId) => ({
...wsListen({ sessionId }),
agent: (agentId) => wsListen({ sessionId, agentId }),
}),
close: () => {
socket.close();
},
};
return {
baseUrl: url,
token: options.token,
rpcBasePath,
core: (id) => proxy('', 'core', {}, id),
session: (sessionId) => {
const scopePath = `/session/${encodeURIComponent(sessionId)}`;
return {
service: (id) => proxy(scopePath, 'session', { sessionId }, id),
agent: (agentId) => ({
service: (subId) =>
proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, 'agent', {
sessionId,
agentId,
}, subId),
}),
};
},
ws: () => ws,
};
}

View file

@ -0,0 +1,15 @@
/**
* Client-side RPC error surfaced when the `/api/v2` envelope carries a non-zero
* `code`. Mirrors the server envelope (`{ code, msg, data, request_id }`) the
* numeric `code` is the stable branch key across the wire, not `instanceof`.
*/
export class RPCError extends Error {
constructor(
readonly code: number,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = 'RPCError';
}
}

View file

@ -0,0 +1,8 @@
export * from './channel';
export * from './channels';
export * from './client';
export * from './errors';
export * from './proxy';
export * from './proxyChannel';
export * from './wsChannel';
export * from './wsSocket';

View file

@ -0,0 +1,21 @@
/**
* Typed proxy turning an `IChannel` (bound to one Service) into a value
* satisfying that Service's interface `T` — VS Code's `ProxyChannel.toService`.
*
* Members named `onUpperCase` become channel events; every other property access
* becomes a function forwarding its complete argument array to `channel.call`
* (the dispatcher also answers property reads this way). The shared interface
* `T` is the whole contract, with no per-method allowlist or renaming.
*/
import type { IChannel, ServiceProxy } from './channel';
export function makeProxy<T extends object>(channel: IChannel): ServiceProxy<T> {
return new Proxy({} as ServiceProxy<T>, {
get(_target, prop) {
if (typeof prop !== 'string') return undefined;
if (/^on[A-Z]/.test(prop)) return channel.listen(prop);
return (...args: unknown[]) => channel.call(prop, args);
},
});
}

View file

@ -0,0 +1,81 @@
/**
* `ProxyChannel` an `IChannel` bound to one Service, routing `call`s to
* kap-server's `/api/v2` HTTP surface. Every call `POST`s the method name to
* the Service base URL with the complete argument array as the JSON body,
* then unwraps the project envelope: a non-zero `code` throws `RPCError`,
* otherwise `data` is returned. Non-function members answer as property reads
* through the same route (the dispatcher returns them as-is).
*
* `listen` cannot be served by HTTP; when the client supplies a WS binding
* (`events` factory) it is delegated to a lazily-created `WsChannel` bound to
* the same scope + Service, so the Service's `onXxx` emitter events work 1:1.
* Without a factory `listen` throws, matching the old HTTP-only channel.
*/
import type { Event, IChannel } from './channel';
import { RPCError } from './errors';
import type { WsChannel } from './wsChannel';
interface Envelope<T> {
readonly code: number;
readonly msg: string;
readonly data: T;
readonly request_id: string;
readonly details?: unknown;
}
export interface ProxyChannelOptions {
/** Service base URL, e.g. `http://127.0.0.1:58627/api/v2[/session/:sid[/agent/:aid]]/:service`. */
readonly baseUrl: string;
/** Optional bearer token. */
readonly token?: string;
/** `fetch` implementation; defaults to the global `fetch`. */
readonly fetch?: typeof fetch;
}
export class ProxyChannel implements IChannel {
private readonly baseUrl: string;
private readonly token?: string;
private readonly fetchImpl: typeof fetch;
private readonly eventsFactory?: () => WsChannel;
private eventsChannel: WsChannel | undefined;
constructor(opts: ProxyChannelOptions, events?: () => WsChannel) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
this.token = opts.token;
// Bind the global fetch: browsers throw "Illegal invocation" when the
// native function is invoked with a non-Window receiver.
this.fetchImpl = opts.fetch ?? fetch.bind(globalThis);
this.eventsFactory = events;
}
async call<T>(command: string, args: unknown[] = []): Promise<T> {
const headers: Record<string, string> = {};
let body: string | undefined;
if (args.length > 0) {
headers['content-type'] = 'application/json';
body = JSON.stringify(args);
}
if (this.token !== undefined) {
headers['authorization'] = `Bearer ${this.token}`;
}
const res = await this.fetchImpl(`${this.baseUrl}/${command}`, {
method: 'POST',
headers,
body,
});
const envelope = (await res.json()) as Envelope<T>;
if (envelope.code !== 0) {
throw new RPCError(envelope.code, envelope.msg, envelope.details);
}
return envelope.data;
}
listen<T>(event: string): Event<T> {
if (this.eventsFactory === undefined) {
throw new Error('events are not supported on this channel (no WS binding)');
}
this.eventsChannel ??= this.eventsFactory();
return this.eventsChannel.listen(event);
}
}

View file

@ -0,0 +1,79 @@
/**
* `WsChannel` an `IChannel` bound to one Service that forwards `call`s and
* `listen`s over the shared `/api/v2/ws` socket instead of HTTP. Same VS Code
* shape as `ProxyChannel` (the URL equivalent is the `{scope, service, ids}`
* triple the socket puts on each frame). `listen` multiplexes local listeners
* onto one remote subscription: the first local listener opens it, the last
* `dispose()` tears it down, and it survives reconnects until then.
*/
import type { Event, IChannel } from './channel';
import type { WsScopeIds, WsScopeKind, WsSocket } from './wsSocket';
export interface WsChannelOptions {
readonly socket: WsSocket;
readonly scope: WsScopeKind;
/** Service channel name (the decorator id, `String(id)`). */
readonly service: string;
readonly sessionId?: string;
readonly agentId?: string;
}
interface SharedEvent {
readonly listeners: Set<{ listener: (data: unknown) => unknown; thisArg: unknown }>;
remote?: { dispose(): void };
}
export class WsChannel implements IChannel {
private readonly socket: WsSocket;
private readonly scope: WsScopeKind;
private readonly service: string;
private readonly ids: WsScopeIds;
private readonly events = new Map<string, SharedEvent>();
constructor(opts: WsChannelOptions) {
this.socket = opts.socket;
this.scope = opts.scope;
this.service = opts.service;
this.ids = { sessionId: opts.sessionId, agentId: opts.agentId };
}
call<T>(command: string, args: unknown[] = []): Promise<T> {
return this.socket.call(this.scope, this.service, command, args, this.ids);
}
listen<T>(event: string): Event<T> {
let shared = this.events.get(event);
if (shared === undefined) {
shared = { listeners: new Set() };
this.events.set(event, shared);
}
return (listener, thisArg, disposables) => {
const entry = { listener: listener as (data: unknown) => unknown, thisArg };
shared.listeners.add(entry);
shared.remote ??= this.socket.listen(
this.scope,
event,
this.ids,
(data) => {
for (const current of shared.listeners) current.listener.call(current.thisArg, data);
},
this.service,
);
let disposed = false;
const subscription = {
dispose: (): void => {
if (disposed) return;
disposed = true;
shared.listeners.delete(entry);
if (shared.listeners.size === 0) {
shared.remote?.dispose();
shared.remote = undefined;
}
},
};
disposables?.push(subscription);
return subscription;
};
}
}

View file

@ -0,0 +1,456 @@
/**
* `/api/v2/ws` socket the persistent WebSocket transport behind the inspect
* client's `WsChannel` (Service emitter `listen`s) and scope event streams.
*
* Speaks the kap-server v2 JSON protocol: one socket multiplexes RPC `call`s
* and event `listen`s, correlated by client-chosen ids. Adds the client-side
* safety features a long-lived devtool connection needs: `hello` handshake,
* `ping``pong` heartbeat answers, per-call timeouts, and opt-out automatic
* reconnect (active `listen`s are re-subscribed after a reconnect; in-flight
* calls reject on close the server cannot resume them).
*
* The bearer token is presented at the upgrade through the
* `kimi-code.bearer.<token>` subprotocol (the only credential channel a browser
* WebSocket has) and again in the `hello` frame for the present-only handshake
* check. Works against the DOM WebSocket (browsers, Node 21); any compatible
* implementation can be injected for tests.
*/
import { RPCError } from './errors';
/** Wire scope kinds, mirroring kap-server's `ScopeKind`. */
export type WsScopeKind = 'core' | 'session' | 'agent';
/** Scope coordinates carried on `call` / `listen` frames. */
export interface WsScopeIds {
readonly sessionId?: string;
readonly agentId?: string;
}
export type WsSocketState = 'connecting' | 'open' | 'closed';
export interface WsSubscription {
dispose(): void;
}
/** Minimal DOM-compatible WebSocket surface this module codes against. */
export interface WsLike {
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: never) => void): void;
}
export interface WsLikeCtor {
new (url: string, protocols?: string | string[]): WsLike;
readonly OPEN: number;
}
export interface WsSocketOptions {
/** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v2/ws` URL. */
readonly url: string;
/** Optional bearer token. */
readonly token?: string;
/** WebSocket implementation; defaults to the global `WebSocket`. */
readonly WebSocketImpl?: WsLikeCtor;
/** Reconnect after an unexpected close. Default `true`. */
readonly autoReconnect?: boolean;
/** Base delay (ms) for the reconnect backoff. Default `500`. */
readonly reconnectDelayMs?: number;
/** Per-call deadline (ms). Default `30000`. */
readonly callTimeoutMs?: number;
}
interface PendingCall {
readonly resolve: (data: unknown) => void;
readonly reject: (err: Error) => void;
readonly timer: ReturnType<typeof setTimeout> | undefined;
}
interface ActiveListen {
readonly scope: WsScopeKind;
readonly service?: string;
readonly event: string;
readonly ids: WsScopeIds;
readonly handler: (data: unknown) => void;
readonly onError?: (error: Error) => void;
acknowledged: boolean;
}
export interface WsListenError {
readonly scope: WsScopeKind;
readonly service?: string;
readonly event: string;
readonly error: Error;
}
interface ServerFrame {
readonly type: string;
readonly id?: string;
readonly data?: unknown;
readonly code?: number;
readonly msg?: string;
readonly eventId?: string;
}
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
export class WsSocket {
private readonly wsUrl: string;
private readonly token?: string;
private readonly WsCtor: WsLikeCtor;
private readonly autoReconnect: boolean;
private readonly reconnectDelayMs: number;
private readonly callTimeoutMs: number;
private ws: WsLike | undefined;
private state: WsSocketState = 'connecting';
private manualClose = false;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private readyWaiters: { resolve: () => void; reject: (err: Error) => void }[] = [];
private readonly pending = new Map<string, PendingCall>();
private readonly listens = new Map<string, ActiveListen>();
private readonly eventControllers = new Map<string, AbortController>();
private readonly stateListeners = new Set<(state: WsSocketState) => void>();
private readonly listenErrorListeners = new Set<(event: WsListenError) => void>();
private seq = 0;
private readonly idPrefix = `k${Date.now().toString(36)}`;
constructor(opts: WsSocketOptions) {
this.wsUrl = toWsUrl(opts.url);
this.token = opts.token;
const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined);
if (ctor === undefined) {
throw new Error('no WebSocket implementation available; pass WebSocketImpl');
}
this.WsCtor = ctor;
this.autoReconnect = opts.autoReconnect ?? true;
this.reconnectDelayMs = opts.reconnectDelayMs ?? 500;
this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
this.connect();
}
get currentState(): WsSocketState {
return this.state;
}
onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription {
this.stateListeners.add(listener);
return { dispose: () => this.stateListeners.delete(listener) };
}
onDidListenError(listener: (event: WsListenError) => void): WsSubscription {
this.listenErrorListeners.add(listener);
return { dispose: () => this.listenErrorListeners.delete(listener) };
}
/** RPC call over the socket; rejects on `error` frame, timeout, or close. */
async call<T>(
scope: WsScopeKind,
service: string,
method: string,
arg?: unknown,
ids?: WsScopeIds,
): Promise<T> {
await this.whenReady();
// The socket may have dropped between `whenReady` resolving and this
// continuation running; never register a call we cannot send.
if (this.state !== 'open') {
throw new Error('ws closed');
}
const id = this.nextId();
const promise = new Promise<T>((resolve, reject) => {
const timer =
this.callTimeoutMs > 0
? setTimeout(() => {
this.pending.delete(id);
reject(new RPCError(50001, `call timed out after ${this.callTimeoutMs}ms`));
}, this.callTimeoutMs)
: undefined;
this.pending.set(id, {
resolve: resolve as (data: unknown) => void,
reject,
timer,
});
});
this.send({ type: 'call', id, scope, service, method, arg, ...ids });
return promise;
}
/**
* Subscribe to a scope event stream. The subscription survives reconnects
* (re-sent after each reconnect) until `dispose()`d.
*/
listen(
scope: WsScopeKind,
event: string,
ids: WsScopeIds,
handler: (data: unknown) => void,
service?: string,
onError?: (error: Error) => void,
): WsSubscription {
const id = this.nextId();
this.listens.set(id, { scope, service, event, ids, handler, onError, acknowledged: false });
if (this.state === 'open') {
this.send({ type: 'listen', id, scope, service, event, ...ids });
}
return {
dispose: () => {
if (!this.listens.delete(id)) return;
if (this.state === 'open') {
this.send({ type: 'unlisten', id });
}
},
};
}
/** Tear the socket down permanently; rejects in-flight calls. */
close(): void {
this.manualClose = true;
if (this.reconnectTimer !== undefined) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
this.setState('closed');
this.ws?.close();
this.ws = undefined;
this.failAll(new Error('ws closed'));
this.rejectReadyWaiters(new Error('ws closed'));
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
private nextId(): string {
this.seq += 1;
return `${this.idPrefix}_${this.seq}`;
}
private connect(): void {
this.setState('connecting');
const protocols =
this.token !== undefined && this.token.length > 0
? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`]
: undefined;
let ws: WsLike;
try {
ws = new this.WsCtor(this.wsUrl, protocols);
} catch (error) {
this.scheduleReconnect(error);
return;
}
this.ws = ws;
ws.addEventListener('open', () => {
this.onOpen();
});
ws.addEventListener('message', (event: { data: unknown }) => {
this.onMessage(event.data);
});
ws.addEventListener('close', () => {
this.onClose();
});
ws.addEventListener('error', () => {
// The 'close' event always follows 'error'; reconnect logic lives there.
});
}
private onOpen(): void {
this.reconnectAttempt = 0;
this.setState('open');
this.send({ type: 'hello', token: this.token });
for (const [id, sub] of this.listens) {
this.send({
type: 'listen',
id,
scope: sub.scope,
service: sub.service,
event: sub.event,
...sub.ids,
});
}
const waiters = this.readyWaiters;
this.readyWaiters = [];
for (const w of waiters) w.resolve();
}
private onMessage(raw: unknown): void {
let frame: ServerFrame;
try {
frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame;
} catch {
return;
}
switch (frame.type) {
case 'ready':
case 'server_hello':
return;
case 'ping':
this.send({ type: 'pong' });
return;
case 'result': {
const p = this.take(frame.id);
p?.resolve(frame.data);
return;
}
case 'error': {
const p = this.take(frame.id);
if (p !== undefined) {
p.reject(new RPCError(frame.code ?? 50001, frame.msg ?? 'error'));
} else {
const sub = this.listens.get(frame.id ?? '');
if (sub !== undefined) {
this.listens.delete(frame.id ?? '');
const error = new RPCError(frame.code ?? 50001, frame.msg ?? 'error');
sub.onError?.(error);
queueMicrotask(() => {
for (const listener of this.listenErrorListeners) {
listener({ scope: sub.scope, service: sub.service, event: sub.event, error });
}
});
}
}
return;
}
case 'listen_result': {
const sub = this.listens.get(frame.id ?? '');
if (sub !== undefined) sub.acknowledged = true;
return;
}
case 'event': {
const sub = this.listens.get(frame.id ?? '');
if (sub === undefined) return;
if (frame.eventId === undefined) {
sub.handler(frame.data);
return;
}
const controller = new AbortController();
const eventKey = `${frame.id}:${frame.eventId}`;
this.eventControllers.set(eventKey, controller);
const waits: Promise<unknown>[] = [];
const data = {
...(frame.data as object),
signal: controller.signal,
waitUntil: (promise: Promise<unknown>) => waits.push(promise),
};
try {
sub.handler(data);
} catch {
// Listener failures are fail-open for the server-side event.
}
void Promise.allSettled(waits).finally(() => {
if (!this.eventControllers.delete(eventKey)) return;
this.send({ type: 'event_result', id: frame.id, eventId: frame.eventId });
});
return;
}
case 'event_cancel': {
const key = `${frame.id}:${frame.eventId}`;
const controller = this.eventControllers.get(key);
if (controller !== undefined) {
this.eventControllers.delete(key);
controller.abort();
}
return;
}
}
}
private onClose(): void {
this.ws = undefined;
for (const controller of this.eventControllers.values()) controller.abort();
this.eventControllers.clear();
this.failAll(new Error('ws closed'));
if (this.manualClose || !this.autoReconnect) {
this.setState('closed');
this.rejectReadyWaiters(new Error('ws closed'));
return;
}
// Transient drop: queued calls keep waiting for the reconnect.
this.scheduleReconnect(undefined);
}
private scheduleReconnect(_cause: unknown): void {
if (this.manualClose || !this.autoReconnect) {
this.setState('closed');
return;
}
this.reconnectAttempt += 1;
const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000);
this.setState('connecting');
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
this.connect();
}, delay);
this.reconnectTimer.unref?.();
}
private whenReady(): Promise<void> {
if (this.state === 'open') return Promise.resolve();
if (this.state === 'closed' && this.manualClose) {
return Promise.reject(new Error('ws closed'));
}
return new Promise<void>((resolve, reject) => {
this.readyWaiters.push({ resolve, reject });
});
}
private rejectReadyWaiters(err: Error): void {
const waiters = this.readyWaiters;
this.readyWaiters = [];
for (const w of waiters) w.reject(err);
}
private take(id: string | undefined): PendingCall | undefined {
const p = this.pending.get(id ?? '');
if (p !== undefined) {
this.pending.delete(id ?? '');
if (p.timer !== undefined) clearTimeout(p.timer);
}
return p;
}
private failAll(err: Error): void {
for (const p of this.pending.values()) {
if (p.timer !== undefined) clearTimeout(p.timer);
p.reject(err);
}
this.pending.clear();
}
private send(frame: Record<string, unknown>): void {
const ws = this.ws;
if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return;
try {
ws.send(JSON.stringify(frame));
} catch {
// best-effort; the close handler handles teardown
}
}
private setState(next: WsSocketState): void {
if (this.state === next) return;
this.state = next;
for (const listener of this.stateListeners) listener(next);
}
}
/** Derive the `/api/v2/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */
function toWsUrl(base: string): string {
const url = new URL(base);
if (url.protocol === 'http:') url.protocol = 'ws:';
else if (url.protocol === 'https:') url.protocol = 'wss:';
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
throw new Error(`unsupported URL scheme for WS transport: ${base}`);
}
if (!url.pathname.endsWith('/api/v2/ws')) {
url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v2/ws`;
}
url.search = '';
url.hash = '';
return url.toString();
}

View file

@ -0,0 +1,333 @@
/**
* Main view the conversation of the active session + agent.
*
* History comes from `IAgentContextMemoryService.get()` (the authoritative
* context); live turns stream in through the agent `events` subscription
* (`assistant.delta` / `thinking.delta` / `tool.*`) as ephemeral entries. On
* `turn.ended` the history is refetched and the ephemeral layer is dropped, so
* the view always converges to the authoritative context. Prompts go out via
* `IAgentRPCService.prompt`; `cancel` aborts the running turn.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory';
import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc';
import { useConnection } from '../connection';
import { eventType, payloadField, useLiveEvent } from '../live';
import { ActionButton, Badge, ErrorLine } from '../ui';
interface ChatEntry {
readonly id: string;
readonly kind: 'user' | 'assistant' | 'thinking' | 'tool' | 'error';
text: string;
name?: string;
args?: string;
output?: string;
isError?: boolean;
}
interface HistoryMessage {
readonly role?: string;
readonly content?: readonly { type?: string; text?: string; think?: string }[];
readonly toolCalls?: readonly { id?: string; name?: string; arguments?: string | null }[];
readonly isError?: boolean;
}
function mapHistory(messages: readonly HistoryMessage[]): ChatEntry[] {
const entries: ChatEntry[] = [];
messages.forEach((m, i) => {
const parts = m.content ?? [];
const text = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('\n');
const think = parts
.filter((p) => p.type === 'think')
.map((p) => p.think ?? p.text ?? '')
.join('\n');
if (m.role === 'user') {
entries.push({ id: `h${i}`, kind: 'user', text: text || '[non-text content]' });
} else if (m.role === 'assistant') {
if (think !== '') entries.push({ id: `h${i}t`, kind: 'thinking', text: think });
if (text !== '') entries.push({ id: `h${i}a`, kind: 'assistant', text });
for (const call of m.toolCalls ?? []) {
entries.push({
id: `h${i}c${call.id ?? ''}`,
kind: 'tool',
text: '',
name: call.name ?? 'tool',
args: call.arguments ?? undefined,
});
}
} else if (m.role === 'tool') {
entries.push({
id: `h${i}r`,
kind: 'tool',
text: '',
name: 'result',
output: text,
isError: m.isError,
});
}
// system / developer messages (the system prompt) are not shown.
});
return entries;
}
export function ChatView({
sessionId,
agentId,
ready,
}: {
sessionId: string | null;
agentId: string;
ready: boolean;
}) {
const { klient } = useConnection();
const queryClient = useQueryClient();
const [stream, setStream] = useState<ChatEntry[]>([]);
const [input, setInput] = useState('');
const [running, setRunning] = useState(false);
const [sendError, setSendError] = useState<unknown>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const enabled = sessionId !== null && ready;
const history = useQuery({
queryKey: ['history', sessionId, agentId],
queryFn: () =>
klient
.session(sessionId as string)
.agent(agentId)
.service(IAgentContextMemoryService)
.get(),
enabled,
});
const refetchHistory = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const scheduleRefetch = () => {
if (refetchHistory.current !== undefined) clearTimeout(refetchHistory.current);
refetchHistory.current = setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: ['history', sessionId, agentId] });
setStream([]);
setRunning(false);
}, 500);
};
useLiveEvent((event) => {
if (event.source !== 'agent') return;
const type = eventType(event);
const data = event.data as Record<string, unknown>;
switch (type) {
case 'turn.started':
setRunning(true);
return;
case 'assistant.delta':
case 'thinking.delta': {
const turnId = payloadField(data, 'turnId', '?');
const kind = type === 'assistant.delta' ? 'assistant' : 'thinking';
const id = `stream:${turnId}:${kind}`;
const delta = payloadField(data, 'delta', '');
setStream((prev) => {
const found = prev.find((e) => e.id === id);
if (found === undefined) return [...prev, { id, kind, text: delta }];
return prev.map((e) => (e.id === id ? { ...e, text: e.text + delta } : e));
});
return;
}
case 'tool.call.started': {
const callId = payloadField(data, 'toolCallId', String(Math.random()));
setStream((prev) => [
...prev,
{
id: `stream:tool:${callId}`,
kind: 'tool',
text: '',
name: payloadField(data, 'name', 'tool'),
args: typeof data['args'] === 'string' ? data['args'] : JSON.stringify(data['args'] ?? ''),
},
]);
return;
}
case 'tool.result': {
const callId = payloadField(data, 'toolCallId', '');
const output = typeof data['output'] === 'string' ? data['output'] : JSON.stringify(data['output']);
const id = `stream:tool:${callId}`;
setStream((prev) => {
const found = prev.find((e) => e.id === id);
if (found === undefined) {
return [...prev, { id, kind: 'tool', text: '', name: 'result', output, isError: Boolean(data['isError']) }];
}
return prev.map((e) => (e.id === id ? { ...e, output, isError: Boolean(data['isError']) } : e));
});
return;
}
case 'turn.ended':
case 'prompt.completed':
case 'prompt.aborted':
case 'compaction.completed':
scheduleRefetch();
return;
case 'error': {
const message =
typeof data['message'] === 'string'
? data['message']
: JSON.stringify(data).slice(0, 300);
setStream((prev) => [
...prev,
{ id: `stream:err:${Date.now()}`, kind: 'error', text: message },
]);
setRunning(false);
return;
}
}
});
const entries = useMemo(
() => [...mapHistory((history.data ?? []) as readonly HistoryMessage[]), ...stream],
[history.data, stream],
);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: 'end' });
}, [entries.length, stream]);
const send = async () => {
if (sessionId === null || input.trim() === '' || running) return;
const text = input.trim();
setInput('');
setSendError(null);
setStream((prev) => [...prev, { id: `optimistic:${Date.now()}`, kind: 'user', text }]);
try {
await klient
.session(sessionId)
.agent(agentId)
.service(IAgentRPCService)
.prompt({ input: [{ type: 'text', text }] });
} catch (error) {
setSendError(error);
}
};
const cancel = async () => {
if (sessionId === null) return;
try {
await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({});
} catch (error) {
setSendError(error);
}
};
if (sessionId === null) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
Select a session on the left to open its conversation.
</div>
);
}
if (!ready) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
Loading session
</div>
);
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
<span className="font-mono text-[11px] text-neutral-400">{sessionId}</span>
<Badge tone="sky">agent: {agentId}</Badge>
{running ? <Badge tone="amber">turn running</Badge> : <Badge tone="green">idle</Badge>}
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
{history.isError ? <ErrorLine error={history.error} /> : null}
{entries.length === 0 && !history.isLoading ? (
<div className="text-[12px] text-neutral-600 italic">Empty context send a prompt below.</div>
) : null}
{entries.map((entry) => (
<EntryView key={entry.id} entry={entry} />
))}
<div ref={bottomRef} />
</div>
<div className="border-t border-neutral-800 p-3">
{sendError !== null ? <div className="mb-2"><ErrorLine error={sendError} /></div> : null}
<div className="flex gap-2">
<textarea
className="min-h-[40px] flex-1 resize-y rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
placeholder="Send a prompt to the active agent… (Enter to send, Shift+Enter for newline)"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<div className="flex flex-col gap-2">
<ActionButton onClick={() => void send()} disabled={running || input.trim() === ''}>
Send
</ActionButton>
<ActionButton onClick={() => void cancel()} danger disabled={!running}>
Cancel
</ActionButton>
</div>
</div>
</div>
</div>
);
}
function EntryView({ entry }: { entry: ChatEntry }) {
if (entry.kind === 'user') {
return (
<div className="mb-3 flex justify-end">
<div className="max-w-[80%] whitespace-pre-wrap rounded-lg bg-sky-900/40 px-3 py-2 text-[13px] text-neutral-100">
{entry.text}
</div>
</div>
);
}
if (entry.kind === 'thinking') {
return (
<div className="mb-3 max-w-[85%] whitespace-pre-wrap rounded-lg border border-dashed border-neutral-700 px-3 py-2 font-mono text-[11px] text-neutral-500">
{entry.text}
</div>
);
}
if (entry.kind === 'tool') {
return (
<div className="mb-3 max-w-[85%] rounded-lg border border-neutral-800 bg-neutral-900/50 px-3 py-2 font-mono text-[11px]">
<div className="mb-1 flex items-center gap-2">
<Badge tone={entry.isError ? 'red' : 'neutral'}>tool</Badge>
<span className="text-neutral-300">{entry.name}</span>
</div>
{entry.args !== undefined ? (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">{entry.args}</pre>
) : null}
{entry.output !== undefined ? (
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap ${entry.isError ? 'text-red-400' : 'text-neutral-400'}`}>
{entry.output}
</pre>
) : null}
</div>
);
}
if (entry.kind === 'error') {
return (
<div className="mb-3 max-w-[85%] rounded-lg bg-red-950/50 px-3 py-2 text-[12px] text-red-400">
{entry.text}
</div>
);
}
return (
<div className="mb-3 max-w-[85%] whitespace-pre-wrap rounded-lg bg-neutral-800/60 px-3 py-2 text-[13px] text-neutral-100">
{entry.text}
</div>
);
}

View file

@ -0,0 +1,661 @@
/**
* Right sidebar access point for the Services of the server (app scope),
* the active session, and the active agent. Hosts the agent switcher, four
* tabs (app / session / agent / events), the live pending-interaction card
* (driven by the session `interactions` stream), and the Service panels.
*
* The panel list is dynamic: `GET /api/v2/channels` describes every
* wire-exposed Service with its methods, rendered by `DynamicServiceCard`;
* the handwritten descriptors in `panels.ts` override individual Services
* with curated cards (`ServiceCard`).
*/
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction';
import {
fetchChannelDescriptors,
serviceByName,
type ChannelDescriptor,
} from '../channel';
import { useConnection } from '../connection';
import { eventType, payloadField, useLiveEvent, useRecentEvents, type LiveEvent } from '../live';
import {
AGENT_PANELS,
CORE_PANELS,
SESSION_PANELS,
call,
type AnyService,
type ServicePanelDef,
} from '../panels';
import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui';
type Tab = 'app' | 'session' | 'agent' | 'events';
type Scope = 'app' | 'session' | 'agent';
const PANEL_OVERRIDES: ReadonlyMap<string, ServicePanelDef> = new Map(
[...CORE_PANELS, ...SESSION_PANELS, ...AGENT_PANELS].map((def) => [def.id, def]),
);
/** Load the full protocol list once per connection (every channel, 1:1). */
function useChannels() {
const { klient } = useConnection();
return useQuery({
queryKey: ['channels', klient.baseUrl, klient.rpcBasePath],
queryFn: () => fetchChannelDescriptors(klient),
staleTime: Number.POSITIVE_INFINITY,
});
}
export function Inspector({
sessionId,
agentId,
onAgentChange,
ready,
}: {
sessionId: string | null;
agentId: string;
onAgentChange: (agentId: string) => void;
ready: boolean;
}) {
const { klient } = useConnection();
const [tab, setTab] = useState<Tab>('session');
const channels = useChannels();
const meta = useQuery({
queryKey: ['sessionMeta', sessionId],
queryFn: () => klient.session(sessionId as string).service(ISessionMetadata).read(),
enabled: sessionId !== null && ready,
});
const agentIds = useMemo(() => {
const ids = Object.keys(meta.data?.agents ?? {});
if (ids.length === 0) return ['main'];
return ['main', ...ids.filter((id) => id !== 'main')].filter(
(id, i, all) => all.indexOf(id) === i,
);
}, [meta.data]);
// Keep the selected agent valid as the registry changes.
const effectiveAgent = agentIds.includes(agentId) ? agentId : agentIds[0]!;
useEffect(() => {
if (effectiveAgent !== agentId) onAgentChange(effectiveAgent);
}, [effectiveAgent, agentId, onAgentChange]);
// Subagents stay in the metadata registry even when their scope is not
// materialized in this process (created before a restart, or disposed on
// session close), so the switcher lists entries that cannot be called.
// Mark one as "not loaded" when an agent-scope call comes back with
// `agent.not_found` (message names the agent).
const [stoppedAgents, setStoppedAgents] = useState<ReadonlySet<string>>(new Set());
useEffect(() => setStoppedAgents(new Set()), [sessionId]);
const noteAgentError = (agent: string, error: unknown) => {
if (error instanceof Error && error.message.includes('not found in session')) {
setStoppedAgents((prev) => (prev.has(agent) ? prev : new Set(prev).add(agent)));
}
};
// Resolve a Service proxy by channel name + scope, 1:1 with the channel
// descriptor from `/api/v2/channels`. Returns null when the scope needs a
// session that isn't selected/ready.
const serviceProxy = useMemo(() => {
return (name: string, scope: Scope): AnyService | null => {
return serviceByName<AnyService>(klient, name, {
scope,
sessionId: sessionId !== null && ready ? sessionId : undefined,
agentId: effectiveAgent,
}) ?? null;
};
}, [klient, sessionId, effectiveAgent, ready]);
// Panels for one scope: the dynamic channel list merged with the handwritten
// overrides. When the channels endpoint is unavailable (older server), fall
// back to the handwritten panels only.
const renderPanels = (scope: Scope) => {
const byName = new Map<string, ChannelDescriptor | undefined>();
if (channels.data !== undefined) {
for (const c of channels.data) {
if (c.scope === scope) byName.set(c.name, c);
}
// Keep overrides the introspection missed (e.g. server drift).
for (const def of PANEL_OVERRIDES.values()) {
if (def.scope === scope && !byName.has(def.id)) byName.set(def.id, undefined);
}
} else {
for (const def of PANEL_OVERRIDES.values()) {
if (def.scope === scope) byName.set(def.id, undefined);
}
}
const list = [...byName.entries()];
return (
<>
{channels.isError ? (
<div className="mb-2">
<ErrorLine error={channels.error} />
<div className="mt-1 text-[10px] text-neutral-600">
dynamic channel list unavailable showing handwritten panels only
</div>
</div>
) : null}
{list.map(([name, channel]) => {
const def = PANEL_OVERRIDES.get(name);
const onError =
scope === 'agent' ? (error: unknown) => noteAgentError(effectiveAgent, error) : undefined;
if (def !== undefined) {
return (
<ServiceCard
key={name}
def={def}
svc={serviceProxy(name, scope)}
onError={onError}
/>
);
}
if (channel === undefined) return null;
return (
<DynamicServiceCard
key={name}
channel={channel}
svc={serviceProxy(name, scope)}
onError={onError}
/>
);
})}
</>
);
};
const sessionBlocked = sessionId === null || !ready;
return (
<div className="flex h-full w-[420px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30">
{/* Agent switcher */}
{sessionId !== null ? (
<div className="border-b border-neutral-800 px-3 py-2">
<label className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
Active agent
</label>
<select
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-100 outline-none focus:border-sky-600"
value={effectiveAgent}
onChange={(e) => onAgentChange(e.target.value)}
>
{agentIds.map((id) => (
<option key={id} value={id}>
{stoppedAgents.has(id) ? `${id} (not loaded)` : id}
</option>
))}
</select>
{stoppedAgents.has(effectiveAgent) ? (
<div className="mt-1 text-[10px] text-neutral-600">
this agent is not materialized in the running server (e.g. created before a
restart) calls will fail; its persisted records remain on disk
</div>
) : null}
{meta.isError ? <div className="mt-1"><ErrorLine error={meta.error} /></div> : null}
</div>
) : null}
{/* Tabs */}
<div className="flex border-b border-neutral-800 text-[11px]">
{(['app', 'session', 'agent', 'events'] as const).map((t) => (
<button
key={t}
className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
}`}
onClick={() => setTab(t)}
>
{t === 'app' ? 'App' : t === 'session' ? 'Session' : t === 'agent' ? 'Agent' : 'Events'}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-3">
{tab === 'app' ? (
renderPanels('app')
) : tab === 'events' ? (
<EventLog />
) : sessionBlocked ? (
<div className="text-[12px] text-neutral-600">
{sessionId === null ? 'No session selected.' : 'Loading session…'}
</div>
) : tab === 'session' ? (
<>
<InteractionsCard sessionId={sessionId} />
{renderPanels('session')}
</>
) : (
renderPanels('agent')
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Generic service card
// ---------------------------------------------------------------------------
function ServiceCard({
def,
svc,
onError,
}: {
def: ServicePanelDef;
svc: AnyService | null;
onError?: (error: unknown) => void;
}) {
const [data, setData] = useState<unknown>(undefined);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const refresh = async () => {
if (svc === null || def.fetch === undefined) return;
try {
setError(null);
const result = await def.fetch(svc);
setData(result);
setLoaded(true);
} catch (error) {
setError(error);
onError?.(error);
}
};
// Refetch on matching live events (app-scope panels ride the `core` stream).
useLiveEvent((event: LiveEvent) => {
if (def.refreshOn === undefined || svc === null) return;
const source = def.scope === 'app' ? 'core' : def.scope;
if (event.source !== source) return;
const type = eventType(event);
if (def.refreshOn.some((prefix) => type.startsWith(prefix))) void refresh();
});
return (
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
<div className="flex items-center justify-between border-b border-neutral-800/60 px-3 py-2">
<div>
<span className="text-[12px] font-medium text-neutral-200">{def.label}</span>
<span className="ml-2 font-mono text-[10px] text-neutral-600">{def.id}</span>
</div>
{def.fetch !== undefined ? (
<ActionButton onClick={() => void refresh()} disabled={svc === null}>
{loaded ? 'Refresh' : 'Load'}
</ActionButton>
) : null}
</div>
<div className="px-3 py-2">
{error !== null ? <div className="mb-2"><ErrorLine error={error} /></div> : null}
{def.fetch !== undefined ? (
loaded ? (
<JsonView data={data} />
) : (
<div className="text-[11px] text-neutral-600 italic">click Load to read this Service</div>
)
) : null}
{def.actions !== undefined && def.actions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{def.actions.map((action) => (
<ActionButton
key={action.label}
danger={action.danger}
disabled={svc === null || busy !== null}
onClick={async () => {
if (svc === null) return;
let input: string | undefined;
if (action.input !== undefined) {
const raw = window.prompt(action.input);
if (raw === null) return;
input = raw;
}
setBusy(action.label);
setError(null);
try {
const result = await action.run(svc, input);
if (result !== undefined && def.fetch === undefined) setData(result);
if (def.fetch !== undefined) await refresh();
} catch (error) {
setError(error);
onError?.(error);
} finally {
setBusy(null);
}
}}
>
{busy === action.label ? '…' : action.label}
</ActionButton>
))}
</div>
) : null}
{def.fetch === undefined && data !== undefined ? (
<div className="mt-2"><JsonView data={data} /></div>
) : null}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Dynamic service card — generic renderer for channels without a handwritten
// override. Every method gets a call button labeled with its declared
// signature (JSON arg input when it takes parameters); getters become read
// buttons. Results render inline.
// ---------------------------------------------------------------------------
function DynamicServiceCard({
channel,
svc,
onError,
}: {
channel: ChannelDescriptor;
svc: AnyService | null;
onError?: (error: unknown) => void;
}) {
const [open, setOpen] = useState(false);
const [args, setArgs] = useState<Record<string, string>>({});
const [results, setResults] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, unknown>>({});
const [busy, setBusy] = useState<string | null>(null);
const invoke = async (method: ChannelDescriptor['methods'][number]) => {
if (svc === null) return;
let arg: unknown;
if (method.kind === 'method' && method.params !== '') {
const raw = (args[method.name] ?? '').trim();
if (raw !== '') {
try {
arg = JSON.parse(raw);
} catch {
setErrors((prev) => ({ ...prev, [method.name]: new Error('arg is not valid JSON') }));
return;
}
}
}
setBusy(method.name);
setErrors((prev) => ({ ...prev, [method.name]: null }));
try {
const result = await call(svc, method.name, arg);
setResults((prev) => ({ ...prev, [method.name]: result ?? '(no result)' }));
} catch (error) {
setErrors((prev) => ({ ...prev, [method.name]: error }));
onError?.(error);
} finally {
setBusy(null);
}
};
return (
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
<div
className="flex cursor-pointer items-center justify-between px-3 py-2 select-none"
onClick={() => setOpen((v) => !v)}
>
<div>
<span className="text-[12px] font-medium text-neutral-300">{channel.name}</span>
<span className="ml-2 text-[10px] text-neutral-600">
{channel.methods.length} methods · {channel.domain}
</span>
</div>
<span className="text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
</div>
{open ? (
<div className="border-t border-neutral-800/60 px-3 py-2">
{channel.methods.length === 0 ? (
<div className="text-[11px] text-neutral-600 italic">no callable members</div>
) : null}
{channel.methods.map((m) => (
<div key={m.name} className="mb-1.5 last:mb-0">
<div className="flex items-center gap-1.5">
<ActionButton
disabled={svc === null || busy !== null}
onClick={() => void invoke(m)}
>
{busy === m.name ? '…' : `${m.name}(${m.params})`}
</ActionButton>
{m.kind === 'property' ? <Badge tone="neutral">get</Badge> : null}
{m.kind === 'method' && m.params !== '' ? (
<input
className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
placeholder="arg (JSON)"
value={args[m.name] ?? ''}
onChange={(e) =>
setArgs((prev) => ({ ...prev, [m.name]: e.target.value }))
}
/>
) : null}
</div>
{errors[m.name] ? (
<div className="mt-1">
<ErrorLine error={errors[m.name]} />
</div>
) : null}
{results[m.name] !== undefined ? (
<div className="mt-1">
<JsonView data={results[m.name]} />
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// Pending interactions (approvals / questions), live via the session stream
// ---------------------------------------------------------------------------
interface PendingInteraction {
readonly id: string;
/** Known kinds: 'approval' | 'question' | 'user_tool'; other kinds may appear. */
readonly kind: string;
readonly payload: Record<string, unknown>;
readonly createdAt: number;
}
function InteractionsCard({ sessionId }: { sessionId: string }) {
const { klient } = useConnection();
const [pending, setPending] = useState<readonly PendingInteraction[]>([]);
const [error, setError] = useState<unknown>(null);
const interaction = klient.session(sessionId).service(ISessionInteractionService);
const approval = klient.session(sessionId).service(ISessionApprovalService);
const question = klient.session(sessionId).service(ISessionQuestionService);
const reload = async () => {
try {
setError(null);
setPending((await interaction.listPending()) as readonly PendingInteraction[]);
} catch (error) {
setError(error);
}
};
// The `interactions` stream pushes the full pending set on every change.
useLiveEvent((event) => {
if (event.source !== 'session') return;
if (Array.isArray(event.data)) setPending(event.data as readonly PendingInteraction[]);
});
const decide = async (id: string, decision: 'approved' | 'rejected') => {
try {
await approval.decide(id, { decision });
} catch (error) {
setError(error);
}
};
const answer = async (id: string, q: string, value: string) => {
try {
await question.answer(id, { answers: { [q]: value } });
} catch (error) {
setError(error);
}
};
const dismiss = async (id: string) => {
try {
await question.dismiss(id);
} catch (error) {
setError(error);
}
};
return (
<div className="mb-3 rounded-lg border border-amber-900/50 bg-amber-950/20">
<div className="flex items-center justify-between border-b border-amber-900/40 px-3 py-2">
<span className="text-[12px] font-medium text-amber-200">
Pending interactions {pending.length > 0 ? `(${pending.length})` : ''}
</span>
<ActionButton onClick={() => void reload()}>Load</ActionButton>
</div>
<div className="px-3 py-2">
{error !== null ? <div className="mb-2"><ErrorLine error={error} /></div> : null}
{pending.length === 0 ? (
<div className="text-[11px] text-neutral-600 italic">nothing pending</div>
) : (
pending.map((item) => (
<div key={item.id} className="mb-2 rounded border border-neutral-800 bg-neutral-950/60 p-2">
<div className="mb-1 flex items-center gap-2">
<Badge tone="amber">{item.kind}</Badge>
<span className="font-mono text-[10px] text-neutral-500">{item.id}</span>
<span className="text-[10px] text-neutral-600">{relTime(item.createdAt)}</span>
</div>
{item.kind === 'approval' ? (
<>
<div className="mb-1.5 text-[11px] text-neutral-300">
<span className="text-neutral-500">tool </span>
{payloadField(item.payload, 'toolName', '?')}
<span className="text-neutral-500"> · </span>
{payloadField(item.payload, 'action', '')}
</div>
<JsonView data={item.payload['display'] ?? item.payload} />
<div className="mt-2 flex gap-1.5">
<ActionButton onClick={() => void decide(item.id, 'approved')}>Approve</ActionButton>
<ActionButton danger onClick={() => void decide(item.id, 'rejected')}>Reject</ActionButton>
</div>
</>
) : item.kind === 'question' ? (
<QuestionView
payload={item.payload}
onAnswer={(q, v) => void answer(item.id, q, v)}
onDismiss={() => void dismiss(item.id)}
/>
) : (
<JsonView data={item.payload} />
)}
</div>
))
)}
</div>
</div>
);
}
function QuestionView({
payload,
onAnswer,
onDismiss,
}: {
payload: Record<string, unknown>;
onAnswer: (question: string, value: string) => void;
onDismiss: () => void;
}) {
const questions = (payload['questions'] ?? []) as readonly {
question: string;
options?: readonly { label: string }[];
}[];
return (
<>
{questions.map((q) => (
<div key={q.question} className="mb-1.5">
<div className="mb-1 text-[11px] text-neutral-300">{q.question}</div>
<div className="flex flex-wrap gap-1.5">
{(q.options ?? []).map((opt) => (
<ActionButton key={opt.label} onClick={() => onAnswer(q.question, opt.label)}>
{opt.label}
</ActionButton>
))}
<ActionButton
onClick={() => {
const raw = window.prompt(q.question);
if (raw !== null) onAnswer(q.question, raw);
}}
>
Other
</ActionButton>
</div>
</div>
))}
{questions.length === 0 ? <JsonView data={payload} /> : null}
<div className="mt-1.5">
<ActionButton danger onClick={onDismiss}>
Dismiss
</ActionButton>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Event log — merged core/session/agent stream
// ---------------------------------------------------------------------------
function EventLog() {
const recent = useRecentEvents();
const [events, setEvents] = useState<readonly LiveEvent[]>(() => recent);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('');
const pausedRef = useRefState(paused);
useLiveEvent((event) => {
if (pausedRef.current) return;
setEvents((prev) => [...prev.slice(-500), event]);
});
const filtered = filter.trim() === ''
? events
: events.filter((e) => JSON.stringify(e.data).toLowerCase().includes(filter.trim().toLowerCase()));
return (
<div>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 text-[11px] text-neutral-100 outline-none focus:border-sky-600"
placeholder="filter JSON…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ActionButton onClick={() => setPaused((v) => !v)}>{paused ? 'Resume' : 'Pause'}</ActionButton>
<ActionButton onClick={() => setEvents([])}>Clear</ActionButton>
</div>
<div className="text-[10px] text-neutral-600">{filtered.length} events (newest first)</div>
<div className="mt-2">
{filtered.toReversed().map((event, i) => (
<div key={`${event.at}-${i}`} className="mb-1.5 rounded border border-neutral-800/70 bg-neutral-950/50 p-2">
<div className="mb-1 flex items-center gap-1.5">
<Badge tone={event.source === 'core' ? 'sky' : event.source === 'session' ? 'amber' : 'green'}>
{event.source}
</Badge>
<span className="font-mono text-[10px] text-neutral-400">{eventType(event) || '(payload)'}</span>
<span className="text-[10px] text-neutral-600">{new Date(event.at).toLocaleTimeString()}</span>
</div>
<JsonView data={event.data} />
</div>
))}
</div>
</div>
);
}
/** Mirror a state value into a ref so event handlers see the latest without re-subscribing. */
function useRefState<T>(value: T): { readonly current: T } {
const ref = useRef(value);
ref.current = value;
return ref;
}

View file

@ -0,0 +1,48 @@
/**
* Header server switcher lists the locally discovered kap-servers (dev
* middleware `/__inspect/servers`) and switches the connection between them
* without a reload. Discovered picks are not persisted as a full connection
* config; only the picked URL is remembered so a reload re-picks it while the
* instance is still alive. Falls back to a plain read-only URL when discovery
* is unavailable (static hosting) or finds no local servers.
*/
import { useConnection } from '../connection';
import { useServerDiscovery } from '../servers';
export function ServerSwitcher() {
const { baseUrl, connect } = useConnection();
const discovery = useServerDiscovery();
const data = discovery.data;
if (data === null || data === undefined || data.servers.length === 0) {
// No local discovery (static hosting / no servers): plain read-only URL.
return <span className="font-mono text-[10px] text-neutral-500">{baseUrl}</span>;
}
const current = data.servers.find((s) => s.url === baseUrl);
return (
<select
className="rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300 outline-none focus:border-sky-600"
title={`Local kap-servers (${data.home})`}
value={current?.id ?? '__custom'}
onChange={(e) => {
const target = data.servers.find((s) => s.id === e.target.value);
if (target === undefined || target.url === baseUrl) return;
connect(
{ url: target.url, token: data.token ?? '' },
{ persist: false, rememberServerUrl: target.url },
);
}}
>
{current === undefined ? (
<option value="__custom">custom: {baseUrl.replace(/^https?:\/\//, '')}</option>
) : null}
{data.servers.map((s) => (
<option key={s.id} value={s.id}>
{s.url.replace(/^https?:\/\//, '')}
{s.pid !== undefined ? ` · pid ${s.pid}` : ''}
{s.source === 'proxy' ? ' · proxy' : ''}
</option>
))}
</select>
);
}

View file

@ -0,0 +1,192 @@
/**
* Left sidebar two columns: the workspace registry (`IWorkspaceRegistry`)
* and the sessions of the selected workspace (`ISessionIndex`). Clicking a
* session opens it in the main view. Lists refresh on core events (debounced)
* and a slow poll as a safety net. Session creation goes through the v1 REST
* endpoint (klient is v2-only).
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { ISessionIndex, type SessionSummary } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex';
import { IWorkspaceRegistry, type Workspace } from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry';
import { useConnection } from '../connection';
import { useLiveEvent } from '../live';
import { Badge, ErrorLine, relTime } from '../ui';
export function Sidebar({
activeSessionId,
onSelectSession,
}: {
activeSessionId: string | null;
onSelectSession: (sessionId: string) => void;
}) {
const { klient, baseUrl, config } = useConnection();
const queryClient = useQueryClient();
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
const workspaces = useQuery({
queryKey: ['workspaces'],
queryFn: () => klient.core(IWorkspaceRegistry).list(),
refetchInterval: 15_000,
});
const sessions = useQuery({
queryKey: ['sessions', workspaceId],
queryFn: () =>
klient
.core(ISessionIndex)
.list({ workspaceId: workspaceId ?? undefined, includeArchived: true, limit: 200 }),
refetchInterval: 15_000,
});
// Core events (session archived, model catalog, …) → debounced list refresh.
const refreshTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useLiveEvent((event) => {
if (event.source !== 'core') return;
if (refreshTimer.current !== undefined) clearTimeout(refreshTimer.current);
refreshTimer.current = setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: ['workspaces'] });
void queryClient.invalidateQueries({ queryKey: ['sessions'] });
}, 800);
});
const sortedWorkspaces = (workspaces.data ?? []).toSorted((a, b) => b.lastOpenedAt - a.lastOpenedAt);
const sortedSessions = (sessions.data?.items ?? []).toSorted((a, b) => b.updatedAt - a.updatedAt);
const createSession = async (ws: Workspace | null) => {
const cwd = window.prompt('Working directory for the new session:', ws?.root ?? '');
if (cwd === null || cwd.trim() === '') return;
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (config.token.trim() !== '') headers['authorization'] = `Bearer ${config.token.trim()}`;
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({ workspace_id: ws?.id, metadata: { cwd: cwd.trim() } }),
});
const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } };
if (envelope.code !== 0) {
window.alert(`create session failed: ${envelope.msg}`);
return;
}
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
onSelectSession(envelope.data.id);
};
return (
<div className="flex h-full w-[480px] shrink-0 border-r border-neutral-800">
{/* Workspaces */}
<div className="flex w-1/2 flex-col border-r border-neutral-800">
<div className="flex items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
<span>Workspaces</span>
<button
className="text-sky-500 hover:text-sky-400"
title="New session (no workspace)"
onClick={() => void createSession(null)}
>
+
</button>
</div>
<div className="flex-1 overflow-y-auto">
{workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
{sortedWorkspaces.map((ws) => (
<WorkspaceRow
key={ws.id}
ws={ws}
selected={ws.id === workspaceId}
onClick={() => setWorkspaceId(ws.id)}
onNew={() => void createSession(ws)}
/>
))}
{workspaces.isLoading ? (
<div className="px-3 py-2 text-[11px] text-neutral-600">loading</div>
) : null}
</div>
</div>
{/* Sessions */}
<div className="flex w-1/2 flex-col">
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
Sessions {workspaceId === null ? '(all)' : ''}
</div>
<div className="flex-1 overflow-y-auto">
{sessions.isError ? <ErrorLine error={sessions.error} /> : null}
{sortedSessions.map((s) => (
<SessionRow
key={s.id}
s={s}
active={s.id === activeSessionId}
onClick={() => onSelectSession(s.id)}
/>
))}
{sessions.isLoading ? (
<div className="px-3 py-2 text-[11px] text-neutral-600">loading</div>
) : null}
{!sessions.isLoading && sortedSessions.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-neutral-600">no sessions</div>
) : null}
</div>
</div>
</div>
);
}
function WorkspaceRow({
ws,
selected,
onClick,
onNew,
}: {
ws: Workspace;
selected: boolean;
onClick: () => void;
onNew: () => void;
}) {
return (
<div
className={`group flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-neutral-800/60 ${
selected ? 'bg-neutral-800' : ''
}`}
onClick={onClick}
>
<div className="min-w-0">
<div className="truncate text-[12px] text-neutral-200">{ws.name}</div>
<div className="truncate text-[10px] text-neutral-500" title={ws.root}>
{ws.root}
</div>
</div>
<button
className="ml-2 hidden shrink-0 text-sky-500 hover:text-sky-400 group-hover:block"
title="New session in this workspace"
onClick={(e) => {
e.stopPropagation();
onNew();
}}
>
+
</button>
</div>
);
}
function SessionRow({ s, active, onClick }: { s: SessionSummary; active: boolean; onClick: () => void }) {
return (
<div
className={`cursor-pointer px-3 py-2 hover:bg-neutral-800/60 ${active ? 'bg-sky-950/60' : ''}`}
onClick={onClick}
>
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-[12px] text-neutral-200">
{s.title ?? s.lastPrompt ?? s.id}
</span>
{s.archived ? <Badge tone="neutral">archived</Badge> : null}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-neutral-500">
<span className="truncate font-mono">{s.id.slice(0, 12)}</span>
<span className="shrink-0">{relTime(s.updatedAt)}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,310 @@
/**
* Connection context owns the inspect client (HTTP calls) and its WebSocket
* (event streams) built from the selected server URL + token.
*
* Three ways to connect:
* 1. deep link `?url=` / `?token=` or a previously saved manual config
* (persisted in localStorage);
* 2. zero-config discovery: on startup the dev middleware
* (`/__inspect/servers`) lists every local kap-server with the home
* token the app connects straight to the remembered / proxy / first
* instance, persisting nothing but the picked URL (so a reload re-picks
* it while it is still alive, and never resurrects a stale port);
* 3. the manual form or a discovered-server card on the connect screen.
* `disconnect` suppresses the discovery bootstrap for the rest of the
* session so it lands on the connect screen instead of reconnecting.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
createInspectClient,
probeRpcBasePath,
type InspectClient,
type RpcBasePath,
type WsSocketState,
} from './channel';
import {
fetchServerDiscovery,
pickDefaultServer,
useServerDiscovery,
} from './servers';
export interface ConnectionConfig {
/** Server base URL; empty string means same-origin (the Vite dev proxy). */
readonly url: string;
readonly token: string;
}
export interface ConnectOptions {
/** Persist the whole config (manual form / deep link). Default `true`;
* discovered connects pass `false` so a reload re-discovers fresh state. */
readonly persist?: boolean;
/** Remember just this URL as the preferred discovery pick (`kimi-inspect.server-url`). */
readonly rememberServerUrl?: string;
}
const STORAGE_KEY = 'kimi-inspect.connection';
const REMEMBERED_SERVER_KEY = 'kimi-inspect.server-url';
function readInitialConfig(): ConnectionConfig {
const params = new URLSearchParams(window.location.search);
const qUrl = params.get('url');
const qToken = params.get('token');
if (qUrl !== null || qToken !== null) {
return { url: qUrl ?? '', token: qToken ?? '' };
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw !== null) {
const parsed = JSON.parse(raw) as ConnectionConfig;
return { url: parsed.url ?? '', token: parsed.token ?? '' };
}
} catch {
// corrupt storage — fall through to default
}
return { url: '', token: '' };
}
/** Resolve the configured (possibly relative) URL to an absolute base for the client. */
export function resolveBaseUrl(url: string): string {
const trimmed = url.trim().replace(/\/$/, '');
if (trimmed === '') return window.location.origin;
return trimmed;
}
interface ConnectionValue {
readonly config: ConnectionConfig;
readonly baseUrl: string;
readonly klient: InspectClient;
readonly wsState: WsSocketState;
readonly connect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
readonly disconnect: () => void;
}
const ConnectionContext = createContext<ConnectionValue | null>(null);
export function ConnectionProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<ConnectionConfig | null>(() => {
const initial = readInitialConfig();
// Auto-connect only when the user explicitly connected before (stored) or
// deep-linked (query). First visit goes through the discovery bootstrap.
const params = new URLSearchParams(window.location.search);
if (params.has('url') || params.has('token')) return initial;
return localStorage.getItem(STORAGE_KEY) !== null ? initial : null;
});
const [wsState, setWsState] = useState<WsSocketState>('connecting');
const [discovering, setDiscovering] = useState(config === null);
const [suppressDiscovery, setSuppressDiscovery] = useState(false);
// Discovery bootstrap: with nothing explicit configured, scan the local
// kap-server instance registry (via the dev middleware) and auto-connect.
useEffect(() => {
if (config !== null || suppressDiscovery) return;
let cancelled = false;
setDiscovering(true);
void fetchServerDiscovery().then((discovery) => {
if (cancelled) return;
setDiscovering(false);
if (discovery === null) return;
const pick = pickDefaultServer(discovery, localStorage.getItem(REMEMBERED_SERVER_KEY));
if (pick === undefined) return;
setConfig({ url: pick.url, token: discovery.token ?? '' });
});
return () => {
cancelled = true;
};
}, [config, suppressDiscovery]);
// RPC surface probe: dev servers (`--debug-endpoints`) mount the
// whitelist-free `/api/v1/debug` dispatcher; older/production servers fall
// back to the `/api/v2` whitelist set. The client is built only after the
// probe for this exact config has resolved.
const [probe, setProbe] = useState<{ readonly key: string; readonly rpcBase: RpcBasePath } | null>(
null,
);
const configKey =
config === null ? null : `${resolveBaseUrl(config.url)}|${config.token.trim()}`;
useEffect(() => {
if (config === null || configKey === null) {
setProbe(null);
return;
}
let cancelled = false;
const token = config.token.trim();
void probeRpcBasePath({
baseUrl: resolveBaseUrl(config.url),
token: token === '' ? undefined : token,
}).then((rpcBase) => {
if (!cancelled) setProbe({ key: configKey, rpcBase });
});
return () => {
cancelled = true;
};
}, [config, configKey]);
const klient = useMemo(() => {
if (config === null || configKey === null) return null;
if (probe === null || probe.key !== configKey) return null;
const token = config.token.trim();
return createInspectClient({
url: resolveBaseUrl(config.url),
token: token === '' ? undefined : token,
rpcBasePath: probe.rpcBase,
});
}, [config, configKey, probe]);
useEffect(() => {
if (klient === null) return;
const ws = klient.ws();
setWsState(ws.state);
const sub = ws.onDidChangeState(setWsState);
return () => {
sub.dispose();
ws.close();
};
}, [klient]);
const connect = useCallback((next: ConnectionConfig, opts: ConnectOptions = {}) => {
if (opts.persist ?? true) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} else {
localStorage.removeItem(STORAGE_KEY);
}
if (opts.rememberServerUrl !== undefined) {
localStorage.setItem(REMEMBERED_SERVER_KEY, opts.rememberServerUrl);
}
setSuppressDiscovery(false);
setConfig(next);
}, []);
const disconnect = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(REMEMBERED_SERVER_KEY);
setSuppressDiscovery(true);
setConfig(null);
}, []);
const value = useMemo<ConnectionValue | null>(() => {
if (klient === null || config === null) return null;
return { config, baseUrl: resolveBaseUrl(config.url), klient, wsState, connect, disconnect };
}, [klient, config, wsState, connect, disconnect]);
return (
<ConnectionContext.Provider value={value}>
{value !== null ? (
children
) : config !== null ? (
<div className="flex h-screen items-center justify-center">
<div className="text-sm text-neutral-500">
Connecting to {resolveBaseUrl(config.url)}
</div>
</div>
) : discovering && !suppressDiscovery ? (
<div className="flex h-screen items-center justify-center">
<div className="text-sm text-neutral-500">Discovering local kap-servers</div>
</div>
) : (
<ConnectScreen onConnect={connect} initial={readInitialConfig()} />
)}
</ConnectionContext.Provider>
);
}
export function useConnection(): ConnectionValue {
const value = useContext(ConnectionContext);
if (value === null) {
throw new Error('useConnection used before connecting');
}
return value;
}
function ConnectScreen({
onConnect,
initial,
}: {
onConnect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
initial: ConnectionConfig;
}) {
const [url, setUrl] = useState(initial.url);
const [token, setToken] = useState(initial.token);
const discovery = useServerDiscovery();
const servers = discovery.data?.servers ?? [];
return (
<div className="flex h-screen items-center justify-center">
<form
className="w-[420px] rounded-lg border border-neutral-800 bg-neutral-900 p-6 shadow-xl"
onSubmit={(e) => {
e.preventDefault();
onConnect({ url, token });
}}
>
<h1 className="mb-1 text-lg font-semibold text-neutral-100">Kimi Inspect</h1>
<p className="mb-5 text-xs text-neutral-500">
Connect to a kap-server (<code className="text-neutral-400">/api/v2</code>). Leave the
URL empty to use the same-origin dev proxy
{` (${__KIMI_INSPECT_PROXY_TARGET__})`}.
</p>
{servers.length > 0 ? (
<div className="mb-5">
<div className="mb-1 text-xs text-neutral-400">
Discovered on this machine{discovery.data?.home ? ` (${discovery.data.home})` : ''}
</div>
<div className="space-y-1.5">
{servers.map((s) => (
<button
key={s.id}
type="button"
className="flex w-full items-center gap-2 rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-left text-[12px] text-neutral-200 hover:border-sky-600"
onClick={() =>
onConnect(
{ url: s.url, token: discovery.data?.token ?? '' },
{ persist: false, rememberServerUrl: s.url },
)
}
>
<span className="font-mono">{s.url.replace(/^https?:\/\//, '')}</span>
{s.pid !== undefined ? (
<span className="text-[10px] text-neutral-500">pid {s.pid}</span>
) : null}
<span className="ml-auto text-[10px] uppercase text-neutral-600">{s.source}</span>
</button>
))}
</div>
</div>
) : null}
<label className="mb-1 block text-xs text-neutral-400">Server URL</label>
<input
className="mb-4 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
placeholder="http://127.0.0.1:58627 (empty = dev proxy)"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<label className="mb-1 block text-xs text-neutral-400">Bearer token (optional)</label>
<input
className="mb-5 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
placeholder="~/.kimi-code/server.token"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<button
type="submit"
className="w-full rounded bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500"
>
Connect
</button>
</form>
</div>
);
}
declare const __KIMI_INSPECT_PROXY_TARGET__: string;

View file

@ -0,0 +1,33 @@
@import 'tailwindcss';
:root {
color-scheme: dark;
}
body {
margin: 0;
background: #0b0d10;
color: #d6dae0;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
font-size: 13px;
}
* {
scrollbar-width: thin;
scrollbar-color: #2b313a transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #2b313a;
border-radius: 4px;
}

View file

@ -0,0 +1,92 @@
/**
* Live event bus fans the three WS event streams (core `events`, session
* `interactions`, agent `events`) out to every UI consumer through one React
* context. Subscriptions are registered in `App`; panels and the chat view
* subscribe here instead of opening their own sockets.
*/
import { createContext, useContext, useEffect, useRef } from 'react';
export interface LiveEvent {
readonly source: 'core' | 'session' | 'agent';
readonly data: unknown;
readonly at: number;
}
type LiveHandler = (event: LiveEvent) => void;
export type Emit = (event: LiveEvent) => void;
interface LiveBus {
readonly subscribe: (handler: LiveHandler) => () => void;
/** Last N events recorded since connect (for the event log's initial view). */
readonly getRecent: () => readonly LiveEvent[];
}
const LiveBusContext = createContext<LiveBus>({ subscribe: () => () => {}, getRecent: () => [] });
/** Extract the discriminant `type` of an agent/core event payload, if any. */
export function eventType(event: LiveEvent): string {
const data = event.data as { type?: unknown } | null;
return typeof data?.type === 'string' ? data.type : '';
}
/**
* Render a wire payload field as display text: strings pass through,
* numbers/booleans are stringified, anything else (or missing) falls back
* never "[object Object]".
*/
export function payloadField(
payload: Record<string, unknown>,
key: string,
fallback: string,
): string {
const value = payload[key];
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return fallback;
}
/**
* The provider assigns its `emit` to `busRef` so the WS subscription wiring in
* `App` (which lives above the React tree's hooks it needs) can publish.
*/
const RECENT_LIMIT = 500;
export function LiveBusProvider({
busRef,
children,
}: {
busRef: React.MutableRefObject<Emit | null>;
children: React.ReactNode;
}) {
const handlers = useRef(new Set<LiveHandler>());
const recent = useRef<LiveEvent[]>([]);
const bus = useRef<LiveBus>({
subscribe: (handler) => {
handlers.current.add(handler);
return () => handlers.current.delete(handler);
},
getRecent: () => recent.current,
});
busRef.current = (event) => {
const buf = recent.current;
if (buf.length >= RECENT_LIMIT) buf.splice(0, buf.length - RECENT_LIMIT + 1);
buf.push(event);
for (const handler of handlers.current) handler(event);
};
return <LiveBusContext.Provider value={bus.current}>{children}</LiveBusContext.Provider>;
}
/** Subscribe to the merged live stream. */
export function useLiveEvent(handler: LiveHandler): void {
const { subscribe } = useContext(LiveBusContext);
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => subscribe((event) => handlerRef.current(event)), [subscribe]);
}
/** Read the buffered recent events (for initial log rendering). */
export function useRecentEvents(): readonly LiveEvent[] {
const { getRecent } = useContext(LiveBusContext);
return getRecent();
}

View file

@ -0,0 +1,23 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { ConnectionProvider } from './connection';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 0, refetchOnWindowFocus: false },
},
});
createRoot(document.querySelector('#root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ConnectionProvider>
<App />
</ConnectionProvider>
</QueryClientProvider>
</StrictMode>,
);

View file

@ -0,0 +1,287 @@
/**
* Service panel descriptors the handwritten *override* layer of the right
* sidebar. The sidebar's baseline is the dynamic channel list served by
* `GET /api/v2/channels` (every wire-exposed Service with its methods); a
* descriptor here replaces the generic card for its Service with a curated
* one: a `fetch` that reads its inspectable state, optional `actions` that
* trigger its methods, and live-event `refreshOn` prefixes. The generic
* `ServiceCard` renders them; adding a curated panel is one entry here, no
* component code.
*
* The proxies are typed by the real `agent-core-v2` contracts at the call
* site, but panels treat them as `AnyService` so one descriptor shape covers
* every Service.
*/
import { IAuthSummaryService } from '@moonshot-ai/agent-core-v2/app/auth/auth';
import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config';
import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag';
import { IProviderService } from '@moonshot-ai/agent-core-v2/app/provider/provider';
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
import { ISessionInitService } from '@moonshot-ai/agent-core-v2/session/sessionInit/sessionInit';
import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction';
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
import { ISessionWorkspaceContext } from '@moonshot-ai/agent-core-v2/session/workspaceContext/workspaceContext';
import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView';
import { IAgentContextSizeService } from '@moonshot-ai/agent-core-v2/agent/contextSize/contextSize';
import { IAgentGoalService } from '@moonshot-ai/agent-core-v2/agent/goal/goal';
import { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp';
import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode';
import { IAgentPermissionRulesService } from '@moonshot-ai/agent-core-v2/agent/permissionRules/permissionRules';
import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan';
import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc';
import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/agent/swarm/swarm';
import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task';
import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/toolRegistry/toolRegistry';
import { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage';
/** Loosely-typed view of a scoped service proxy (every member is a remote call). */
export type AnyService = Record<string, (arg?: unknown) => Promise<unknown>>;
/** Invoke a method on a loose proxy; the proxy materializes every member. */
export function call(svc: AnyService, method: string, arg?: unknown): Promise<unknown> {
const fn = svc[method];
if (fn === undefined) {
return Promise.reject(new Error(`no such method on proxy: ${method}`));
}
return fn(arg);
}
export interface PanelAction {
readonly label: string;
/** Prompt for one string input before running (raw string passed to `run`). */
readonly input?: string;
readonly danger?: boolean;
readonly run: (svc: AnyService, input?: string) => unknown;
}
export interface ServicePanelDef {
/** Decorator id / wire channel name, e.g. `sessionMetadata`. */
readonly id: string;
readonly label: string;
/** Wire scope the Service is called on (`app` maps to the `core` route). */
readonly scope: 'app' | 'session' | 'agent';
readonly fetch?: (svc: AnyService) => Promise<unknown>;
readonly actions?: readonly PanelAction[];
/** Live-event `type` prefixes that refetch this panel. */
readonly refreshOn?: readonly string[];
}
const setModeModes = ['manual', 'auto', 'yolo'];
export const CORE_PANELS: readonly ServicePanelDef[] = [
{
id: String(IConfigService),
label: 'ConfigService',
scope: 'app',
fetch: async (svc) => ({
config: await call(svc, 'getAll'),
diagnostics: await call(svc, 'diagnostics'),
}),
actions: [{ label: 'reload', run: (svc) => call(svc, 'reload') }],
},
{
id: String(IProviderService),
label: 'ProviderService',
scope: 'app',
fetch: (svc) => call(svc, 'list'),
},
{
id: String(IAuthSummaryService),
label: 'AuthSummaryService',
scope: 'app',
fetch: (svc) => call(svc, 'summarize'),
},
{
id: String(IFlagService),
label: 'FlagService',
scope: 'app',
fetch: (svc) => call(svc, 'explainAll'),
},
];
export const SESSION_PANELS: readonly ServicePanelDef[] = [
{
id: String(ISessionMetadata),
label: 'SessionMetadata',
scope: 'session',
fetch: (svc) => call(svc, 'read'),
actions: [
{ label: 'Set title', input: 'New title', run: (svc, title) => call(svc, 'setTitle', title) },
{ label: 'Archive', danger: true, run: (svc) => call(svc, 'setArchived', true) },
{ label: 'Unarchive', run: (svc) => call(svc, 'setArchived', false) },
],
},
{
id: String(ISessionApprovalService),
label: 'SessionApprovalService',
scope: 'session',
fetch: (svc) => call(svc, 'listPending'),
},
{
id: String(ISessionQuestionService),
label: 'SessionQuestionService',
scope: 'session',
fetch: (svc) => call(svc, 'listPending'),
},
{
id: String(ISessionInteractionService),
label: 'SessionInteractionService',
scope: 'session',
fetch: (svc) => call(svc, 'listPending'),
},
{
id: String(ISessionWorkspaceContext),
label: 'SessionWorkspaceContext',
scope: 'session',
fetch: async (svc) => ({
workDir: await call(svc, 'workDir'),
additionalDirs: await call(svc, 'additionalDirs'),
}),
},
{
id: String(ISessionInitService),
label: 'SessionInitService',
scope: 'session',
actions: [{ label: 'generateAgentsMd (/init)', run: (svc) => call(svc, 'generateAgentsMd') }],
},
];
export const AGENT_PANELS: readonly ServicePanelDef[] = [
{
id: String(IAgentActivityView),
label: 'AgentActivityView',
scope: 'agent',
fetch: (svc) => call(svc, 'state'),
refreshOn: ['agent.activity.'],
},
{
id: String(IAgentProfileService),
label: 'AgentProfileService',
scope: 'agent',
fetch: async (svc) => ({
model: await call(svc, 'getModel'),
hasModel: await call(svc, 'hasModel'),
isRunnable: await call(svc, 'isRunnable'),
data: await call(svc, 'data'),
}),
actions: [
{ label: 'Set model', input: 'Model id', run: (svc, model) => call(svc, 'setModel', model) },
{ label: 'Refresh system prompt', run: (svc) => call(svc, 'refreshSystemPrompt') },
],
refreshOn: ['agent.status.updated'],
},
{
id: String(IAgentUsageService),
label: 'AgentUsageService',
scope: 'agent',
fetch: (svc) => call(svc, 'status'),
refreshOn: ['turn.step.completed', 'agent.status.updated', 'turn.ended'],
},
{
id: String(IAgentContextSizeService),
label: 'AgentContextSizeService',
scope: 'agent',
fetch: (svc) => call(svc, 'get'),
refreshOn: ['turn.', 'context.', 'compaction.'],
},
{
id: String(IAgentPermissionModeService),
label: 'AgentPermissionModeService',
scope: 'agent',
fetch: (svc) => call(svc, 'mode'),
actions: setModeModes.map((mode) => ({
label: `setMode('${mode}')`,
run: (svc) => call(svc, 'setMode', mode),
})),
},
{
id: String(IAgentPermissionRulesService),
label: 'AgentPermissionRulesService',
scope: 'agent',
fetch: (svc) => call(svc, 'rules'),
},
{
id: String(IAgentPlanService),
label: 'AgentPlanService',
scope: 'agent',
fetch: (svc) => call(svc, 'status'),
actions: [
{ label: 'enter', run: (svc) => call(svc, 'enter') },
{ label: 'cancel', run: (svc) => call(svc, 'cancel') },
{ label: 'clear', run: (svc) => call(svc, 'clear') },
],
refreshOn: ['turn.ended'],
},
{
id: String(IAgentGoalService),
label: 'AgentGoalService',
scope: 'agent',
fetch: (svc) => call(svc, 'getGoal'),
actions: [
{ label: 'pause', run: (svc) => call(svc, 'pauseGoal', {}) },
{ label: 'resume', run: (svc) => call(svc, 'resumeGoal', {}) },
{ label: 'cancel', danger: true, run: (svc) => call(svc, 'cancelGoal', {}) },
],
refreshOn: ['goal.updated'],
},
{
id: String(IAgentTaskService),
label: 'AgentTaskService',
scope: 'agent',
fetch: (svc) => call(svc, 'list'),
actions: [
{ label: 'Stop task', input: 'Task id', danger: true, run: (svc, id) => call(svc, 'stop', id) },
{ label: 'stopAll', danger: true, run: (svc) => call(svc, 'stopAll') },
],
refreshOn: ['task.', 'subagent.'],
},
{
id: String(IAgentToolRegistryService),
label: 'AgentToolRegistryService',
scope: 'agent',
fetch: async (svc) => {
const tools = (await call(svc, 'list')) as readonly { name?: string }[];
return { count: tools.length, names: tools.map((t) => t.name) };
},
refreshOn: ['tool.list.updated'],
},
{
id: String(IAgentMcpService),
label: 'AgentMcpService',
scope: 'agent',
fetch: (svc) => call(svc, 'list'),
actions: [
{ label: 'Reconnect server', input: 'Server name', run: (svc, name) => call(svc, 'reconnect', name) },
],
refreshOn: ['mcp.server.status'],
},
{
id: String(IAgentSwarmService),
label: 'AgentSwarmService',
scope: 'agent',
fetch: (svc) => call(svc, 'isActive'),
actions: [
{ label: 'enter (manual)', run: (svc) => call(svc, 'enter', 'manual') },
{ label: 'exit', run: (svc) => call(svc, 'exit') },
],
},
{
id: String(IAgentRPCService),
label: 'AgentRPCService',
scope: 'agent',
actions: [
{ label: 'cancel turn', run: (svc) => call(svc, 'cancel', {}) },
{
label: 'undoHistory',
input: 'Steps',
run: (svc, n) => call(svc, 'undoHistory', { count: Number(n) }),
},
{ label: 'beginCompaction', run: (svc) => call(svc, 'beginCompaction', {}) },
{ label: 'clearContext', danger: true, run: (svc) => call(svc, 'clearContext', {}) },
],
},
];

View file

@ -0,0 +1,70 @@
/**
* Local server discovery (browser side) reads the dev/preview middleware at
* `/__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local
* kap-server instance registry and reads the home token. Powers the
* zero-config startup connect, the header server switcher, and the discovered
* list on the connect screen. Returns `null` outside dev/preview (no such
* endpoint on a static host) so the app falls back to the manual flow.
*/
import { useQuery } from '@tanstack/react-query';
export type DiscoveredServerSource = 'instance' | 'lock' | 'proxy';
export interface DiscoveredServer {
readonly id: string;
readonly url: string;
readonly pid?: number;
readonly startedAt?: number;
readonly hostVersion?: string;
readonly source: DiscoveredServerSource;
}
export interface ServerDiscoveryResult {
readonly home: string;
readonly token?: string;
readonly servers: readonly DiscoveredServer[];
}
export async function fetchServerDiscovery(): Promise<ServerDiscoveryResult | null> {
let res: Response;
try {
res = await fetch('/__inspect/servers');
} catch {
return null;
}
if (!res.ok) return null;
try {
const parsed = (await res.json()) as ServerDiscoveryResult;
return Array.isArray(parsed.servers) ? parsed : null;
} catch {
return null;
}
}
/** Poll discovery: instances come and go (the server-side heartbeat is 15 s). */
export function useServerDiscovery() {
return useQuery({
queryKey: ['local-server-discovery'],
queryFn: fetchServerDiscovery,
refetchInterval: 10_000,
retry: false,
staleTime: 5_000,
});
}
/** Pick the auto-connect target: the remembered pick when it is still alive,
* else the dev-proxy target (the standard `pnpm dev:v2` flow), else the
* longest-running instance. */
export function pickDefaultServer(
discovery: ServerDiscoveryResult,
rememberedUrl?: string | null,
): DiscoveredServer | undefined {
const { servers } = discovery;
if (servers.length === 0) return undefined;
if (rememberedUrl !== undefined && rememberedUrl !== null && rememberedUrl !== '') {
const remembered = servers.find((s) => s.url === rememberedUrl);
if (remembered !== undefined) return remembered;
}
return servers.find((s) => s.source === 'proxy') ?? servers[0];
}

View file

@ -0,0 +1,96 @@
/**
* Small shared UI primitives for the inspector: JSON dump, badges, buttons,
* relative time. Deliberately minimal this is an internal devtool.
*/
import { useState } from 'react';
export function JsonView({ data, empty }: { data: unknown; empty?: string }) {
const [open, setOpen] = useState(false);
if (data === undefined || data === null) {
return <div className="text-[11px] text-neutral-600 italic">{empty ?? 'no data'}</div>;
}
const text = JSON.stringify(data, null, 2);
const long = text.length > 500;
return (
<pre
className={`cursor-text overflow-auto rounded bg-neutral-950/70 p-2 font-mono text-[11px] leading-relaxed text-neutral-300 ${
long && !open ? 'max-h-48' : 'max-h-[28rem]'
}`}
onClick={() => long && setOpen((v) => !v)}
title={long ? 'click to expand / collapse' : undefined}
>
{long && !open ? `${text.slice(0, 500)}\n… (${text.length} chars, click to expand)` : text}
</pre>
);
}
export function Badge({
children,
tone = 'neutral',
}: {
children: React.ReactNode;
tone?: 'neutral' | 'green' | 'amber' | 'red' | 'sky';
}) {
const tones: Record<string, string> = {
neutral: 'bg-neutral-800 text-neutral-300',
green: 'bg-emerald-900/60 text-emerald-300',
amber: 'bg-amber-900/60 text-amber-300',
red: 'bg-red-900/60 text-red-300',
sky: 'bg-sky-900/60 text-sky-300',
};
return (
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${tones[tone]}`}>{children}</span>
);
}
export function ActionButton({
children,
onClick,
danger,
disabled,
}: {
children: React.ReactNode;
onClick: () => void | Promise<void>;
danger?: boolean;
disabled?: boolean;
}) {
return (
<button
className={`rounded border px-2 py-1 text-[11px] transition-colors disabled:opacity-40 ${
danger
? 'border-red-900/70 text-red-400 hover:bg-red-950/60'
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800'
}`}
disabled={disabled}
onClick={() => {
void onClick();
}}
>
{children}
</button>
);
}
export function relTime(epochMs: number | undefined): string {
if (epochMs === undefined) return '';
const diff = Date.now() - epochMs;
if (diff < 60_000) return `${Math.max(0, Math.round(diff / 1000))}s ago`;
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
return new Date(epochMs).toLocaleDateString();
}
/** Render an unknown thrown value as display text (never "[object Object]"). */
export function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error === null || typeof error !== 'object') return String(error);
return JSON.stringify(error) ?? 'unknown error';
}
export function ErrorLine({ error }: { error: unknown }) {
if (error === null || error === undefined) return null;
const msg = errorMessage(error);
return <div className="rounded bg-red-950/50 px-2 py-1 text-[11px] text-red-400">{msg}</div>;
}

View file

@ -0,0 +1,21 @@
{
// Mirror the repo root tsconfig: agent-core-v2 is consumed through source
// `exports`, so its sources are type-checked here too, and the strictness
// flags must match the root exactly. The src includes are required (not
// just for speed): agent-core-v2 fills interfaces like `AgentTaskInfoByKind`
// via cross-module `declare module` augmentation, and those collapse to
// `never` when the augmenting files are not in the program. Only the
// environment (DOM + vite client types) differs.
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node", "vite/client"],
"noEmit": true
},
"include": [
"src",
"vite",
"vite.config.ts",
"../../packages/agent-core-v2/src"
]
}

View file

@ -0,0 +1,37 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { serverDiscoveryPlugin } from './vite/serverDiscovery';
const webPort = Number(process.env['INSPECT_PORT']) || 5176;
// Where the dev proxy forwards server traffic. The app can also connect to an
// arbitrary server URL typed into the connect screen (loopback cross-origin is
// allowed by kap-server), but the default connection is same-origin through
// this proxy so no CORS / Origin handling is involved.
const serverTarget = process.env['KIMI_SERVER_URL'] || 'http://127.0.0.1:58627';
export default defineConfig({
plugins: [react(), tailwindcss(), serverDiscoveryPlugin({ proxyTarget: serverTarget })],
define: {
__KIMI_INSPECT_PROXY_TARGET__: JSON.stringify(serverTarget),
},
server: {
port: webPort,
strictPort: false,
proxy: {
'/api': { target: serverTarget, changeOrigin: true, ws: true },
},
},
preview: {
port: Number(process.env['INSPECT_PREVIEW_PORT']) || 4176,
proxy: {
'/api': { target: serverTarget, changeOrigin: true, ws: true },
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2022',
},
});

View file

@ -0,0 +1,137 @@
/**
* Unit tests for the local server discovery middleware helpers
* (`vite/serverDiscovery.ts`): instance/lock/token file reading, pid-liveness
* filtering, URL normalization and dedupe. Runs in Node (mkdtemp homes).
*/
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
discoverLocalServers,
pidAlive,
readLiveInstances,
readLiveLock,
readServerToken,
resolveKimiHomeDir,
} from './serverDiscovery';
const ALIVE_PID = process.pid;
// Far above any realistic pid_max; must not collide with a live process.
const DEAD_PID = 999_999_999;
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-inspect-discovery-'));
});
afterEach(async () => {
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 });
});
async function writeInstance(
id: string,
disk: { pid: number; host?: string; port: number; started_at?: number; host_version?: string },
): Promise<void> {
const dir = join(home, 'server', 'instances');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, `${id}.json`), JSON.stringify({ server_id: id, ...disk }));
}
describe('pidAlive', () => {
it('probes the current process as alive and a bogus pid as dead', () => {
expect(pidAlive(ALIVE_PID)).toBe(true);
expect(pidAlive(DEAD_PID)).toBe(false);
});
});
describe('readLiveInstances', () => {
it('lists live instances sorted by started_at, dropping dead pids and bad files', async () => {
await writeInstance('b-older', { pid: ALIVE_PID, port: 58628, started_at: 100 });
await writeInstance('a-newer', { pid: ALIVE_PID, port: 58629, started_at: 200 });
await writeInstance('x-dead', { pid: DEAD_PID, port: 58630, started_at: 50 });
await mkdir(join(home, 'server', 'instances'), { recursive: true });
await writeFile(join(home, 'server', 'instances', 'garbage.json'), '{not json');
const instances = await readLiveInstances(home);
expect(instances.map((i) => i.id)).toEqual(['b-older', 'a-newer']);
expect(instances[0]!.url).toBe('http://127.0.0.1:58628');
});
it('normalizes wildcard hosts to loopback', async () => {
await writeInstance('wild', { pid: ALIVE_PID, host: '0.0.0.0', port: 58627, started_at: 1 });
const instances = await readLiveInstances(home);
expect(instances[0]!.url).toBe('http://127.0.0.1:58627');
});
it('returns [] when the instances directory does not exist', async () => {
await expect(readLiveInstances(home)).resolves.toEqual([]);
});
});
describe('readLiveLock / readServerToken', () => {
it('reads the legacy lock only when its pid is alive', async () => {
await mkdir(join(home, 'server'), { recursive: true });
await writeFile(
join(home, 'server', 'lock'),
JSON.stringify({ pid: ALIVE_PID, port: 58627, started_at: 42 }),
);
expect((await readLiveLock(home))?.url).toBe('http://127.0.0.1:58627');
await writeFile(join(home, 'server', 'lock'), JSON.stringify({ pid: DEAD_PID, port: 58627 }));
await expect(readLiveLock(home)).resolves.toBeUndefined();
});
it('reads the home token, undefined when missing or empty', async () => {
await expect(readServerToken(home)).resolves.toBeUndefined();
await mkdir(join(home, 'server'), { recursive: true });
await writeFile(join(home, 'server.token'), ' tok-123\n');
await expect(readServerToken(home)).resolves.toBe('tok-123');
await writeFile(join(home, 'server.token'), '\n');
await expect(readServerToken(home)).resolves.toBeUndefined();
});
});
describe('discoverLocalServers', () => {
it('merges instances + lock + proxy target, deduped by url, with the home token', async () => {
await writeInstance('inst', { pid: ALIVE_PID, port: 58627, started_at: 1 });
await mkdir(join(home, 'server'), { recursive: true });
// Lock points at another (also live) server on a different port.
await writeFile(join(home, 'server', 'lock'), JSON.stringify({ pid: ALIVE_PID, port: 59000 }));
await writeFile(join(home, 'server.token'), 'tok-xyz');
const payload = await discoverLocalServers({
homeDir: home,
// Proxy target collides with the instance → dedupe keeps the instance.
proxyTarget: 'http://127.0.0.1:58627/',
});
expect(payload.home).toBe(home);
expect(payload.token).toBe('tok-xyz');
expect(payload.servers.map((s) => [s.url, s.source])).toEqual([
['http://127.0.0.1:58627', 'instance'],
['http://127.0.0.1:59000', 'lock'],
]);
});
it('keeps the proxy entry when nothing else is discovered', async () => {
const payload = await discoverLocalServers({
homeDir: home,
proxyTarget: 'http://127.0.0.1:58627',
});
expect(payload.servers).toEqual([
{ id: 'proxy', url: 'http://127.0.0.1:58627', source: 'proxy' },
]);
expect(payload.token).toBeUndefined();
});
});
describe('resolveKimiHomeDir', () => {
it('honors KIMI_CODE_HOME, else falls back to ~/.kimi-code', () => {
expect(resolveKimiHomeDir({ KIMI_CODE_HOME: '/tmp/kh' })).toBe('/tmp/kh');
expect(resolveKimiHomeDir({})).toBe(join(process.env['HOME'] ?? '', '.kimi-code'));
});
});

View file

@ -0,0 +1,226 @@
/**
* Local kap-server discovery a dev/preview middleware that lets the browser
* see and reach every kap-server running on this machine without typing a URL
* or a token.
*
* kap-server already self-registers for peer discovery
* (`packages/kap-server/src/instanceRegistry.ts`):
* multi_server `<kimi home>/server/instances/<serverId>.json`
* single-server `<kimi home>/server/lock`
* and persists the bearer token at `<kimi home>/server.token` (one token per
* home, shared by every instance). The browser cannot read those files, but
* this Vite process can, so `GET /__inspect/servers` answers with the live
* instances (pid-liveness filtered), the dev-proxy target, and the token.
*
* The registry/lock file formats are deliberately reimplemented here (~100
* lines) instead of importing kap-server: the inspector must stay free of
* server-side dependencies.
*
* Security: dev/preview only, bound to loopback by Vite defaults. It hands
* out the same token the user would otherwise paste from
* `~/.kimi-code/server.token` by hand no new exposure beyond the local dev
* session.
*/
import { readdir, readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { Plugin } from 'vite';
export const SERVER_DISCOVERY_ENDPOINT = '/__inspect/servers';
export type DiscoveredServerSource = 'instance' | 'lock' | 'proxy';
export interface DiscoveredServerInfo {
readonly id: string;
readonly url: string;
readonly pid?: number;
readonly startedAt?: number;
readonly hostVersion?: string;
readonly source: DiscoveredServerSource;
}
export interface ServerDiscoveryPayload {
readonly home: string;
readonly token?: string;
readonly servers: readonly DiscoveredServerInfo[];
}
/** Mirror of the on-disk instance file (`server_id` …, snake_case). */
interface ServerInstanceDisk {
server_id?: string;
pid?: number;
host?: string;
port?: number;
started_at?: number;
host_version?: string;
}
/** Mirror of `LockContents` (`packages/kap-server/src/lock.ts`). */
interface ServerLockDisk {
pid?: number;
host?: string;
port?: number;
started_at?: number;
host_version?: string;
}
/** home resolution per request: `KIMI_CODE_HOME` env, else `~/.kimi-code`. */
export function resolveKimiHomeDir(env: NodeJS.ProcessEnv = process.env): string {
const fromEnv = env['KIMI_CODE_HOME'];
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
return join(homedir(), '.kimi-code');
}
/** `process.kill(pid, 0)` probe same semantics as the server's registry:
* ESRCH = dead, EPERM/anything else = alive (never clobber a live entry). */
export function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
}
}
/** Browser-reachable host: wildcard binds advertise as loopback. */
function normalizeHost(host: string | undefined): string {
if (host === undefined || host === '' || host === '0.0.0.0' || host === '::' || host === '[::]') {
return '127.0.0.1';
}
return host;
}
function toUrl(host: string | undefined, port: number): string {
return `http://${normalizeHost(host)}:${port}`;
}
async function readJson<T>(filePath: string): Promise<T | undefined> {
try {
return JSON.parse(await readFile(filePath, 'utf8')) as T;
} catch {
return undefined;
}
}
/** Live instances under `<home>/server/instances`, sorted by `started_at`
* ascending (longest-running first, matching the server's own ordering). */
export async function readLiveInstances(homeDir: string): Promise<readonly DiscoveredServerInfo[]> {
const instancesDir = join(homeDir, 'server', 'instances');
let names: string[];
try {
names = await readdir(instancesDir);
} catch {
return [];
}
const live: { started_at: number; info: DiscoveredServerInfo }[] = [];
await Promise.all(
names
.filter((name) => name.endsWith('.json'))
.map(async (name) => {
const disk = await readJson<ServerInstanceDisk>(join(instancesDir, name));
if (
disk === undefined ||
typeof disk.server_id !== 'string' ||
typeof disk.pid !== 'number' ||
typeof disk.port !== 'number' ||
!pidAlive(disk.pid)
) {
return;
}
live.push({
started_at: typeof disk.started_at === 'number' ? disk.started_at : 0,
info: {
id: disk.server_id,
url: toUrl(disk.host, disk.port),
pid: disk.pid,
startedAt: typeof disk.started_at === 'number' ? disk.started_at : undefined,
hostVersion: typeof disk.host_version === 'string' ? disk.host_version : undefined,
source: 'instance',
},
});
}),
);
live.sort((a, b) => a.started_at - b.started_at);
return live.map((entry) => entry.info);
}
/** The legacy single-server lock (`<home>/server/lock`), when its pid is alive. */
export async function readLiveLock(homeDir: string): Promise<DiscoveredServerInfo | undefined> {
const disk = await readJson<ServerLockDisk>(join(homeDir, 'server', 'lock'));
if (disk === undefined || typeof disk.pid !== 'number' || typeof disk.port !== 'number') {
return undefined;
}
if (!pidAlive(disk.pid)) return undefined;
return {
id: 'lock',
url: toUrl(disk.host, disk.port),
pid: disk.pid,
startedAt: typeof disk.started_at === 'number' ? disk.started_at : undefined,
hostVersion: typeof disk.host_version === 'string' ? disk.host_version : undefined,
source: 'lock',
};
}
/** The home-wide bearer token (`<home>/server.token`); undefined when absent/unreadable. */
export async function readServerToken(homeDir: string): Promise<string | undefined> {
try {
const token = (await readFile(join(homeDir, 'server.token'), 'utf8')).trim();
return token.length > 0 ? token : undefined;
} catch {
return undefined;
}
}
export interface DiscoverOptions {
/** The dev-proxy target (`KIMI_SERVER_URL`); merged as a `proxy` entry. */
readonly proxyTarget?: string;
/** home override; defaults to the request-time env resolution. */
readonly homeDir?: string;
}
/** Assemble the full discovery payload: instances (oldest first) + lock +
* proxy target, deduped by normalized URL, plus the home token. */
export async function discoverLocalServers(
options: DiscoverOptions = {},
): Promise<ServerDiscoveryPayload> {
const home = options.homeDir ?? resolveKimiHomeDir();
const [instances, lock, token] = await Promise.all([
readLiveInstances(home),
readLiveLock(home),
readServerToken(home),
]);
const byUrl = new Map<string, DiscoveredServerInfo>();
for (const info of instances) byUrl.set(info.url, info);
if (lock !== undefined && !byUrl.has(lock.url)) byUrl.set(lock.url, lock);
const proxyUrl = options.proxyTarget?.replace(/\/$/, '');
if (proxyUrl !== undefined && proxyUrl !== '' && !byUrl.has(proxyUrl)) {
byUrl.set(proxyUrl, { id: 'proxy', url: proxyUrl, source: 'proxy' });
}
return { home, token, servers: [...byUrl.values()] };
}
/** Vite plugin exposing `GET /__inspect/servers` on dev and preview servers. */
export function serverDiscoveryPlugin(options: { proxyTarget: string }): Plugin {
const handler = (_req: unknown, res: { setHeader(name: string, value: string): void; end(data: string): void }): void => {
void discoverLocalServers({ proxyTarget: options.proxyTarget })
.then((payload) => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(payload));
})
.catch((error: unknown) => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ error: String(error), servers: [] }));
});
};
return {
name: 'kimi-inspect-server-discovery',
configureServer(server) {
server.middlewares.use(SERVER_DISCOVERY_ENDPOINT, handler);
},
configurePreviewServer(server) {
server.middlewares.use(SERVER_DISCOVERY_ENDPOINT, handler);
},
};
}

View file

@ -79,6 +79,7 @@
./apps/kimi-code
./apps/kimi-desktop
./apps/vscode
./apps/kimi-inspect
./apps/kimi-web
./apps/vis
./apps/vis/server
@ -104,6 +105,7 @@
"@moonshot-ai/kimi-code"
"@moonshot-ai/kimi-desktop"
"kimi-code"
"@moonshot-ai/kimi-inspect"
"@moonshot-ai/kimi-web"
"@moonshot-ai/vis"
"@moonshot-ai/vis-server"

View file

@ -25,6 +25,7 @@ import { registerGuiStoreRoutes } from './guiStore';
import { registerMessagesRoutes } from './messages';
import type { IGuiStoreService } from '../services/guiStore/guiStore';
import type { ISnapshotReader } from '../services/snapshot';
import { registerDebugRoutes } from '../transport/registerDebugRoutes';
import { registerMetaRoute } from './meta';
import { registerModelCatalogRoutes } from './modelCatalog';
import { registerOAuthRoutes } from './oauth';
@ -83,6 +84,12 @@ export async function registerApiV1Routes(
async (apiV1) => {
registerHealthRoute(apiV1);
// Dev-only debug RPC surface (`--debug-endpoints`, loopback-gated in
// `start.ts`): every scoped Service reachable, no `/api/v2` whitelist.
if (opts.debugEndpoints === true) {
registerDebugRoutes(apiV1 as unknown as Parameters<typeof registerDebugRoutes>[0], core);
}
registerMetaRoute(apiV1, {
serverVersion: opts.serverVersion,
serverId: ulid(),

View file

@ -246,3 +246,61 @@ const EXPOSED_SERVICES: readonly ServiceIdentifier<unknown>[] = [
for (const id of EXPOSED_SERVICES) {
registerChannel(id);
}
// ---------------------------------------------------------------------------
// Debug surface — every scoped Service, no whitelist (dev-only `/api/v1/debug`)
// ---------------------------------------------------------------------------
let serviceNameIndex: Map<string, ServiceIdentifier<unknown>> | undefined;
/**
* Wire name identifier index over the ENTIRE scoped DI registry. The
* decorator registry de-dupes by name, so a wire name maps to exactly one
* identifier; a Service registered at several scopes (e.g. `logService`
* at App + Session) resolves at its minimal scope, reachable from every
* route form.
*/
function scopedServiceNameIndex(): Map<string, ServiceIdentifier<unknown>> {
serviceNameIndex ??= (() => {
const map = new Map<string, ServiceIdentifier<unknown>>();
for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) {
for (const entry of getScopedServiceDescriptors(scope)) {
const name = entry.id.toString();
if (!map.has(name)) map.set(name, entry.id);
}
}
return map;
})();
return serviceNameIndex;
}
/**
* Resolve a wire name to its `ServiceIdentifier` without the `/api/v2`
* whitelist dev-only (`/api/v1/debug`) consumers.
*/
export function resolveAnyScopedServiceId(name: string): ServiceIdentifier<unknown> | undefined {
return scopedServiceNameIndex().get(name);
}
/**
* Describe EVERY registered scoped Service served by
* `GET /api/v1/debug/channels` so dev tooling (kimi-inspect) can load the
* full protocol surface 1:1 instead of the whitelist subset.
*/
export function describeAllChannels(): readonly ChannelDescriptor[] {
const byName = new Map<string, ScopedEntry>();
for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) {
for (const entry of getScopedServiceDescriptors(scope)) {
const name = entry.id.toString();
if (!byName.has(name)) byName.set(name, entry);
}
}
return [...byName.entries()]
.map(([name, entry]) => ({
name,
scope: SCOPE_NAME[entry.scope],
domain: entry.domain,
methods: describeMethods(entry.descriptor.ctor),
}))
.toSorted((a, b) => a.name.localeCompare(b.name));
}

View file

@ -13,6 +13,7 @@ import {
Error2,
type IScopeHandle,
type Scope,
type ServiceIdentifier,
} from '@moonshot-ai/agent-core-v2';
import type { ScopeKind } from './channel';
@ -20,6 +21,13 @@ import { resolveChannel } from './channelRegistry';
import { assertSerializable } from './errors';
import { MAIN_AGENT_ID, ensureMainAgent } from './mainAgent';
/**
* Channel name identifier resolution used to gate which Services are
* reachable. `/api/v2` passes the whitelist registry (default);
* `/api/v1/debug` passes the full scoped-registry lookup (dev only).
*/
export type ChannelLookup = (name: string) => ServiceIdentifier<unknown> | undefined;
/**
* Resolve the scope a request targets. Throws `Error2` when the referenced
* session or agent does not exist `session.not_found` for a missing session,
@ -74,6 +82,7 @@ export async function resolveService(
scopeKind: ScopeKind,
params: Record<string, string>,
serviceName: string,
lookup: ChannelLookup = resolveChannel,
): Promise<object> {
const scope = await resolveScope(core, scopeKind, params);
if (scope === undefined) {
@ -82,7 +91,7 @@ export async function resolveService(
`session ${params['session_id'] ?? ''} not found`,
);
}
const id = resolveChannel(serviceName);
const id = lookup(serviceName);
if (id === undefined) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `unknown service: ${serviceName}`);
}
@ -114,8 +123,9 @@ export async function dispatch(
serviceName: string,
method: string,
arg: unknown,
lookup: ChannelLookup = resolveChannel,
): Promise<unknown> {
const service = await resolveService(core, scopeKind, params, serviceName);
const service = await resolveService(core, scopeKind, params, serviceName, lookup);
const member = (service as Record<string, unknown>)[method];
if (member === undefined) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `method not found: ${serviceName}.${method}`);

View file

@ -0,0 +1,29 @@
/**
* `/api/v1/debug` route registration the dev-only, whitelist-free RPC
* surface.
*
* Same dispatcher and envelope semantics as `/api/v2`
* (`registerServiceDispatcherRoutes`), but the channel lookup spans the whole
* scoped DI registry, so EVERY Service (App/Session/Agent scope) is callable,
* not just the `/api/v2` whitelist. Intended for dev tooling (kimi-inspect),
* never for production clients:
*
* - mounted only when `--debug-endpoints` is passed AND the bind is loopback
* (the AND happens in `start.ts`; this module trusts that gate);
* - still behind the global bearer-auth hook like every `/api/*` route.
*
* Called from `registerApiV1Routes` with the prefixed `/api/v1` route host,
* so the base path here is relative: `/debug`.
*/
import type { Scope } from '@moonshot-ai/agent-core-v2';
import { describeAllChannels, resolveAnyScopedServiceId } from './channelRegistry';
import { type RouteHost, registerServiceDispatcherRoutes } from './registerRpcRoutes';
export function registerDebugRoutes(app: RouteHost, core: Scope): void {
registerServiceDispatcherRoutes(app, core, '/debug', {
lookup: resolveAnyScopedServiceId,
describe: describeAllChannels,
});
}

View file

@ -12,6 +12,10 @@
* Body (POST) or `?arg=<json>` (GET) is the method's single argument. Responses
* are always the project envelope (HTTP 200; business outcome in `code`). Body
* size, connection timeout, and graceful close are Fastify's.
*
* `registerServiceDispatcherRoutes` is the shared, path-agnostic core: the
* dev-only `/api/v1/debug` surface (`registerDebugRoutes.ts`) mounts the same
* dispatcher with a different base path and a whitelist-free channel lookup.
*/
import type { Scope } from '@moonshot-ai/agent-core-v2';
@ -20,8 +24,8 @@ import { requestLog } from '../lib/requestLog';
import { okEnvelope } from '../protocol/envelope';
import { ErrorCode } from '../protocol/error-codes';
import type { ScopeKind } from './channel';
import { describeChannels } from './channelRegistry';
import { dispatch } from './dispatcher';
import { type ChannelDescriptor, describeChannels, resolveChannel } from './channelRegistry';
import { type ChannelLookup, dispatch } from './dispatcher';
import { mapError, validationEnvelope, withTimeout } from './errors';
interface RpcRequest {
@ -38,7 +42,7 @@ interface RpcReply {
send(payload: unknown): unknown;
}
interface RouteHost {
export interface RouteHost {
get(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
post(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
}
@ -55,34 +59,63 @@ export interface RegisterRpcRoutesOptions {
readonly callTimeoutMs?: number;
}
const SCOPE_ROUTES: { path: string; scopeKind: ScopeKind }[] = [
{ path: '/api/v2/:service/:method', scopeKind: 'core' },
{ path: '/api/v2/session/:session_id/:service/:method', scopeKind: 'session' },
{ path: '/api/v2/session/:session_id/agent/:agent_id/:service/:method', scopeKind: 'agent' },
];
export interface ServiceDispatcherRouteOptions {
/** Per-call deadline in ms. Default 30s. */
readonly callTimeoutMs?: number;
/** Channel name → identifier resolution. Default: the `/api/v2` whitelist registry. */
readonly lookup?: ChannelLookup;
/** Descriptor source for `GET {basePath}/channels`. Default: the whitelist set. */
readonly describe?: () => readonly ChannelDescriptor[];
}
/**
* Mount the reflection dispatcher under `basePath` (e.g. `/api/v2`, or
* `/debug` inside the prefixed `/api/v1` plugin): the three scope routes plus
* `GET {basePath}/channels` for introspection. `channels` is a single segment,
* so it cannot collide with `:service/:method`.
*/
export function registerServiceDispatcherRoutes(
app: RouteHost,
core: Scope,
basePath: string,
opts: ServiceDispatcherRouteOptions = {},
): void {
const lookup = opts.lookup ?? resolveChannel;
const scopeRoutes: { path: string; scopeKind: ScopeKind }[] = [
{ path: `${basePath}/:service/:method`, scopeKind: 'core' },
{ path: `${basePath}/session/:session_id/:service/:method`, scopeKind: 'session' },
{
path: `${basePath}/session/:session_id/agent/:agent_id/:service/:method`,
scopeKind: 'agent',
},
];
for (const { path, scopeKind } of scopeRoutes) {
const handler = makeHandler(core, scopeKind, opts, lookup);
app.get(path, handler);
app.post(path, handler);
}
// Introspection: the dynamic service browser (kimi-inspect) reads this once
// per connection.
const describe = opts.describe ?? describeChannels;
app.get(`${basePath}/channels`, async (req, reply) =>
reply.send(okEnvelope(describe(), req.id)),
);
}
export function registerRpcRoutes(
app: RouteHost,
core: Scope,
opts: RegisterRpcRoutesOptions = {},
): void {
for (const { path, scopeKind } of SCOPE_ROUTES) {
const handler = makeHandler(core, scopeKind, opts);
app.get(path, handler);
app.post(path, handler);
}
// Introspection: the dynamic service browser (kimi-inspect) reads this once
// per connection. Single segment, so it cannot collide with `:service/:method`.
app.get('/api/v2/channels', async (req, reply) =>
reply.send(okEnvelope(describeChannels(), req.id)),
);
registerServiceDispatcherRoutes(app, core, '/api/v2', opts);
}
function makeHandler(
core: Scope,
scopeKind: ScopeKind,
opts: RegisterRpcRoutesOptions,
opts: ServiceDispatcherRouteOptions,
lookup: ChannelLookup,
): (req: RpcRequest, reply: RpcReply) => Promise<unknown> {
return async (req, reply) => {
const requestId = req.id;
@ -113,6 +146,7 @@ function makeHandler(
service,
method,
arg,
lookup,
),
opts.callTimeoutMs ?? 30_000,
);

View file

@ -1,12 +1,12 @@
/**
* Debug-route suppression (port of v1 `debug-nonloopback.e2e.test.ts`).
* Debug-route gating (port of v1 `debug-nonloopback.e2e.test.ts`).
*
* Security property: `/api/v1/debug/*` introspection/mutation endpoints must
* NOT be reachable on a non-loopback bind. server-v2 does not register the
* debug routes at all (the `debugEndpoints` option is currently a no-op), so
* the property holds trivially the routes are 404 on every bind. This test
* pins that guarantee on a public bind and documents the current (not-yet-
* implemented) loopback behavior.
* Security property: `/api/v1/debug/*` the dev-only, whitelist-free RPC
* surface (`--debug-endpoints`) must NOT be reachable on a non-loopback
* bind (suppressed in `start.ts` regardless of the option), and must stay
* unmounted by default on loopback too. Pinned here: 404 on a public bind
* even with `debugEndpoints: true`, 404 on loopback without the option, and
* the mounted surface on loopback with the option.
*/
import { mkdtemp, rm } from 'node:fs/promises';
@ -51,10 +51,9 @@ async function tmpHome(): Promise<string> {
async function probeDebug(server: RunningServer): Promise<number> {
const token = server.authTokenService.getToken();
const res = await fetch(
`http://127.0.0.1:${server.port}/api/v1/debug/prompts/some-session/state`,
{ headers: { authorization: `Bearer ${token}` } },
);
const res = await fetch(`http://127.0.0.1:${server.port}/api/v1/debug/channels`, {
headers: { authorization: `Bearer ${token}` },
});
return res.status;
}
@ -75,7 +74,19 @@ describe('debug endpoints are not exposed on a non-loopback bind', () => {
expect(await probeDebug(server)).toBe(404);
});
it('is not mounted on loopback either (server-v2 does not implement debug routes yet)', async () => {
it('is not mounted on loopback by default (without the option)', async () => {
const home = await tmpHome();
const server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
running.push(server);
expect(await probeDebug(server)).toBe(404);
});
it('mounts the whitelist-free RPC surface on loopback when requested', async () => {
const home = await tmpHome();
const server = await startServer({
host: '127.0.0.1',
@ -85,6 +96,6 @@ describe('debug endpoints are not exposed on a non-loopback bind', () => {
debugEndpoints: true,
});
running.push(server);
expect(await probeDebug(server)).toBe(404);
expect(await probeDebug(server)).toBe(200);
});
});

View file

@ -7,6 +7,7 @@ import {
IAgentGoalService,
IAgentLifecycleService,
IAgentRPCService,
IAppendLogStore,
IEventService,
IPluginService,
ISessionIndex,
@ -657,3 +658,99 @@ describe('server-v2 /api/v2 RPC auth', () => {
expect(body.code).toBe(40101);
});
});
describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-debug-rpc-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
debugEndpoints: true,
});
base = `http://127.0.0.1:${server.port}`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never);
home = undefined;
}
});
async function call<T>(
method: 'GET' | 'POST',
path: string,
arg?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
const headers: Record<string, string> = {
authorization: `Bearer ${(server as RunningServer).authTokenService.getToken()}`,
};
const init: { method: string; headers: Record<string, string>; body?: string } = {
method,
headers,
};
if (arg !== undefined) {
headers['content-type'] = 'application/json';
init.body = JSON.stringify(arg);
}
const res = await fetch(`${base}${path}`, init);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
it('describes every scoped Service via GET /api/v1/debug/channels', async () => {
const { status, body } = await call<readonly { name: string; scope: string }[]>(
'GET',
'/api/v1/debug/channels',
);
expect(status).toBe(200);
expect(body.code).toBe(0);
// Well past the /api/v2 whitelist size: the debug surface spans the whole
// scoped DI registry (App + Session + Agent).
expect(body.data.length).toBeGreaterThan(50);
const names = body.data.map((c) => c.name);
// Internal (non-whitelisted) Services are included...
expect(names).toContain(String(IAppendLogStore));
// ...alongside the regular whitelisted ones.
expect(names).toContain(String(ISessionIndex));
});
it('calls a non-whitelisted Service method', async () => {
const { status, body } = await call(
'POST',
`/api/v1/debug/${String(IAppendLogStore)}/flush`,
[],
);
expect(status).toBe(200);
expect(body.code).toBe(0);
});
it('also reaches whitelisted Services by the same wire names', async () => {
const { body } = await call<{ items: unknown[] }>(
'POST',
`/api/v1/debug/${String(ISessionIndex)}/list`,
[{ limit: 1 }],
);
expect(body.code).toBe(0);
expect(Array.isArray(body.data.items)).toBe(true);
});
it('rejects an unknown service with 40001', async () => {
const { body } = await call('POST', '/api/v1/debug/noSuchService/whatever', []);
expect(body.code).toBe(40001);
});
it('is gated by the same bearer auth as the rest of /api/*', async () => {
const res = await fetch(`${base}/api/v1/debug/channels`);
expect(res.status).toBe(401);
});
});

112
pnpm-lock.yaml generated
View file

@ -61,7 +61,7 @@ importers:
version: 2.13.1
tsdown:
specifier: 0.22.0
version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2))
version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(vue-tsc@3.2.9(typescript@6.0.2))
tsx:
specifier: ^4.21.0
version: 4.21.0
@ -169,6 +169,46 @@ importers:
specifier: 6.0.2
version: 6.0.2
apps/kimi-inspect:
dependencies:
'@moonshot-ai/agent-core-v2':
specifier: workspace:^
version: link:../../packages/agent-core-v2
'@tanstack/react-query':
specifier: ^5.74.4
version: 5.99.2(react@19.2.5)
react:
specifier: ^19.1.0
version: 19.2.5
react-dom:
specifier: ^19.1.0
version: 19.2.5(react@19.2.5)
devDependencies:
'@tailwindcss/vite':
specifier: ^4.1.4
version: 4.1.18(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
'@types/react':
specifier: ^19.1.2
version: 19.2.14
'@types/react-dom':
specifier: ^19.1.2
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.4.1
version: 4.7.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
tailwindcss:
specifier: ^4.1.4
version: 4.1.18
typescript:
specifier: 6.0.2
version: 6.0.2
vite:
specifier: ^6.3.3
version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: 4.1.4
version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
apps/kimi-web:
dependencies:
'@chenglou/pretext':
@ -13545,14 +13585,6 @@ snapshots:
'@rolldown/binding-openharmony-arm64@1.0.1':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
dependencies:
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
@ -19716,31 +19748,6 @@ snapshots:
transitivePeerDependencies:
- oxc-resolver
rolldown@1.0.0-rc.12:
dependencies:
'@oxc-project/types': 0.122.0
'@rolldown/pluginutils': 1.0.0-rc.12
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-rc.12
'@rolldown/binding-darwin-arm64': 1.0.0-rc.12
'@rolldown/binding-darwin-x64': 1.0.0-rc.12
'@rolldown/binding-freebsd-x64': 1.0.0-rc.12
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.12
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
'@oxc-project/types': 0.122.0
@ -20707,35 +20714,6 @@ snapshots:
- oxc-resolver
- vue-tsc
tsdown@0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2)):
dependencies:
ansis: 4.2.0
cac: 7.0.0
defu: 6.1.7
empathic: 2.0.0
hookable: 6.1.1
import-without-cache: 0.4.0
obug: 2.1.1
picomatch: 4.0.4
rolldown: 1.0.1
rolldown-plugin-dts: 0.25.1(rolldown@1.0.1)(typescript@6.0.2)(vue-tsc@3.2.9(typescript@6.0.2))
semver: 7.7.4
tinyexec: 1.1.2
tinyglobby: 0.2.16
tree-kill: 1.2.2
unconfig-core: 7.5.0
optionalDependencies:
'@arethetypeswrong/core': 0.18.2
publint: 0.3.18
tsx: 4.21.0
typescript: 6.0.2
unrun: 0.2.34
transitivePeerDependencies:
- '@ts-macro/tsc'
- '@typescript/native-preview'
- oxc-resolver
- vue-tsc
tslib@2.8.1: {}
tsx@4.21.0:
@ -20936,14 +20914,6 @@ snapshots:
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
unrun@0.2.34:
dependencies:
rolldown: 1.0.0-rc.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)