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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Only write entries that are worth mentioning to users.
## Unreleased

- Core: Pass session ID as `user_id` metadata to Anthropic API
- Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization

## 1.17.0 (2026-03-03)

Expand Down
8 changes: 4 additions & 4 deletions docs/en/reference/kimi-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ kimi info [--json]

```sh
$ kimi info
kimi-cli version: 0.71
kimi-cli version: 1.17.0
agent spec versions: 1
wire protocol: 1
python version: 3.14.0
wire protocol: 1.4
python version: 3.13.1
```

**JSON output**

```sh
$ kimi info --json
{"kimi_cli_version": "0.71", "agent_spec_versions": ["1"], "wire_protocol_version": "1", "python_version": "3.13.1"}
{"kimi_cli_version": "1.17.0", "agent_spec_versions": ["1"], "wire_protocol_version": "1.4", "python_version": "3.13.1"}
```
6 changes: 3 additions & 3 deletions docs/en/reference/kimi-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Use `--auth-token` to set an access token. Clients need to include `Authorizatio
kimi web --network --auth-token my-secret-token
```

::: tip Tip
::: tip
The access token should be a randomly generated string with at least 32 characters. You can use `openssl rand -hex 32` to generate a random token.
:::

Expand All @@ -104,7 +104,7 @@ Use `--allowed-origins` to restrict the origin domains that can access Web UI:
kimi web --network --allowed-origins "https://example.com,https://app.example.com"
```

::: tip Tip
::: tip
When using `--network` or `--host` to enable network access, it is recommended to configure `--allowed-origins` to prevent Cross-Site Request Forgery (CSRF) attacks.
:::

Expand Down Expand Up @@ -134,7 +134,7 @@ kimi web --network --restrict-sensitive-apis

In `--public` mode, `--restrict-sensitive-apis` is enabled by default; in `--lan-only` mode (default), it is not enabled.

::: tip Tip
::: tip
When you need to expose Web UI to untrusted network environments, it is recommended to enable the `--restrict-sensitive-apis` option.
:::

Expand Down
1 change: 1 addition & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This page documents the changes in each Kimi Code CLI release.
## Unreleased

- Core: Pass session ID as `user_id` metadata to Anthropic API
- Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization

## 1.17.0 (2026-03-03)

Expand Down
8 changes: 4 additions & 4 deletions docs/zh/reference/kimi-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ kimi info [--json]

```sh
$ kimi info
kimi-cli version: 0.71
kimi-cli version: 1.17.0
agent spec versions: 1
wire protocol: 1
python version: 3.14.0
wire protocol: 1.4
python version: 3.13.1
```

**JSON 输出**

```sh
$ kimi info --json
{"kimi_cli_version": "0.71", "agent_spec_versions": ["1"], "wire_protocol_version": "1", "python_version": "3.13.1"}
{"kimi_cli_version": "1.17.0", "agent_spec_versions": ["1"], "wire_protocol_version": "1.4", "python_version": "3.13.1"}
```
1 change: 1 addition & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
## 未发布

- Core:将会话 ID 作为 `user_id` 元数据传递给 Anthropic API
- Web:修复 WebSocket 重连时斜杠命令丢失的问题,并为会话初始化添加自动重试逻辑

## 1.17.0 (2026-03-03)

Expand Down
95 changes: 61 additions & 34 deletions web/src/hooks/useSessionStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ export function useSessionStream(

// Initialize message tracking
const initializeIdRef = useRef<string | null>(null);
const initializeRetryCountRef = useRef(0); // Track retry attempts for initialize
const MAX_INITIALIZE_RETRIES = 5; // Maximum retry attempts
const usingCachedCommandsRef = useRef(false); // Track if using cached slash commands

// Current state accumulators
const currentThinkingRef = useRef("");
Expand Down Expand Up @@ -776,7 +779,7 @@ export function useSessionStream(
}, []);

// Reset all state
const resetState = useCallback(() => {
const resetState = useCallback((preserveSlashCommands = false) => {
resetStepState();
currentToolCallsRef.current.clear();
currentToolCallIdRef.current = null;
Expand All @@ -792,7 +795,6 @@ export function useSessionStream(
isReplayingRef.current = true;
setIsReplayingHistory(true);
setAwaitingFirstResponse(false);
setSlashCommands([]);
// Reset first turn tracking
hasTurnStartedRef.current = false;
firstTurnCompleteCalledRef.current = false;
Expand All @@ -803,7 +805,14 @@ export function useSessionStream(
window.clearTimeout(historyCompleteTimeoutRef.current);
historyCompleteTimeoutRef.current = null;
}
}, [resetStepState, setAwaitingFirstResponse]);
// Handle slashCommands: preserve or clear
if (!preserveSlashCommands) {
setSlashCommands([]);
usingCachedCommandsRef.current = false;
} else if (slashCommands.length > 0) {
usingCachedCommandsRef.current = true;
}
}, [resetStepState, setAwaitingFirstResponse, slashCommands.length]);
Comment on lines +812 to +815
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Adding slashCommands.length to resetState deps causes infinite WebSocket reconnect loop

The resetState callback now includes slashCommands.length in its dependency array (line 815). Since resetState is a direct dependency of the useLayoutEffect at web/src/hooks/useSessionStream.ts:2653, any change to slashCommands.length recreates resetState, which re-triggers the effect. The effect disconnects the current WebSocket (web/src/hooks/useSessionStream.ts:2628-2630), calls resetState() without arguments (defaulting preserveSlashCommands=false, which clears slash commands back to []), then reconnects via connectRef.current() (line 2641). On reconnect, the initialize response sets slash commands again (setSlashCommands(slash_commands) at line 1951), changing slashCommands.length from 0→N, which recreates resetState, re-fires the effect, and the cycle repeats infinitely.

Cycle trace
  1. Connect → receive initialize response → setSlashCommands(commands) (length 0→N)
  2. slashCommands.length changes → resetState identity changes
  3. useLayoutEffect dep resetState changed → effect re-runs
  4. Effect calls disconnectRef.current() (line 2629) → WebSocket closed
  5. Effect calls resetState() (no args → preserveSlashCommands=false) → setSlashCommands([]) (length N→0)
  6. Effect schedules connectRef.current() (line 2641) → new WebSocket
  7. Go to step 1
Prompt for agents
In web/src/hooks/useSessionStream.ts, the root cause is that `slashCommands.length` (a reactive state value) is in the dependency array of `resetState` (line 815), and `resetState` is a dependency of the `useLayoutEffect` at line 2653. This creates a cascade where receiving slash commands triggers a reconnect loop.

Fix: Remove `slashCommands.length` from the `resetState` dependency array. Instead of reading `slashCommands.length` inside the callback (line 812), use a ref to track the current slash commands length, e.g. `slashCommandsRef.current = slashCommands` (synced via a separate useEffect or inline assignment). Then read `slashCommandsRef.current.length > 0` in the `resetState` callback body. This way `resetState` identity won't change when slash commands are received.

Alternatively, remove the `else if (slashCommands.length > 0)` branch entirely since `usingCachedCommandsRef` is only used for tracking and not consumed elsewhere in the diff — the ref could be set directly in the `connect` function instead.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Process a SubagentEvent: accumulate inner events into parent Task tool's subagentSteps
const processSubagentEvent = useCallback(
Expand Down Expand Up @@ -1805,20 +1814,56 @@ export function useSessionStream(
],
);

// Helper to send initialize message
const sendInitialize = useCallback((ws: WebSocket) => {
const id = uuidV4();
initializeIdRef.current = id;
const message = {
jsonrpc: "2.0",
method: "initialize",
id,
params: {
protocol_version: "1.3",
client: {
name: "kiwi",
version: kimiCliVersion,
},
capabilities: {
supports_question: true,
},
},
};
ws.send(JSON.stringify(message));
console.log("[SessionStream] Sent initialize message");
}, []);

// Handle incoming WebSocket message
const handleMessage = useCallback(
(data: string) => {
try {
console.log("[SessionStream] Received raw message:", data);
const message: WireMessage = JSON.parse(data);
console.log("[SessionStream] Parsed message:", message);

// Check for JSON-RPC error response
if (message.error) {
// Initialize failure during busy session is non-fatal - just skip
// Initialize failure during busy session is non-fatal - retry after delay
if (message.id === initializeIdRef.current) {
console.warn("[SessionStream] Initialize rejected (session busy), continuing...");
initializeRetryCountRef.current += 1;

if (initializeRetryCountRef.current > MAX_INITIALIZE_RETRIES) {
initializeIdRef.current = null;
initializeRetryCountRef.current = 0;
return;
}

initializeIdRef.current = null;

// Auto-retry initialize after 2 seconds
setTimeout(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
sendInitialize(wsRef.current);
}
}, 2000);
Comment on lines +1861 to +1865
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Initialize retry setTimeout is not cancelled on disconnect/reconnect, risking cross-session initialize

The setTimeout at line 1861 that retries sendInitialize after 2 seconds is not stored in any ref and is never cancelled. If the user switches sessions or the WebSocket disconnects during the 2-second window, the timer fires and calls sendInitialize(wsRef.current). Since wsRef.current may now point to a different session's WebSocket (or be null), this could send an unexpected initialize message to the wrong session. While the wsRef.current?.readyState === WebSocket.OPEN guard at line 1862 prevents sending on a closed socket, it does not prevent sending on a new WebSocket opened for a different session.

Prompt for agents
In web/src/hooks/useSessionStream.ts around line 1861, the initialize retry setTimeout should be tracked in a ref (e.g. `initializeRetryTimeoutRef`) so it can be cancelled in the `disconnect` function and in `connect` when a new connection starts. Capture the current `ws` reference before the setTimeout and compare it against `wsRef.current` inside the callback to ensure the retry only fires for the same WebSocket connection:

const currentWs = wsRef.current;
const retryTimeout = setTimeout(() => {
  if (wsRef.current === currentWs && wsRef.current?.readyState === WebSocket.OPEN) {
    sendInitialize(wsRef.current);
  }
}, 2000);
initializeRetryTimeoutRef.current = retryTimeout;

Also clear this timeout in the `disconnect` function and at the start of `connect`.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return;
}

Expand Down Expand Up @@ -1897,13 +1942,14 @@ export function useSessionStream(

// Handle initialize response
if (message.id && message.id === initializeIdRef.current && message.result) {
console.log("[SessionStream] Initialize response received:", message.result);
initializeIdRef.current = null;
initializeRetryCountRef.current = 0;

// Extract slash commands
const { slash_commands } = message.result;
if (slash_commands) {

if (slash_commands && slash_commands.length > 0) {
setSlashCommands(slash_commands);
usingCachedCommandsRef.current = false;
}
return;
}
Expand Down Expand Up @@ -1961,6 +2007,7 @@ export function useSessionStream(
setAwaitingFirstResponse,
applySessionStatus,
completeStreamingMessages,
sendInitialize,
],
);

Expand Down Expand Up @@ -2010,29 +2057,6 @@ export function useSessionStream(
[setAwaitingFirstResponse],
);

// Helper to send initialize message
const sendInitialize = useCallback((ws: WebSocket) => {
const id = uuidV4();
initializeIdRef.current = id;
const message = {
jsonrpc: "2.0",
method: "initialize",
id,
params: {
protocol_version: "1.3",
client: {
name: "kiwi",
version: kimiCliVersion,
},
capabilities: {
supports_question: true,
},
},
};
ws.send(JSON.stringify(message));
console.log("[SessionStream] Sent initialize message");
}, []);

const respondToApproval = useCallback(
async (
requestId: string,
Expand Down Expand Up @@ -2196,8 +2220,11 @@ export function useSessionStream(
const connect = useCallback(() => {
if (!sessionId) return;

initializeRetryCountRef.current = 0; // Reset retry count for new connection

// Close existing connection
if (wsRef.current) {
console.log("[SessionStream] Closing existing WebSocket");
wsRef.current.close();
wsRef.current = null;
}
Expand All @@ -2207,7 +2234,7 @@ export function useSessionStream(
}

awaitingIdleRef.current = false;
resetState();
resetState(true); // preserve slashCommands on reconnect
setMessages([]);
setStatus("submitted");
setAwaitingFirstResponse(Boolean(pendingMessageRef.current));
Expand Down
Loading