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

## Unreleased

- Shell: Refine idle background completion auto-trigger — resumed shell sessions no longer auto-start a foreground turn from stale pending background notifications before the user sends a message, and fresh background completions now wait briefly while the user is actively typing to avoid stealing the prompt or breaking CJK IME composition
- Core: Fix interrupted foreground turns leaving unbalanced wire events — `TurnEnd` is now emitted even when a turn exits via cancellation or step interruption, preventing dirty session wire logs from accumulating across resume cycles
- Core: Improve session startup resilience — `--continue`/`--resume` now tolerate malformed `context.jsonl` records and corrupted subagent, background-task, or notification artifacts; the CLI skips invalid persisted state where possible instead of failing to restore the session
- Grep: Add `include_ignored` parameter to search files excluded by `.gitignore` — when set to `true`, ripgrep's `--no-ignore` flag is enabled, allowing searches in gitignored artifacts such as build outputs or `node_modules`; sensitive files (like `.env`) remain filtered by the sensitive-file protection layer; defaults to `false` to preserve existing behavior
- Core: Add sensitive file protection to Grep and Read tools — `.env`, SSH private keys (`id_rsa`, `id_ed25519`, `id_ecdsa`), and cloud credentials (`.aws/credentials`, `.gcp/credentials`) are now detected and blocked; Grep filters them from results with a warning, Read rejects them outright; `.env.example`/`.env.sample`/`.env.template` are exempted
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

- Shell: Refine idle background completion auto-trigger — resumed shell sessions no longer auto-start a foreground turn from stale pending background notifications before the user sends a message, and fresh background completions now wait briefly while the user is actively typing to avoid stealing the prompt or breaking CJK IME composition
- Core: Fix interrupted foreground turns leaving unbalanced wire events — `TurnEnd` is now emitted even when a turn exits via cancellation or step interruption, preventing dirty session wire logs from accumulating across resume cycles
- Grep: Add `include_ignored` parameter to search files excluded by `.gitignore` — when set to `true`, ripgrep's `--no-ignore` flag is enabled, allowing searches in gitignored artifacts such as build outputs or `node_modules`; sensitive files (like `.env`) remain filtered by the sensitive-file protection layer; defaults to `false` to preserve existing behavior
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.

🟡 Duplicate Grep changelog entry in English docs

The PR adds three new entries to the English changelog at lines 7-9, but line 9 is an exact duplicate of the pre-existing line 11 — both are the Grep: Add include_ignored parameter entry. The root CHANGELOG.md (lines 14-17) has no such duplicate; only the Shell and Core entries were added there. The duplicate appears to have been introduced by manual editing of the English docs, which per docs/AGENTS.md should be auto-synced from the root CHANGELOG.md via script (npm run sync).

Suggested change
- Grep: Add `include_ignored` parameter to search files excluded by `.gitignore` — when set to `true`, ripgrep's `--no-ignore` flag is enabled, allowing searches in gitignored artifacts such as build outputs or `node_modules`; sensitive files (like `.env`) remain filtered by the sensitive-file protection layer; defaults to `false` to preserve existing behavior
Open in Devin Review

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

- Core: Improve session startup resilience — `--continue`/`--resume` now tolerate malformed `context.jsonl` records and corrupted subagent, background-task, or notification artifacts; the CLI skips invalid persisted state where possible instead of failing to restore the session
- Grep: Add `include_ignored` parameter to search files excluded by `.gitignore` — when set to `true`, ripgrep's `--no-ignore` flag is enabled, allowing searches in gitignored artifacts such as build outputs or `node_modules`; sensitive files (like `.env`) remain filtered by the sensitive-file protection layer; defaults to `false` to preserve existing behavior
- CLI: Improve `kimi export` session export UX — `kimi export` now previews the previous session for the current working directory and asks for confirmation, showing the session ID, title, and last user-message time; adds `--yes` to skip confirmation; also fixes explicit session-ID invocations where `--output` after the argument was incorrectly parsed as a subcommand
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 未发布

- Shell:细化空闲时后台完成的自动触发行为——恢复的 Shell 会话在用户发送消息前,不会因为历史遗留的后台通知而自动启动新的前景轮次;当用户正在输入时,新的后台完成事件也会短暂延后触发,避免抢占提示符或打断 CJK 输入法组合态
- Core:修复前景轮次在中断后残留不平衡 Wire 事件的问题——轮次因取消或步骤中断退出时,现在也会补发 `TurnEnd`,避免恢复多次后会话 `wire.jsonl` 越来越脏
- Core:提升会话启动恢复的鲁棒性——`--continue`/`--resume` 现在可容忍损坏的 `context.jsonl` 记录,以及损坏的子 Agent、后台任务或通知持久化工件;CLI 会尽可能跳过无效状态并继续恢复会话,而不是直接启动失败
- CLI:改进 `kimi export` 会话导出体验——`kimi export` 现在默认预览并确认当前工作目录的上一个会话,显示会话 ID、标题和最后一条用户消息时间;新增 `--yes` 跳过确认;同时修复显式会话 ID 时 `--output` 放在参数后面会被错误解析为子命令的问题
- Grep:新增 `include_ignored` 参数,支持搜索被 `.gitignore` 排除的文件——设为 `true` 时启用 ripgrep 的 `--no-ignore` 标志,可搜索构建产物或 `node_modules` 等通常被忽略的文件;敏感文件(如 `.env`)仍由敏感文件保护层过滤;默认 `false`,不影响现有行为
Expand Down
8 changes: 8 additions & 0 deletions src/kimi_cli/soul/kimisoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ def available_slash_commands(self) -> list[SlashCommand[Any]]:

async def run(self, user_input: str | list[ContentPart]):
approval_source_token = None
turn_started = False
turn_finished = False
if get_current_approval_source_or_none() is None:
approval_source_token = set_current_approval_source(
ApprovalSource(kind="foreground_turn", id=uuid.uuid4().hex)
Expand Down Expand Up @@ -489,11 +491,14 @@ async def run(self, user_input: str | list[ContentPart]):
for result in hook_results:
if result.action == "block":
wire_send(TurnBegin(user_input=user_input))
turn_started = True
wire_send(TextPart(text=result.reason or "Prompt blocked by hook."))
wire_send(TurnEnd())
turn_finished = True
return

wire_send(TurnBegin(user_input=user_input))
turn_started = True
user_message = Message(role="user", content=user_input)
text_input = user_message.extract_text(" ").strip()

Expand Down Expand Up @@ -535,6 +540,7 @@ async def run(self, user_input: str | list[ContentPart]):
break

wire_send(TurnEnd())
turn_finished = True

# Auto-set title after first real turn (skip slash commands)
if not command_call:
Expand All @@ -560,6 +566,8 @@ async def run(self, user_input: str | list[ContentPart]):
save_session_state(fresh, session.dir)
session.state.custom_title = fresh.custom_title
finally:
if turn_started and not turn_finished:
wire_send(TurnEnd())
if approval_source_token is not None:
reset_current_approval_source(approval_source_token)

Expand Down
135 changes: 125 additions & 10 deletions src/kimi_cli/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from enum import Enum
from typing import Any
from typing import Any, Protocol

from kosong.chat_provider import APIStatusError, ChatProviderError
from rich.console import Group, RenderableType
Expand Down Expand Up @@ -64,18 +64,32 @@ class _PromptEvent:
_MAX_BG_AUTO_TRIGGER_FAILURES = 3
"""Stop auto-triggering after this many consecutive failures."""

_BG_AUTO_TRIGGER_INPUT_GRACE_S = 0.75
"""Delay background auto-trigger briefly after local prompt activity."""


class _BackgroundCompletionWatcher:
"""Watches for background task completions and auto-triggers the agent.

Sits between the idle event loop and the soul: when a background task
finishes while the agent is idle *and* the LLM hasn't consumed the
notification yet, it triggers a soul run.

Important: pre-existing pending notifications alone should not trigger a
foreground run immediately on session resume. They are consumed either by
the next actual background completion signal or by the next user-triggered
turn.
"""

def __init__(self, soul: Soul) -> None:
def __init__(
self,
soul: Soul,
*,
can_auto_trigger_pending: Callable[[], bool] | None = None,
) -> None:
self._event: asyncio.Event | None = None
self._notifications: NotificationManager | None = None
self._can_auto_trigger_pending = can_auto_trigger_pending or (lambda: True)
if isinstance(soul, KimiSoul):
self._event = soul.runtime.background_tasks.completion_event
self._notifications = soul.runtime.notifications
Expand All @@ -97,11 +111,16 @@ async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _Prom
User input always takes priority over background completions.
"""
if self.enabled and self._has_pending_llm_notifications():
# Pending notifications exist, but user input still wins.
# Pending notifications already exist (for example after resume).
# Before the user sends the first foreground turn after resume,
# pending background notifications should not auto-trigger a run.
# Once the shell is armed by a user-triggered turn, pending
# notifications can resume the normal auto-follow-up behavior.
try:
return idle_events.get_nowait()
except asyncio.QueueEmpty:
return None
if self._can_auto_trigger_pending():
return None

idle_task = asyncio.create_task(idle_events.get())
if not self.enabled:
Expand All @@ -128,7 +147,9 @@ async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _Prom
# Only bg fired
self._event.clear()
if self._has_pending_llm_notifications():
return None
if self._can_auto_trigger_pending():
return None
return _PromptEvent(kind="bg_noop")
return _PromptEvent(kind="bg_noop")

def _has_pending_llm_notifications(self) -> bool:
Expand All @@ -137,6 +158,16 @@ def _has_pending_llm_notifications(self) -> bool:
return self._notifications.has_pending_for_sink("llm")


class _BackgroundAutoTriggerPromptState(Protocol):
def has_pending_input(self) -> bool: ...

def had_recent_input_activity(self, *, within_s: float) -> bool: ...

def recent_input_activity_remaining(self, *, within_s: float) -> float: ...

async def wait_for_input_activity(self) -> None: ...


class Shell:
def __init__(
self,
Expand Down Expand Up @@ -388,19 +419,44 @@ async def _invalidate_after_mcp_loading() -> None:
prompt_task = asyncio.create_task(
self._route_prompt_events(prompt_session, idle_events, resume_prompt)
)
bg_watcher = _BackgroundCompletionWatcher(self.soul)
background_autotrigger_armed = False

def _can_auto_trigger_pending() -> bool:
return background_autotrigger_armed

bg_watcher = _BackgroundCompletionWatcher(
self.soul,
can_auto_trigger_pending=_can_auto_trigger_pending,
)

shell_ok = True
bg_auto_failures = 0
deferred_bg_trigger = False
try:
while True:
bg_watcher.clear()
if bg_auto_failures >= _MAX_BG_AUTO_TRIGGER_FAILURES:
result = await idle_events.get()
if deferred_bg_trigger and not self._should_defer_background_auto_trigger(
prompt_session
):
result = None
elif deferred_bg_trigger:
result = await self._wait_for_input_or_activity(
prompt_session,
idle_events,
timeout_s=self._background_auto_trigger_timeout_s(prompt_session),
)
else:
result = await bg_watcher.wait_for_next(idle_events)
bg_watcher.clear()
if bg_auto_failures >= _MAX_BG_AUTO_TRIGGER_FAILURES:
result = await idle_events.get()
else:
result = await bg_watcher.wait_for_next(idle_events)

if result is None:
if self._should_defer_background_auto_trigger(prompt_session):
deferred_bg_trigger = True
resume_prompt.set()
continue
deferred_bg_trigger = False
logger.info("Background task completed while idle, triggering agent")
resume_prompt.set()
ok = await self.run_soul_command(
Expand All @@ -426,6 +482,9 @@ async def _invalidate_after_mcp_loading() -> None:

event = result

if event.kind == "input_activity":
continue

if event.kind == "bg_noop":
continue

Expand All @@ -445,6 +504,7 @@ async def _invalidate_after_mcp_loading() -> None:
user_input = event.user_input
assert user_input is not None
bg_auto_failures = 0
deferred_bg_trigger = False
if not user_input:
logger.debug("Got empty input, skipping")
resume_prompt.set()
Expand All @@ -470,6 +530,7 @@ async def _invalidate_after_mcp_loading() -> None:
and shell_slash_registry.find_command(slash_cmd_call.name) is None
)
if is_soul_slash:
background_autotrigger_armed = True
resume_prompt.set()
await self.run_soul_command(slash_cmd_call.raw_input)
console.print()
Expand All @@ -481,6 +542,7 @@ async def _invalidate_after_mcp_loading() -> None:
resume_prompt.set()
continue

background_autotrigger_armed = True
resume_prompt.set()
await self.run_soul_command(user_input.content)
console.print()
Expand Down Expand Up @@ -675,6 +737,59 @@ def _handler():
remove_sigint()
return False

@staticmethod
def _should_defer_background_auto_trigger(
prompt_session: _BackgroundAutoTriggerPromptState | None,
) -> bool:
if prompt_session is None:
return False
return prompt_session.has_pending_input() or prompt_session.had_recent_input_activity(
within_s=_BG_AUTO_TRIGGER_INPUT_GRACE_S
)

@staticmethod
def _background_auto_trigger_timeout_s(
prompt_session: _BackgroundAutoTriggerPromptState | None,
) -> float | None:
if prompt_session is None or prompt_session.has_pending_input():
return None
remaining = prompt_session.recent_input_activity_remaining(
within_s=_BG_AUTO_TRIGGER_INPUT_GRACE_S
)
return remaining if remaining > 0 else None

async def _wait_for_input_or_activity(
self,
prompt_session: _BackgroundAutoTriggerPromptState,
idle_events: asyncio.Queue[_PromptEvent],
*,
timeout_s: float | None = None,
) -> _PromptEvent:
idle_task = asyncio.create_task(idle_events.get())
activity_task = asyncio.create_task(prompt_session.wait_for_input_activity())
timeout_task = (
asyncio.create_task(asyncio.sleep(timeout_s)) if timeout_s is not None else None
)
done: set[asyncio.Task[Any]] = set()
try:
done, _ = await asyncio.wait(
[task for task in (idle_task, activity_task, timeout_task) if task is not None],
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for task in (idle_task, activity_task, timeout_task):
if task is None:
continue
if task.done():
continue
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task

if idle_task in done:
return idle_task.result()
return _PromptEvent(kind="input_activity")

async def _watch_root_wire_hub(self) -> None:
if not isinstance(self.soul, KimiSoul):
return
Expand Down
22 changes: 22 additions & 0 deletions src/kimi_cli/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,8 @@ def __init__(
self._attachment_cache = self._placeholder_manager.attachment_cache
self._last_tip_rotate_time: float = time.monotonic()
self._last_submission_was_running = False
self._last_input_activity_time: float = 0.0
self._input_activity_event: asyncio.Event = asyncio.Event()
self._running_prompt_previous_mode: PromptMode | None = None
self._running_prompt_delegate: RunningPromptDelegate | None = None
self._modal_delegates: list[RunningPromptDelegate] = []
Expand Down Expand Up @@ -1516,6 +1518,8 @@ def _(event: KeyPressEvent) -> None:
# such as when backspace is used to delete text.
@self._session.default_buffer.on_text_changed.add_handler
def _(buffer: Buffer) -> None:
self._last_input_activity_time = time.monotonic()
self._input_activity_event.set()
if buffer.complete_while_typing():
buffer.start_completion()

Expand Down Expand Up @@ -1891,6 +1895,24 @@ async def prompt_next(self) -> UserInput:
def last_submission_was_running(self) -> bool:
return getattr(self, "_last_submission_was_running", False)

def has_pending_input(self) -> bool:
return bool(self._session.default_buffer.text)

def had_recent_input_activity(self, *, within_s: float) -> bool:
if self._last_input_activity_time <= 0:
return False
return (time.monotonic() - self._last_input_activity_time) <= within_s

def recent_input_activity_remaining(self, *, within_s: float) -> float:
if self._last_input_activity_time <= 0:
return 0.0
elapsed = time.monotonic() - self._last_input_activity_time
return max(0.0, within_s - elapsed)

async def wait_for_input_activity(self) -> None:
await self._input_activity_event.wait()
self._input_activity_event.clear()
Comment on lines +1913 to +1914
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

wait_for_input_activity() clears the asyncio.Event after awaiting it. If another input activity happens between the await completing and the subsequent .clear(), the new signal can be lost, and callers may miss recent typing activity. Consider clearing the event before waiting (or switching to a monotonic counter/Condition) so activity signals can’t be dropped by this race.

Suggested change
await self._input_activity_event.wait()
self._input_activity_event.clear()
# Clear any previous activity signal before waiting, so that
# new activity that occurs after this point will reliably wake us.
self._input_activity_event.clear()
await self._input_activity_event.wait()

Copilot uses AI. Check for mistakes.

def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None:
current = getattr(self, "_running_prompt_delegate", None)
if current is delegate:
Expand Down
Loading
Loading