diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md
index b5fb0fd50d..800d0c3d79 100644
--- a/.agents/skills/gen-changesets/SKILL.md
+++ b/.agents/skills/gen-changesets/SKILL.md
@@ -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
diff --git a/.changeset/config.json b/.changeset/config.json
index 0f9da8d75c..9aaa6ce298 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -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,
diff --git a/.changeset/debug-rpc-surface.md b/.changeset/debug-rpc-surface.md
new file mode 100644
index 0000000000..0162b8a9ea
--- /dev/null
+++ b/.changeset/debug-rpc-surface.md
@@ -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.
diff --git a/AGENTS.md b/AGENTS.md
index df1b117c71..6715558877 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json
index 7b8a06914d..9fb63d05ac 100644
--- a/apps/kimi-code/package.json
+++ b/apps/kimi-code/package.json
@@ -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",
diff --git a/apps/kimi-inspect/index.html b/apps/kimi-inspect/index.html
new file mode 100644
index 0000000000..3a9b3b5e59
--- /dev/null
+++ b/apps/kimi-inspect/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Kimi Inspect
+
+
+
+
+
+
diff --git a/apps/kimi-inspect/package.json b/apps/kimi-inspect/package.json
new file mode 100644
index 0000000000..63acb6f06c
--- /dev/null
+++ b/apps/kimi-inspect/package.json
@@ -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"
+ }
+}
diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx
new file mode 100644
index 0000000000..57a5c3dcc4
--- /dev/null
+++ b/apps/kimi-inspect/src/App.tsx
@@ -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(null);
+ const [agentId, setAgentId] = useState('main');
+ const [ready, setReady] = useState(false);
+ const [resumeError, setResumeError] = useState(null);
+ const emitRef = useRef(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 (
+
+
+
+ KIMI INSPECT
+
+
+ ws: {wsState}
+
+
+
+
+
+
+ {resumeError !== null ? (
+
+ Failed to open session: {errorMessage(resumeError)}
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/kimi-inspect/src/channel/channel.test.ts b/apps/kimi-inspect/src/channel/channel.test.ts
new file mode 100644
index 0000000000..fa1b249665
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/channel.test.ts
@@ -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;
+ 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 (command: string, args?: unknown[]): Promise => {
+ seen.calls.push([command, args ?? []]);
+ return 'ret' as T;
+ },
+ listen: (event: string): Event => {
+ seen.listens.push(event);
+ return () => ({ dispose: () => {} });
+ },
+ };
+ const svc = makeProxy(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('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('onDidChangeConfiguration')(() => {});
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(state.listens).toBe(1);
+ channel.listen('onDidSectionChange');
+ expect(factory).toHaveBeenCalledTimes(1);
+ sub.dispose();
+ expect(state.disposed).toBe(1);
+ });
+});
diff --git a/apps/kimi-inspect/src/channel/channel.ts b/apps/kimi-inspect/src/channel/channel.ts
new file mode 100644
index 0000000000..fe87242509
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/channel.ts
@@ -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 {
+ (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(command: string, args?: unknown[]): Promise;
+ listen(event: string, arg?: unknown): Event;
+}
+
+/** A wire Service reference: a DI decorator (stringifies to the wire channel
+ * name) or the raw channel name as a string. */
+export type ServiceRef = ServiceIdentifier | string;
+
+/**
+ * Remote view of a Service contract: every method becomes an async wire call;
+ * `onXxx` event members (`Event` — 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 = {
+ [K in keyof T]: T[K] extends (...args: infer A) => infer R
+ ? R extends IDisposable
+ ? T[K]
+ : (...args: A) => Promise>
+ : () => Promise>;
+};
diff --git a/apps/kimi-inspect/src/channel/channels.ts b/apps/kimi-inspect/src/channel/channels.ts
new file mode 100644
index 0000000000..1810f6c830
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/channels.ts
@@ -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 {
+ const headers: Record = {};
+ 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 {
+ try {
+ const headers: Record = {};
+ 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(
+ client: InspectClient,
+ name: string,
+ target: ServiceTarget,
+): ServiceProxy | undefined {
+ const id = createDecorator(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);
+}
diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts
new file mode 100644
index 0000000000..2f1149dd23
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/client.ts
@@ -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(id: ServiceRef): ServiceProxy;
+}
+
+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(id: ServiceRef): ServiceProxy;
+ 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(
+ scopePath: string,
+ scope: WsScopeKind,
+ ids: WsScopeIds,
+ id: ServiceRef,
+ ): ServiceProxy {
+ const service = String(id);
+ return makeProxy(
+ 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,
+ };
+}
diff --git a/apps/kimi-inspect/src/channel/errors.ts b/apps/kimi-inspect/src/channel/errors.ts
new file mode 100644
index 0000000000..b655802de5
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/errors.ts
@@ -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';
+ }
+}
diff --git a/apps/kimi-inspect/src/channel/index.ts b/apps/kimi-inspect/src/channel/index.ts
new file mode 100644
index 0000000000..bcb9186399
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/index.ts
@@ -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';
diff --git a/apps/kimi-inspect/src/channel/proxy.ts b/apps/kimi-inspect/src/channel/proxy.ts
new file mode 100644
index 0000000000..5a0eb75187
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/proxy.ts
@@ -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(channel: IChannel): ServiceProxy {
+ return new Proxy({} as ServiceProxy, {
+ 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);
+ },
+ });
+}
diff --git a/apps/kimi-inspect/src/channel/proxyChannel.ts b/apps/kimi-inspect/src/channel/proxyChannel.ts
new file mode 100644
index 0000000000..e88143f8eb
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/proxyChannel.ts
@@ -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 {
+ 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(command: string, args: unknown[] = []): Promise {
+ const headers: Record = {};
+ 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;
+ if (envelope.code !== 0) {
+ throw new RPCError(envelope.code, envelope.msg, envelope.details);
+ }
+ return envelope.data;
+ }
+
+ listen(event: string): Event {
+ 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);
+ }
+}
diff --git a/apps/kimi-inspect/src/channel/wsChannel.ts b/apps/kimi-inspect/src/channel/wsChannel.ts
new file mode 100644
index 0000000000..7166a1ee87
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/wsChannel.ts
@@ -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();
+
+ constructor(opts: WsChannelOptions) {
+ this.socket = opts.socket;
+ this.scope = opts.scope;
+ this.service = opts.service;
+ this.ids = { sessionId: opts.sessionId, agentId: opts.agentId };
+ }
+
+ call(command: string, args: unknown[] = []): Promise {
+ return this.socket.call(this.scope, this.service, command, args, this.ids);
+ }
+
+ listen(event: string): Event {
+ 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;
+ };
+ }
+}
diff --git a/apps/kimi-inspect/src/channel/wsSocket.ts b/apps/kimi-inspect/src/channel/wsSocket.ts
new file mode 100644
index 0000000000..98eca01650
--- /dev/null
+++ b/apps/kimi-inspect/src/channel/wsSocket.ts
@@ -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.` 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 | 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 | undefined;
+ private readyWaiters: { resolve: () => void; reject: (err: Error) => void }[] = [];
+
+ private readonly pending = new Map();
+ private readonly listens = new Map();
+ private readonly eventControllers = new Map();
+ 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(
+ scope: WsScopeKind,
+ service: string,
+ method: string,
+ arg?: unknown,
+ ids?: WsScopeIds,
+ ): Promise {
+ 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((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[] = [];
+ const data = {
+ ...(frame.data as object),
+ signal: controller.signal,
+ waitUntil: (promise: Promise) => 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 {
+ if (this.state === 'open') return Promise.resolve();
+ if (this.state === 'closed' && this.manualClose) {
+ return Promise.reject(new Error('ws closed'));
+ }
+ return new Promise((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): 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();
+}
diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx
new file mode 100644
index 0000000000..ac6169b8a5
--- /dev/null
+++ b/apps/kimi-inspect/src/components/ChatView.tsx
@@ -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([]);
+ const [input, setInput] = useState('');
+ const [running, setRunning] = useState(false);
+ const [sendError, setSendError] = useState(null);
+ const bottomRef = useRef(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 | 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;
+ 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 (
+
+ Select a session on the left to open its conversation.
+