-
Notifications
You must be signed in to change notification settings - Fork 802
feat(shell): inject shell mode output into context & persist cd #1587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bddb90f
e0abf65
a5a6b51
5e4375f
6537e0c
96a434c
165b13b
000e82b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,9 @@ | |
|
|
||
| import asyncio | ||
| import contextlib | ||
| import os | ||
| import shlex | ||
| import sys | ||
| import time | ||
| from collections import deque | ||
| from collections.abc import Awaitable, Callable, Coroutine | ||
|
|
@@ -22,6 +24,12 @@ | |
| from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul | ||
| from kimi_cli.soul.kimisoul import KimiSoul | ||
| from kimi_cli.ui.shell import update as _update_mod | ||
| from kimi_cli.ui.shell.capture import inject_to_context | ||
|
|
||
| if sys.platform != "win32": | ||
| from kimi_cli.ui.shell.capture import execute_with_pty_capture as _execute_capture | ||
| else: | ||
| from kimi_cli.ui.shell.capture import execute_with_pipe_capture as _execute_capture | ||
| from kimi_cli.ui.shell.console import console | ||
| from kimi_cli.ui.shell.echo import render_user_echo_text | ||
| from kimi_cli.ui.shell.mcp_status import render_mcp_prompt | ||
|
|
@@ -40,7 +48,6 @@ | |
| visualize, | ||
| ) | ||
| from kimi_cli.utils.envvar import get_env_bool | ||
| from kimi_cli.utils.logging import open_original_stderr | ||
| from kimi_cli.utils.signals import install_sigint_handler | ||
| from kimi_cli.utils.slashcmd import SlashCommand, SlashCommandCall, parse_slash_command_call | ||
| from kimi_cli.utils.subprocess_env import get_clean_env | ||
|
|
@@ -487,7 +494,7 @@ async def _invalidate_after_mcp_loading() -> None: | |
| return shell_ok | ||
|
|
||
| async def _run_shell_command(self, command: str) -> None: | ||
| """Run a shell command in foreground.""" | ||
| """Run a shell command in foreground, capturing output for context injection.""" | ||
| if not command.strip(): | ||
| return | ||
|
|
||
|
|
@@ -503,45 +510,91 @@ async def _run_shell_command(self, command: str) -> None: | |
| ) | ||
| return | ||
|
|
||
| # Check if user is trying to use 'cd' command | ||
| # Handle bare `cd` / `cd <path>` — resolve and persist globally. | ||
| # Compound commands like `cd /tmp && ls` are left to the shell. | ||
| stripped_cmd = command.strip() | ||
| split_cmd: list[str] | None = None | ||
| try: | ||
| split_cmd = shlex.split(stripped_cmd) | ||
| except ValueError as exc: | ||
| logger.debug("Failed to parse shell command for cd check: {error}", error=exc) | ||
| if split_cmd and len(split_cmd) == 2 and split_cmd[0] == "cd": | ||
| console.print( | ||
| "[yellow]Warning: Directory changes are not preserved across command executions." | ||
| "[/yellow]" | ||
| ) | ||
| if split_cmd and split_cmd[0] == "cd" and len(split_cmd) <= 2: | ||
| await self._handle_cd(split_cmd) | ||
| return | ||
|
|
||
| logger.info("Running shell command: {cmd}", cmd=command) | ||
|
|
||
| proc: asyncio.subprocess.Process | None = None | ||
|
|
||
| def _handler(): | ||
| logger.debug("SIGINT received.") | ||
| if proc: | ||
| proc.terminate() | ||
|
|
||
| loop = asyncio.get_running_loop() | ||
| remove_sigint = install_sigint_handler(loop, _handler) | ||
| exit_code: int | None = None | ||
| raw_output: str | None = None | ||
| try: | ||
| # TODO: For the sake of simplicity, we now use `create_subprocess_shell`. | ||
| # Later we should consider making this behave like a real shell. | ||
| with open_original_stderr() as stderr: | ||
| kwargs: dict[str, Any] = {} | ||
| if stderr is not None: | ||
| kwargs["stderr"] = stderr | ||
| proc = await asyncio.create_subprocess_shell(command, env=get_clean_env(), **kwargs) | ||
| await proc.wait() | ||
| exit_code, raw_output = await _execute_capture( | ||
| command, env=get_clean_env(), cwd=os.getcwd() | ||
| ) | ||
| except Exception as e: | ||
| logger.exception("Failed to run shell command:") | ||
| console.print(f"[red]Failed to run shell command: {e}[/red]") | ||
| finally: | ||
| remove_sigint() | ||
|
|
||
| # Inject captured output into conversation context | ||
| if raw_output is not None and isinstance(self.soul, KimiSoul): | ||
| try: | ||
| await inject_to_context( | ||
| self.soul.context.append_message, command, raw_output, exit_code | ||
| ) | ||
| except Exception: | ||
| logger.debug("Failed to inject shell output to context", exc_info=True) | ||
|
|
||
| async def _handle_cd(self, args: list[str]) -> None: | ||
| """Resolve ``cd`` via a real shell and persist the directory change. | ||
|
|
||
| Only called for bare ``cd`` / ``cd <path>`` (at most 2 tokens). | ||
| """ | ||
| target = args[1] if len(args) > 1 else "~" | ||
|
|
||
| # Provide OLDPWD so `cd -` works across invocations. | ||
| env = get_clean_env() | ||
| old_cwd = os.getcwd() | ||
| if "OLDPWD" not in env: | ||
| env["OLDPWD"] = old_cwd | ||
|
|
||
| # Use shlex.quote for safety, but NOT for targets that need shell | ||
| # expansion: ~, -, and $VAR references. For those we let the real | ||
| # shell handle expansion directly. | ||
| needs_shell_expansion = target.startswith("~") or target.startswith("$") or target == "-" | ||
| quoted_target = target if needs_shell_expansion else shlex.quote(target) | ||
|
Comment on lines
+562
to
+563
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Unquoted tilde/dollar targets in The PR removed the Python-side Reproduction traceOld code path: New code path: Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+562
to
+563
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For targets starting with Useful? React with 👍 / 👎.
Comment on lines
+562
to
+563
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||
|
|
||
| # Let the shell resolve ~, -, $HOME, CDPATH, etc. | ||
| probe = await asyncio.create_subprocess_shell( | ||
| f"cd {quoted_target} && pwd", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||
| stdout=asyncio.subprocess.PIPE, | ||
| stderr=asyncio.subprocess.PIPE, | ||
| env=env, | ||
| ) | ||
| stdout, stderr = await probe.communicate() | ||
|
|
||
| if probe.returncode != 0: | ||
| err = stderr.decode("utf-8", errors="replace").strip() | ||
| console.print(f"[red]cd: {err or 'failed'}[/red]") | ||
| return | ||
|
|
||
| # `cd -` prints the destination before `pwd`; take the last non-empty line. | ||
| lines = [ln for ln in stdout.decode("utf-8", errors="replace").splitlines() if ln.strip()] | ||
| new_cwd = lines[-1].strip() if lines else "" | ||
| if not new_cwd or not os.path.isdir(new_cwd): | ||
| console.print(f"[red]cd: not a directory: {target}[/red]") | ||
| return | ||
|
|
||
| os.chdir(new_cwd) | ||
| # Set OLDPWD for the next `cd -` invocation. | ||
| os.environ["OLDPWD"] = old_cwd | ||
|
|
||
| # Keep the session's work_dir in sync so agent background tasks | ||
| # (which use session.work_dir as cwd) also see the new directory. | ||
| if isinstance(self.soul, KimiSoul): | ||
| from kaos.path import KaosPath | ||
|
|
||
| self.soul.runtime.session.work_dir = KaosPath.unsafe_from_local_path( | ||
| __import__("pathlib").Path(new_cwd) | ||
| ) | ||
|
Comment on lines
+546
to
+597
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Unhandled exceptions in The This contrasts with the general shell command path which wraps Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| async def _run_slash_command(self, command_call: SlashCommandCall) -> None: | ||
| from kimi_cli.cli import Reload, SwitchToWeb | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
len(split_cmd) <= 2gate still captures commands likecd /tmp&&pwdorcd ~/x;lsbecauseshlex.splitkeeps&&/;inside the second token when no spaces are present. Those are valid shell command chains, but this branch routes them into_handle_cdinstead of executing normally: for quoted targets it fails ascd '/tmp&&pwd', and for~/$targets it can execute extra probe-side commands with output swallowed. This is a functional regression for common no-space shell syntax.Useful? React with 👍 / 👎.