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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "Kimi Code CLI is your next CLI agent."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"agent-client-protocol==0.8.0",
"agent-client-protocol==0.10.0",
"aiofiles>=24.0,<26.0",
"aiohttp==3.13.3",
"typer==0.21.1",
Expand Down
14 changes: 12 additions & 2 deletions src/kimi_cli/acp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
- `prompt_capabilities`: `embedded_context=False`, `image=True`, `audio=False`.
- `mcp_capabilities`: `http=True`, `sse=False`.
- Single-session: `load_session=False`, no session list capabilities.
- Multi-session: `load_session=True`, `session_capabilities.list` supported.
- Multi-session: `load_session=True`, `session_capabilities.list` supported, and
`session_capabilities._meta.kimi.sessionHistoryReplay=True`.
- `auth_methods=[]` (no authentication methods advertised).

## Session lifecycle (implemented behavior)
Expand All @@ -31,8 +32,14 @@
- MCP servers passed by ACP are converted via `acp_mcp_servers_to_mcp_config`.
- `session/load`
- Multi-session only: loads by `Session.find`, then builds `KimiCLI` and `ACPSession`.
- No history replay yet (TODO).
- Replays persisted `wire.jsonl` history as ACP `session/update` notifications, falls back to
context text history for older sessions without wire history, and returns initial modes/models
state plus title metadata in `_meta.kimi.session`.
- Sends `SessionInfoUpdate` with title/updatedAt before replaying history.
- Single-session: not implemented.
- `session/resume`
- Multi-session only: returns initial modes/models state plus title metadata in
`_meta.kimi.session`, and sends `SessionInfoUpdate` with title/updatedAt.
- `session/list`
- Multi-session only: lists sessions via `Session.list`, no pagination.
- Single-session: not implemented.
Expand All @@ -54,6 +61,9 @@
- `TodoDisplayBlock` is converted into `AgentPlanUpdate`.
- Available commands:
- `AvailableCommandsUpdate` is sent right after session creation.
- Session metadata:
- `SessionInfoUpdate` is sent after prompt completion when Kimi auto-generates a title, and
during `session/load`/`session/resume` so clients can hydrate title/updatedAt.

## Prompt/content conversion
- Incoming prompt blocks:
Expand Down
106 changes: 65 additions & 41 deletions src/kimi_cli/acp/server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import asyncio
import sys
import time
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -33,7 +32,9 @@ def __init__(self) -> None:
self.conn: acp.Client | None = None
self.sessions: dict[str, tuple[ACPSession, _ModelIDConv]] = {}
self.negotiated_version: ACPVersionSpec | None = None
self._auth_methods: list[acp.schema.AuthMethod] = []
self._auth_methods: list[
acp.schema.EnvVarAuthMethod | acp.schema.TerminalAuthMethod | acp.schema.AuthMethodAgent
] = []

def on_connect(self, conn: acp.Client) -> None:
logger.info("ACP client connected")
Expand Down Expand Up @@ -66,32 +67,18 @@ async def initialize(
version=getattr(client_info, "version", None),
)

# get command and args of current process for terminal-auth
command = sys.argv[0]
args: list[str] = []

# Build terminal auth data for error response
terminal_args = args + ["login"]

# Build and cache auth methods for reuse in AUTH_REQUIRED errors
self._auth_methods = [
acp.schema.AuthMethod(
acp.schema.TerminalAuthMethod(
id="login",
type="terminal",
name="Login with Kimi account",
description=(
"Run `kimi login` command in the terminal, "
"then follow the instructions to finish login."
),
# Store auth data in field_meta for building AUTH_REQUIRED error
field_meta={
"terminal-auth": {
"command": command,
"args": terminal_args,
"label": "Kimi Code Login",
"env": {},
"type": "terminal",
}
},
args=["login"],
env={},
),
]

Expand All @@ -104,6 +91,7 @@ async def initialize(
),
mcp_capabilities=acp.schema.McpCapabilities(http=True, sse=False),
session_capabilities=acp.schema.SessionCapabilities(
field_meta={"kimi": {"sessionHistoryReplay": True}},
list=acp.schema.SessionListCapabilities(),
resume=acp.schema.SessionResumeCapabilities(),
),
Expand All @@ -129,20 +117,9 @@ def _check_auth(self) -> None:
"""Check if Kimi Code authentication is complete. Raise AUTH_REQUIRED if not."""
reason = self._check_token_usable()
if reason:
auth_methods_data: list[dict[str, Any]] = []
for m in self._auth_methods:
if m.field_meta and "terminal-auth" in m.field_meta:
terminal_auth = m.field_meta["terminal-auth"]
auth_methods_data.append(
{
"id": m.id,
"name": m.name,
"description": m.description,
"type": terminal_auth.get("type", "terminal"),
"args": terminal_auth.get("args", []),
"env": terminal_auth.get("env", {}),
}
)
auth_methods_data = [
m.model_dump(by_alias=True, exclude_none=True) for m in self._auth_methods
]

logger.warning("Authentication required, {reason}", reason=reason)
raise acp.RequestError.auth_required({"authMethods": auth_methods_data})
Expand Down Expand Up @@ -255,18 +232,44 @@ async def _setup_session(

async def load_session(
self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any
) -> None:
) -> acp.schema.LoadSessionResponse:
logger.info("Loading session: {id} for working directory: {cwd}", id=session_id, cwd=cwd)

if session_id in self.sessions:
logger.warning("Session already loaded: {id}", id=session_id)
return
acp_session, model_id_conv = self.sessions[session_id]
else:
# Check authentication before loading session
self._check_auth()

# Check authentication before loading session
self._check_auth()
acp_session, model_id_conv = await self._setup_session(cwd, session_id, mcp_servers)

await self._setup_session(cwd, session_id, mcp_servers)
# TODO: replay session history?
await acp_session.send_session_info_update()
replayed_updates = await acp_session.replay_history()
logger.info(
"Replayed {count} ACP history updates for session: {id}",
count=replayed_updates,
id=session_id,
)

config = acp_session.cli.soul.runtime.config
return acp.schema.LoadSessionResponse(
field_meta=_session_response_meta(acp_session),
modes=acp.schema.SessionModeState(
available_modes=[
acp.schema.SessionMode(
id="default",
name="Default",
description="The default mode.",
),
],
current_mode_id="default",
),
models=acp.schema.SessionModelState(
available_models=_expand_llm_models(config.models),
current_model_id=model_id_conv.to_acp_model_id(),
),
)

async def resume_session(
self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any
Expand All @@ -277,8 +280,10 @@ async def resume_session(
await self._setup_session(cwd, session_id, mcp_servers)

acp_session, model_id_conv = self.sessions[session_id]
await acp_session.send_session_info_update()
config = acp_session.cli.soul.runtime.config
return acp.schema.ResumeSessionResponse(
field_meta=_session_response_meta(acp_session),
modes=acp.schema.SessionModeState(
available_modes=[
acp.schema.SessionMode(
Expand Down Expand Up @@ -390,7 +395,10 @@ async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateR
raise acp.RequestError.auth_required(
{
"message": "Please complete login in terminal first",
"authMethods": self._auth_methods,
"authMethods": [
m.model_dump(by_alias=True, exclude_none=True)
for m in self._auth_methods
],
}
)

Expand Down Expand Up @@ -466,3 +474,19 @@ def _expand_llm_models(models: dict[str, LLMModel]) -> list[acp.schema.ModelInfo
)
)
return expanded_models


def _session_response_meta(acp_session: ACPSession) -> dict[str, Any]:
session = acp_session.cli.soul.runtime.session
title = session.state.custom_title or session.title
updated_at = (
datetime.fromtimestamp(session.context_file.stat().st_mtime).astimezone().isoformat()
if session.context_file.exists()
else None
)
meta: dict[str, Any] = {"sessionId": session.id}
if title != "Untitled":
meta["title"] = title
if updated_at is not None:
meta["updatedAt"] = updated_at
return {"kimi": {"session": meta}}
Loading