From 9894c4de77a203a64940364d74df828da5a565dd Mon Sep 17 00:00:00 2001 From: n-WN <30841158+n-WN@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:34:22 +0800 Subject: [PATCH 1/3] fix: exit gracefully with crash report when CWD is removed during session When the working directory disappears (e.g. external drive unplugged), os.getcwd() raises OSError in the bottom toolbar render callback. Because prompt_toolkit's event loop catches unhandled exceptions and shows a "Press ENTER to continue..." prompt that itself triggers a redraw, this creates an infinite cascade of errors flooding the terminal. Fix by catching OSError at the KaosPath.cwd() call site in _render_bottom_toolbar and calling app.exit(exception=CwdLostError()) to cleanly terminate the prompt_toolkit application. The Shell event router then catches CwdLostError and prints a rich-formatted crash panel with session ID and working directory, so the user knows what happened and can resume. Closes #1621 --- src/kimi_cli/ui/shell/__init__.py | 42 +++++++++++++++++++++++++++++++ src/kimi_cli/ui/shell/prompt.py | 14 ++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 1f1905b0e..a88cbaff0 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -27,6 +27,7 @@ from kimi_cli.ui.shell.mcp_status import render_mcp_prompt from kimi_cli.ui.shell.prompt import ( CustomPromptSession, + CwdLostError, PromptMode, UserInput, toast, @@ -160,6 +161,37 @@ def available_slash_commands(self) -> dict[str, SlashCommand[Any]]: """Get all available slash commands, including shell-level and soul-level commands.""" return self._available_slash_commands + def _print_cwd_lost_crash(self) -> None: + """Print a crash report when the working directory is no longer accessible.""" + runtime = self.soul.runtime if isinstance(self.soul, KimiSoul) else None + session_id = runtime.session.id if runtime else "unknown" + work_dir = str(runtime.session.work_dir) if runtime else "unknown" + + info = Table.grid(padding=(0, 1)) + info.add_row("Session:", session_id) + info.add_row("Working directory:", work_dir) + + panel = Panel( + Group( + Text( + "The working directory is no longer accessible " + "(external drive unplugged, directory deleted, or filesystem unmounted).", + ), + Text(""), + info, + Text(""), + Text( + "Your conversation history has been saved. " + "Restart kimi in a valid directory to continue.", + style="dim", + ), + ), + title="[bold red]Session crashed[/bold red]", + border_style="red", + ) + console.print() + console.print(panel) + @staticmethod def _should_exit_input(user_input: UserInput) -> bool: return user_input.command.strip() in {"exit", "quit", "/exit", "/quit"} @@ -240,6 +272,11 @@ async def _route_prompt_events( resume_prompt.clear() await idle_events.put(_PromptEvent(kind="eof")) return + except CwdLostError: + logger.error("Working directory no longer exists") + resume_prompt.clear() + await idle_events.put(_PromptEvent(kind="cwd_lost")) + return except Exception: logger.exception("Prompt router crashed") resume_prompt.clear() @@ -422,6 +459,11 @@ async def _invalidate_after_mcp_loading() -> None: console.print("Bye!") break + if event.kind == "cwd_lost": + self._print_cwd_lost_crash() + shell_ok = False + break + if event.kind == "error": shell_ok = False break diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index ede338e05..8fc648a27 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -80,6 +80,10 @@ PROMPT_SYMBOL_PLAN = "📋" +class CwdLostError(OSError): + """Raised when the working directory no longer exists (e.g. external drive unplugged).""" + + class SlashCommandCompleter(Completer): """ A completer that: @@ -2042,7 +2046,15 @@ def _render_bottom_toolbar(self) -> FormattedText: # CWD (truncated from left) + git branch with status badge # Degrade gracefully on narrow terminals: full → cwd-only → truncated cwd → skip - cwd = _truncate_left(_shorten_cwd(str(KaosPath.cwd())), _MAX_CWD_COLS) + try: + cwd = _truncate_left(_shorten_cwd(str(KaosPath.cwd())), _MAX_CWD_COLS) + except OSError: + # CWD no longer exists (e.g. external drive unplugged). Ask + # prompt_toolkit to exit; the raised exception will propagate out + # of prompt_async() into the Shell's event router which prints a + # crash report with session info and exits cleanly. + app.exit(exception=CwdLostError()) + return FormattedText([]) branch = _get_git_branch() if branch: dirty, ahead, behind = _get_git_status() From 68b738bbfd5d74342810ae4556fec6196f2ce095 Mon Sep 17 00:00:00 2001 From: n-WN <30841158+n-WN@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:57:59 +0800 Subject: [PATCH 2/3] docs: regenerate changelog for CWD loss crash fix --- CHANGELOG.md | 2 ++ docs/en/release-notes/changelog.md | 2 ++ docs/zh/release-notes/changelog.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3da592f6..d4e41add3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Fix crash when working directory is removed during session — instead of flooding the terminal with OSError tracebacks, the shell now exits cleanly with a crash report showing the session ID and working directory path, allowing users to restart in a valid directory + ## 1.27.0 (2026-03-28) - Shell: Add `/feedback` command — submit feedback directly from the CLI session; the command falls back to opening GitHub Issues on network errors or timeouts diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index b71cac5a8..03faaea63 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Shell: Fix crash when working directory is removed during session — instead of flooding the terminal with OSError tracebacks, the shell now exits cleanly with a crash report showing the session ID and working directory path, allowing users to restart in a valid directory + ## 1.27.0 (2026-03-28) - Shell: Add `/feedback` command — submit feedback directly from the CLI session; the command falls back to opening GitHub Issues on network errors or timeouts diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 1f8de646c..7f6377821 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Shell:修复会话期间工作目录被移除时崩溃的问题——不再向终端输出大量 OSError 堆栈跟踪,而是以崩溃报告形式优雅退出,显示会话 ID 和工作目录路径,用户可在有效目录下重启继续 + ## 1.27.0 (2026-03-28) - Shell:新增 `/feedback` 命令——可直接在 CLI 会话中提交反馈,网络错误或超时时自动回退到打开 GitHub Issues 页面 From 1573efb57184517c6f8f6d1e24adc96db2b0943c Mon Sep 17 00:00:00 2001 From: n-WN <30841158+n-WN@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:20:17 +0800 Subject: [PATCH 3/3] test: add unit test for CwdLostError event routing --- tests/ui_and_conv/test_shell_prompt_router.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/ui_and_conv/test_shell_prompt_router.py b/tests/ui_and_conv/test_shell_prompt_router.py index 91e7b4de5..593e3e452 100644 --- a/tests/ui_and_conv/test_shell_prompt_router.py +++ b/tests/ui_and_conv/test_shell_prompt_router.py @@ -9,7 +9,7 @@ import kimi_cli.ui.shell as shell_module from kimi_cli.soul import Soul -from kimi_cli.ui.shell.prompt import PromptMode, UserInput +from kimi_cli.ui.shell.prompt import CwdLostError, PromptMode, UserInput from kimi_cli.wire.types import TextPart @@ -245,3 +245,22 @@ def running_prompt_accepts_submission(self) -> bool: event = idle_events.get_nowait() assert event.kind == "eof" assert shell._exit_after_run is False + + +@pytest.mark.asyncio +async def test_route_prompt_events_cwd_lost_posts_cwd_lost_event( + _patched_prompt_router, +) -> None: + """When prompt_next raises CwdLostError the router should post a 'cwd_lost' + event and stop, so the main loop can print a crash report and exit.""" + shell = shell_module.Shell(cast(Soul, _make_fake_soul())) + prompt_session = _FakePromptSession([(False, CwdLostError())]) + idle_events: asyncio.Queue[shell_module._PromptEvent] = asyncio.Queue() + resume_prompt = asyncio.Event() + resume_prompt.set() + + await shell._route_prompt_events(cast(Any, prompt_session), idle_events, resume_prompt) + + event = idle_events.get_nowait() + assert event.kind == "cwd_lost" + assert not resume_prompt.is_set()