Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method
- Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes
- Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state
- Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending
- Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set
- Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with "Imported" filter toggle, `kimi export <session_id>` CLI command, and delete support for imported sessions with AlertDialog confirmation
Expand Down
59 changes: 59 additions & 0 deletions docs/en/customization/wire-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ interface InitializeParams {
interface ClientCapabilities {
/** Whether the client can handle QuestionRequest messages */
supports_question?: boolean
/** Whether the client supports plan mode */
supports_plan_mode?: boolean
}

interface ClientInfo {
Expand Down Expand Up @@ -293,6 +295,57 @@ If no turn is in progress:
{"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}}
```

### `set_plan_mode`

::: info Added
Added in Wire 1.4.
:::

- **Direction**: Client → Agent
- **Type**: Request (requires response)

Set plan mode to a specific state. After calling, the agent updates plan mode and sends a `StatusUpdate` event with the new state.

This feature requires capability negotiation: the client must declare `capabilities.supports_plan_mode: true` during `initialize` for the agent to enable plan mode tools (`EnterPlanMode`, `ExitPlanMode`). If the client does not declare support, these tools are automatically hidden from the LLM's tool list.

Plan mode state is persisted to the session, so it survives process restarts and is restored when the session resumes.

```typescript
/** set_plan_mode request parameters */
interface SetPlanModeParams {
/** Whether to enable plan mode */
enabled: boolean
}

/** set_plan_mode response result */
interface SetPlanModeResult {
/** Fixed as "ok" */
status: "ok"
/** Plan mode state after the call */
plan_mode: boolean
}
```

**Request example**

```json
{"jsonrpc": "2.0", "method": "set_plan_mode", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "params": {"enabled": true}}
```

**Success response example**

```json
{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "ok", "plan_mode": true}}
```

**Error response example**

If plan mode is not supported in the current environment:

```json
{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "Plan mode is not supported"}}
```

### `cancel`

- **Direction**: Client → Agent
Expand Down Expand Up @@ -484,10 +537,16 @@ Status update.
interface StatusUpdate {
/** Context usage ratio, float between 0-1, may be absent in JSON */
context_usage?: number | null
/** Number of tokens currently in the context, may be absent in JSON */
context_tokens?: number | null
/** Maximum number of tokens the context can hold, may be absent in JSON */
max_context_tokens?: number | null
/** Token usage stats for current step, may be absent in JSON */
token_usage?: TokenUsage | null
/** Message ID for current step, may be absent in JSON */
message_id?: string | null
/** Whether plan mode (read-only) is active, null means no change, may be absent in JSON */
plan_mode?: boolean | null
}

interface TokenUsage {
Expand Down
3 changes: 3 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method
- Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes
- Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state
- Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending
- Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set
- Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with "Imported" filter toggle, `kimi export <session_id>` CLI command, and delete support for imported sessions with AlertDialog confirmation
Expand Down
59 changes: 59 additions & 0 deletions docs/zh/customization/wire-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ interface InitializeParams {
interface ClientCapabilities {
/** 是否支持处理 QuestionRequest 消息 */
supports_question?: boolean
/** 是否支持 Plan 模式 */
supports_plan_mode?: boolean
}

interface ClientInfo {
Expand Down Expand Up @@ -293,6 +295,57 @@ interface SteerResult {
{"jsonrpc": "2.0", "id": "7ca7c810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "No agent turn is in progress"}}
```

### `set_plan_mode`

::: info 新增
新增于 Wire 1.4。
:::

- **方向**:Client → Agent
- **类型**:Request(需要响应)

将 Plan 模式设置为指定状态。调用后 Agent 会更新 Plan 模式并通过 `StatusUpdate` 事件通知新的状态。

此功能需要能力协商:Client 在 `initialize` 时通过 `capabilities.supports_plan_mode: true` 声明支持后,Agent 才会启用 Plan 模式相关工具(`EnterPlanMode`、`ExitPlanMode`)。如果 Client 未声明支持,这些工具会从 LLM 的工具列表中自动隐藏。

Plan 模式状态会持久化到会话中,因此在进程重启后可以恢复。

```typescript
/** set_plan_mode 请求参数 */
interface SetPlanModeParams {
/** 是否启用 Plan 模式 */
enabled: boolean
}

/** set_plan_mode 响应结果 */
interface SetPlanModeResult {
/** 固定为 "ok" */
status: "ok"
/** 调用后的 Plan 模式状态 */
plan_mode: boolean
}
```

**请求示例**

```json
{"jsonrpc": "2.0", "method": "set_plan_mode", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "params": {"enabled": true}}
```

**成功响应示例**

```json
{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "result": {"status": "ok", "plan_mode": true}}
```

**错误响应示例**

如果当前环境不支持 Plan 模式:

```json
{"jsonrpc": "2.0", "id": "8da7d810-9dad-11d1-80b4-00c04fd430c8", "error": {"code": -32000, "message": "Plan mode is not supported"}}
```

### `cancel`

- **方向**:Client → Agent
Expand Down Expand Up @@ -484,10 +537,16 @@ interface StepBegin {
interface StatusUpdate {
/** 上下文使用率,0-1 之间的浮点数,JSON 中可能不存在 */
context_usage?: number | null
/** 当前上下文中的 token 数量,JSON 中可能不存在 */
context_tokens?: number | null
/** 上下文可容纳的最大 token 数量,JSON 中可能不存在 */
max_context_tokens?: number | null
/** 当前步骤的 token 用量统计,JSON 中可能不存在 */
token_usage?: TokenUsage | null
/** 当前步骤的消息 ID,JSON 中可能不存在 */
message_id?: string | null
/** Plan 模式是否激活,null 表示状态未变更,JSON 中可能不存在 */
plan_mode?: boolean | null
}

interface TokenUsage {
Expand Down
3 changes: 3 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

## 未发布

- Web:新增 Web UI 中的 Plan 模式切换——在输入工具栏中添加开关控件,Plan 模式激活时输入框显示蓝色虚线边框,并支持通过 `set_plan_mode` Wire 协议方法设置 Plan 模式
- Core:Plan 模式状态跨会话持久化——将 `plan_mode` 保存到 `SessionState`,会话恢复时自动还原
- Core:修复工具触发的 Plan 模式变更未正确反映在 StatusUpdate 中的问题——在 `EnterPlanMode`/`ExitPlanMode` 工具执行后发送更新的 `StatusUpdate`,确保客户端看到最新状态
- Core:修复部分 Linux 系统(如内核版本 6.8.0-101)上 HTTP 请求头包含尾部空白/换行符导致连接错误的问题——发送前对 ASCII 请求头值执行空白裁剪
- Core:修复 OpenAI Responses provider 隐式发送 `reasoning.effort=null` 导致需要推理的 Responses 兼容端点报错的问题——现在仅在显式设置时才发送推理参数
- Vis:新增会话下载、导入、导出与删除功能——在会话浏览器和详情页支持一键 ZIP 下载,支持将 ZIP 文件导入到独立的 `~/.kimi/imported_sessions/` 目录并通过"Imported"筛选器切换查看,新增 `kimi export <session_id>` CLI 命令,支持删除导入的会话并提供 AlertDialog 二次确认
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class SessionState(BaseModel):
approval: ApprovalStateData = Field(default_factory=ApprovalStateData)
dynamic_subagents: list[DynamicSubagentSpec] = Field(default_factory=_default_dynamic_subagents)
additional_dirs: list[str] = Field(default_factory=list)
plan_mode: bool = False


def load_session_state(session_dir: Path) -> SessionState:
Expand Down
32 changes: 25 additions & 7 deletions src/kimi_cli/soul/kimisoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,11 @@ def __init__(
self._checkpoint_with_user_message = False

self._steer_queue: asyncio.Queue[str | list[ContentPart]] = asyncio.Queue()
self._plan_mode: bool = False
self._plan_mode: bool = self._runtime.session.state.plan_mode
self._plan_session_id: str | None = None
self._pending_plan_activation_injection: bool = False
if self._plan_mode:
self._ensure_plan_session_id()
self._injection_providers: list[DynamicInjectionProvider] = [
PlanModeInjectionProvider(),
]
Expand Down Expand Up @@ -231,12 +233,17 @@ def _ensure_plan_session_id(self) -> None:

def _set_plan_mode(self, enabled: bool, *, source: Literal["manual", "tool"]) -> bool:
"""Update plan mode state for either manual or tool-driven toggles."""
if enabled == self._plan_mode:
return self._plan_mode
self._plan_mode = enabled
if enabled:
self._ensure_plan_session_id()
self._pending_plan_activation_injection = source == "manual"
else:
self._pending_plan_activation_injection = False
# Persist plan mode to session state so it survives process restarts
self._runtime.session.state.plan_mode = self._plan_mode
self._runtime.session.save_state()
return self._plan_mode

def get_plan_file_path(self) -> Path | None:
Expand Down Expand Up @@ -271,13 +278,16 @@ async def toggle_plan_mode(self) -> bool:
return self._set_plan_mode(not self._plan_mode, source="tool")

async def toggle_plan_mode_from_manual(self) -> bool:
"""Toggle plan mode from UI/manual entry points.
"""Toggle plan mode from UI/manual entry points (slash command, keybinding)."""
return self._set_plan_mode(not self._plan_mode, source="manual")

async def set_plan_mode_from_manual(self, enabled: bool) -> bool:
"""Set plan mode to a specific state from UI/manual entry points.

Manual toggles do not append a synthetic history message. Instead, entering
plan mode schedules a one-shot injection for the next LLM step, and exiting
plan mode clears that pending injection if it has not been used yet.
Unlike toggle, this accepts the desired state directly, avoiding
race conditions when the caller already knows the target value.
"""
return self._set_plan_mode(not self._plan_mode, source="manual")
return self._set_plan_mode(enabled, source="manual")

def consume_pending_plan_activation_injection(self) -> bool:
"""Consume the next-step activation reminder scheduled by a manual toggle."""
Expand Down Expand Up @@ -650,7 +660,9 @@ async def _kosong_step_with_retry() -> StepResult:

result = await _kosong_step_with_retry()
logger.debug("Got step result: {result}", result=result)
status_update = StatusUpdate(token_usage=result.usage, message_id=result.id)
status_update = StatusUpdate(
token_usage=result.usage, message_id=result.id, plan_mode=self._plan_mode
)
if result.usage is not None:
# mark the token count for the context before the step
await self._context.update_token_count(result.usage.input)
Expand All @@ -661,9 +673,15 @@ async def _kosong_step_with_retry() -> StepResult:
wire_send(status_update)

# wait for all tool results (may be interrupted)
plan_mode_before_tools = self._plan_mode
results = await result.tool_results()
logger.debug("Got tool results: {results}", results=results)

# If a tool (EnterPlanMode/ExitPlanMode) changed plan mode during execution,
# send a corrected StatusUpdate so the client sees the up-to-date state.
if self._plan_mode != plan_mode_before_tools:
wire_send(StatusUpdate(plan_mode=self._plan_mode))

# shield the context manipulation from interruption
await asyncio.shield(self._grow_context(result, results))

Expand Down
3 changes: 3 additions & 0 deletions src/kimi_cli/soul/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,12 @@ async def plan(soul: KimiSoul, args: str):
await soul.toggle_plan_mode_from_manual()
plan_path = soul.get_plan_file_path()
wire_send(TextPart(text=f"Plan mode ON. Plan file: {plan_path}"))
wire_send(StatusUpdate(plan_mode=soul.plan_mode))
elif subcmd == "off":
if soul.plan_mode:
await soul.toggle_plan_mode_from_manual()
wire_send(TextPart(text="Plan mode OFF. All tools are now available."))
wire_send(StatusUpdate(plan_mode=soul.plan_mode))
elif subcmd == "view":
content = soul.read_current_plan()
if content:
Expand All @@ -135,6 +137,7 @@ async def plan(soul: KimiSoul, args: str):
)
else:
wire_send(TextPart(text="Plan mode OFF. All tools are now available."))
wire_send(StatusUpdate(plan_mode=soul.plan_mode))


@registry.command(name="add-dir")
Expand Down
15 changes: 14 additions & 1 deletion src/kimi_cli/wire/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ def _serialize(self) -> dict[str, Any]:
raise NotImplementedError("Steer message serialization is not implemented.")


class _SetPlanModeParams(BaseModel):
enabled: bool

model_config = ConfigDict(extra="ignore")


class JSONRPCSetPlanModeMessage(_MessageBase):
method: Literal["set_plan_mode"] = "set_plan_mode"
id: str
params: _SetPlanModeParams


class JSONRPCCancelMessage(_MessageBase):
method: Literal["cancel"] = "cancel"
id: str
Expand Down Expand Up @@ -185,10 +197,11 @@ def _validate_params(cls, value: Any) -> Request:
| JSONRPCPromptMessage
| JSONRPCSteerMessage
| JSONRPCReplayMessage
| JSONRPCSetPlanModeMessage
| JSONRPCCancelMessage
)
JSONRPCInMessageAdapter = TypeAdapter[JSONRPCInMessage](JSONRPCInMessage)
JSONRPC_IN_METHODS = {"initialize", "prompt", "steer", "replay", "cancel"}
JSONRPC_IN_METHODS = {"initialize", "prompt", "steer", "replay", "set_plan_mode", "cancel"}

type JSONRPCOutMessage = (
JSONRPCSuccessResponse
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/wire/protocol.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
WIRE_PROTOCOL_VERSION: str = "1.3"
WIRE_PROTOCOL_VERSION: str = "1.4"
WIRE_PROTOCOL_LEGACY_VERSION: str = "1.1"
27 changes: 27 additions & 0 deletions src/kimi_cli/wire/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
QuestionRequest,
QuestionResponse,
Request,
StatusUpdate,
ToolCallRequest,
is_event,
is_request,
Expand All @@ -47,6 +48,7 @@
JSONRPCPromptMessage,
JSONRPCReplayMessage,
JSONRPCRequestMessage,
JSONRPCSetPlanModeMessage,
JSONRPCSteerMessage,
JSONRPCSuccessResponse,
Statuses,
Expand Down Expand Up @@ -294,6 +296,8 @@ async def _dispatch_msg(self, msg: JSONRPCInMessage) -> None:
resp = await self._handle_replay(msg)
case JSONRPCSteerMessage():
resp = await self._handle_steer(msg)
case JSONRPCSetPlanModeMessage():
resp = await self._handle_set_plan_mode(msg)
case JSONRPCCancelMessage():
resp = await self._handle_cancel(msg)
case JSONRPCSuccessResponse() | JSONRPCErrorResponse():
Expand Down Expand Up @@ -552,6 +556,29 @@ async def _handle_steer(
result={"status": Statuses.STEERED},
)

async def _handle_set_plan_mode(
self, msg: JSONRPCSetPlanModeMessage
) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:
if not isinstance(self._soul, KimiSoul):
return JSONRPCErrorResponse(
id=msg.id,
error=JSONRPCErrorObject(
code=ErrorCodes.INVALID_STATE,
message="Plan mode is not supported",
),
)

new_state = await self._soul.set_plan_mode_from_manual(msg.params.enabled)

status = StatusUpdate(plan_mode=new_state)
await self._send_msg(JSONRPCEventMessage(params=status))
# Persist to wire file so replay reconstructs plan mode state
await self._soul.wire_file.append_message(status)
return JSONRPCSuccessResponse(
id=msg.id,
result={"status": "ok", "plan_mode": new_state},
)

async def _handle_replay(
self, msg: JSONRPCReplayMessage
) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:
Expand Down
Loading
Loading