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
23 changes: 23 additions & 0 deletions src/kimi_cli/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@


class ApprovalStateData(BaseModel):
approval_mode: Literal["manual", "edits", "auto"] = "manual"
"""Approval mode: manual, edits (auto-approve file ops), auto (auto-approve everything)."""
yolo: bool = False
"""Legacy flag, migrated to approval_mode='auto'. Kept for backward compat."""
afk: bool = False
"""Legacy flag, migrated to approval_mode='auto'. Kept for backward compat."""
auto_approve_actions: set[str] = Field(default_factory=set)
"""Legacy set, migrated to approval_mode='edits'. Kept for backward compat."""


class TodoItemState(BaseModel):
Expand Down Expand Up @@ -96,6 +101,21 @@ def _migrate_legacy_metadata(session_dir: Path, state: SessionState) -> str:
return "migrated" if changed else "no_change"


def _migrate_legacy_approval_state(state: SessionState) -> None:
"""Migrate legacy yolo/afk to approval_mode.

auto_approve_actions is intentionally NOT promoted to edits mode here.
The legacy set still works when approval_mode is manual (checked in
Approval.request), so promoting it would broaden the approval scope
and misrepresent the session state in the UI.
"""
approval = state.approval
if approval.approval_mode != "manual":
return
if approval.yolo or approval.afk:
approval.approval_mode = "auto"


def load_session_state(session_dir: Path) -> SessionState:
state_file = session_dir / STATE_FILE_NAME
if not state_file.exists():
Expand All @@ -111,6 +131,9 @@ def load_session_state(session_dir: Path) -> SessionState:
track("session_load_failed", reason=type(e).__name__)
state = SessionState()

# Migrate legacy approval flags to approval_mode
_migrate_legacy_approval_state(state)

# One-time migration from legacy metadata.json (best-effort)
migration = _migrate_legacy_metadata(session_dir, state)
if migration in ("migrated", "no_change"):
Expand Down
3 changes: 3 additions & 0 deletions src/kimi_cli/soul/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
if TYPE_CHECKING:
from kimi_cli.llm import LLM, ModelCapability
from kimi_cli.soul.agent import Runtime
from kimi_cli.soul.approval import ApprovalMode
from kimi_cli.utils.slashcmd import SlashCommand


Expand Down Expand Up @@ -98,6 +99,8 @@ class StatusSnapshot:
"""The maximum number of tokens the context can hold."""
mcp_status: MCPStatusSnapshot | None = None
"""The current MCP startup snapshot, if MCP is configured."""
approval_mode: ApprovalMode = "manual"
"""Approval mode: manual, edits (auto-approve file ops), auto (auto-approve everything)."""


@runtime_checkable
Expand Down
12 changes: 8 additions & 4 deletions src/kimi_cli/soul/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,23 +274,27 @@ async def create(
additional_dirs_info = "\n\n".join(parts)

# Merge invocation flags with persisted session state.
effective_yolo = yolo or session.state.approval.yolo
effective_mode = session.state.approval.approval_mode
if yolo or session.state.approval.yolo or session.state.approval.afk:
effective_mode = "auto"
if afk and not session.state.approval.afk:
session.state.approval.afk = True
session.save_state()
saved_actions = set(session.state.approval.auto_approve_actions)

def _on_approval_change() -> None:
session.state.approval.approval_mode = approval_state.approval_mode
# Also update legacy flags so older code reading them directly sees consistency.
session.state.approval.yolo = approval_state.yolo
session.state.approval.afk = approval_state.afk
session.state.approval.auto_approve_actions = set(approval_state.auto_approve_actions)
session.save_state()

approval_state = ApprovalState(
yolo=effective_yolo,
approval_mode=effective_mode,
yolo=yolo or session.state.approval.yolo,
afk=session.state.approval.afk,
Comment on lines 292 to 295
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the --yolo flag in approval state

When an interactive session is started with --yolo, effective_mode becomes "all" but the shared state keeps yolo from the persisted session instead of the invocation flag. In that scenario /yolo checks is_yolo_flag(), sees false, and turns yolo on rather than disabling auto-approval on the first toggle, which is a regression from the previous effective_yolo = yolo or session.state.approval.yolo behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. ApprovalState now receives yolo or session.state.approval.yolo, so invocation-time --yolo correctly sets the internal yolo flag and /yolo toggle works as expected on first use.

runtime_afk=runtime_afk,
auto_approve_actions=saved_actions,
auto_approve_actions=set(session.state.approval.auto_approve_actions),
on_change=_on_approval_change,
)
notifications = NotificationManager(
Expand Down
94 changes: 78 additions & 16 deletions src/kimi_cli/soul/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@

type Response = Literal["approve", "approve_for_session", "reject"]

# Actions that touch files but do not execute arbitrary shell commands.
# Action strings passed by WriteFile and StrReplaceFile tools.
_FILE_ACTIONS = frozenset({
"edit file",
"edit file outside of working directory",
})

type ApprovalMode = Literal["manual", "edits", "auto"]


class ApprovalResult:
"""Result of an approval request. Behaves as bool for backward compatibility."""
Expand Down Expand Up @@ -59,18 +68,22 @@ def __init__(
afk: bool = False,
runtime_afk: bool = False,
auto_approve_actions: set[str] | None = None,
approval_mode: ApprovalMode = "manual",
on_change: Callable[[], None] | None = None,
):
# Derive approval_mode from legacy flags if not explicitly provided.
if approval_mode == "manual" and (yolo or afk):
approval_mode = "auto"
elif approval_mode == "manual" and auto_approve_actions:
approval_mode = "edits"
Comment on lines +77 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid promoting legacy action approvals to edits

Fresh evidence: load_session_state() now intentionally leaves legacy auto_approve_actions in manual mode, but Runtime.create() still passes that set into this constructor, and this branch promotes any non-empty set to edits. Resuming a session that previously approved only a non-file action such as run command therefore shows edits mode and starts auto-approving all file edits while the old shell action remains auto-approved via the preserved set, broadening the session's approval scope.

Useful? React with 👍 / 👎.


self.approval_mode: ApprovalMode = approval_mode
"""Current approval mode: manual, edits, or auto."""
# Keep legacy fields for backward compat with tests and external code.
self.yolo = yolo
self.afk = afk
"""Persisted session flag. True when no user is present.

Implies auto-approve and is restored with the session.
"""
self.runtime_afk = runtime_afk
"""Invocation-only afk flag, e.g. ``--afk`` or ``--print``. Not persisted."""
self.auto_approve_actions: set[str] = auto_approve_actions or set()
"""Set of action names that should automatically be approved."""
self._on_change = on_change

def notify_change(self) -> None:
Expand Down Expand Up @@ -101,7 +114,12 @@ def runtime(self) -> ApprovalRuntime:
return self._runtime

def set_yolo(self, yolo: bool) -> None:
"""Legacy setter; maps to approval_mode for backward compat."""
self._state.yolo = yolo
if yolo:
self._state.approval_mode = "auto"
elif self._state.approval_mode == "auto" and not self._state.afk:
self._state.approval_mode = "manual"
self._state.notify_change()

def set_afk(self, afk: bool) -> None:
Expand All @@ -112,6 +130,10 @@ def set_afk(self, afk: bool) -> None:
behavior via ``/afk``.
"""
self._state.afk = afk
if afk:
self._state.approval_mode = "auto"
elif self._state.approval_mode == "auto" and not self._state.yolo:
self._state.approval_mode = "manual"
if not afk:
self._state.runtime_afk = False
self._state.notify_change()
Expand All @@ -120,13 +142,30 @@ def set_runtime_afk(self, afk: bool) -> None:
"""Toggle invocation-only afk mode without persisting it."""
self._state.runtime_afk = afk

def is_auto_approve(self) -> bool:
"""True when tool calls should be auto-approved.
def set_approval_mode(self, mode: ApprovalMode) -> None:
self._state.approval_mode = mode
self._state.yolo = mode == "auto"
self._state.afk = False
self._state.auto_approve_actions.clear()
self._state.notify_change()

Afk implies auto-approve, so this returns True whenever either the
explicit yolo flag or afk is set.
"""
return self._state.yolo or self.is_afk()
def cycle_approval_mode(self) -> ApprovalMode:
"""Cycle to the next approval mode (manual → edits → auto → manual)."""
match self._state.approval_mode:
case "manual":
new_mode = "edits"
case "edits":
new_mode = "auto"
case "auto":
new_mode = "manual"
case _:
new_mode = "manual"
self.set_approval_mode(new_mode)
return new_mode

def is_auto_approve(self) -> bool:
"""True when tool calls should be auto-approved."""
return self._state.approval_mode == "auto" or self.is_afk()

def is_yolo(self) -> bool:
"""True only when the user explicitly opted into yolo."""
Expand All @@ -148,6 +187,22 @@ def is_runtime_afk(self) -> bool:
"""True only when afk came from this invocation."""
return self._state.runtime_afk

def get_auto_approve_actions(self) -> set[str]:
"""Return the set of action names that are auto-approved for this session."""
return set(self._state.auto_approve_actions)

def get_approval_mode(self) -> ApprovalMode:
"""Return the current approval mode."""
return self._state.approval_mode

def _should_auto_approve(self, action: str) -> bool:
"""Determine if an action should be auto-approved based on current mode."""
if self._state.approval_mode == "auto" or self.is_afk():
return True
if self._state.approval_mode == "edits":
return action in _FILE_ACTIONS
return False

async def request(
self,
sender: str,
Expand Down Expand Up @@ -182,13 +237,14 @@ async def request(
action=action,
description=description,
)
if self.is_auto_approve():

if self._should_auto_approve(action):
from kimi_cli.telemetry import track

track(
"tool_approved",
tool_name=tool_call.function.name,
approval_mode="afk" if self.is_afk() else "yolo",
approval_mode=self._state.approval_mode,
)
return ApprovalResult(approved=True)

Expand Down Expand Up @@ -245,8 +301,14 @@ async def request(
tool_name=tool_call.function.name,
approval_mode="manual",
)
self._state.auto_approve_actions.add(action)
self._state.notify_change()
# Promote approval mode based on action type to keep UX simple.
if action in _FILE_ACTIONS and self._state.approval_mode == "manual":
self.set_approval_mode("edits")
elif self._state.approval_mode in ("manual", "edits"):
self.set_approval_mode("auto")
else:
self._state.auto_approve_actions.add(action)
self._state.notify_change()
for pending in self._runtime.list_pending():
if pending.action == action:
self._runtime.resolve(pending.id, "approve")
Expand Down
6 changes: 6 additions & 0 deletions src/kimi_cli/soul/kimisoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ def status(self) -> StatusSnapshot:
context_tokens=token_count,
max_context_tokens=max_size,
mcp_status=self._mcp_status_snapshot(),
approval_mode=self._approval.get_approval_mode(),
)

@property
Expand All @@ -501,6 +502,11 @@ def agent(self) -> Agent:
def runtime(self) -> Runtime:
return self._runtime

def cycle_approval_mode(self) -> str:
"""Cycle to the next approval mode and return the new mode name."""
new_mode = self._approval.cycle_approval_mode()
return new_mode

@property
def context(self) -> Context:
return self._context
Expand Down
27 changes: 22 additions & 5 deletions src/kimi_cli/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,10 +411,27 @@ async def run(self, command: str | None = None) -> bool:
)
await self.soul.start_background_mcp_loading()

async def _plan_mode_toggle() -> bool:
if isinstance(self.soul, KimiSoul):
return await self.soul.toggle_plan_mode_from_manual()
return False
async def _mode_cycle() -> str:
"""Cycle through unified modes: manual → edits → auto → plan → manual."""
if not isinstance(self.soul, KimiSoul):
return "manual"
status = self.soul.status
if status.plan_mode:
await self.soul.toggle_plan_mode_from_manual()
self.soul.runtime.approval.set_approval_mode("manual")
return "manual"
match status.approval_mode:
case "manual":
self.soul.runtime.approval.set_approval_mode("edits")
return "edits"
case "edits":
self.soul.runtime.approval.set_approval_mode("auto")
return "auto"
case "auto":
await self.soul.toggle_plan_mode_from_manual()
self.soul.runtime.approval.set_approval_mode("manual")
return "plan"
return "manual"

def _mcp_status_block(columns: int):
if not isinstance(self.soul, KimiSoul):
Expand Down Expand Up @@ -468,7 +485,7 @@ def _bg_task_counts() -> BgTaskCounts:
editor_command_provider=lambda: (
self.soul.runtime.config.default_editor if isinstance(self.soul, KimiSoul) else ""
),
plan_mode_toggle_callback=_plan_mode_toggle,
mode_cycle_callback=_mode_cycle,
) as prompt_session:
self._prompt_session = prompt_session
if self._prefill_text:
Expand Down
Loading