diff --git a/.changeset/anthropic-max-tokens-fallback.md b/.changeset/anthropic-max-tokens-fallback.md new file mode 100644 index 0000000000..71c9e64db6 --- /dev/null +++ b/.changeset/anthropic-max-tokens-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix 400 rejections from Anthropic-compatible providers that enforce a lower max_tokens output limit than the default 128000 fallback. Custom endpoints now use a conservative 32768 default, and a rejected request is automatically retried with the provider's declared limit. diff --git a/.changeset/bound-git-diff-output.md b/.changeset/bound-git-diff-output.md new file mode 100644 index 0000000000..1f69c50410 --- /dev/null +++ b/.changeset/bound-git-diff-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Limit file diff output by UTF-8 byte size while it is read so large changes no longer cause excessive memory use. diff --git a/.changeset/calm-usage-count.md b/.changeset/calm-usage-count.md new file mode 100644 index 0000000000..64c1ef96d6 --- /dev/null +++ b/.changeset/calm-usage-count.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent partial OpenAI-compatible usage reports from producing negative uncached input token counts. diff --git a/.changeset/clean-catalog-payloads.md b/.changeset/clean-catalog-payloads.md new file mode 100644 index 0000000000..fb3c2a1ff8 --- /dev/null +++ b/.changeset/clean-catalog-payloads.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Reject invalid catalog payloads consistently. diff --git a/.changeset/fix-acp-multi-question-prompts.md b/.changeset/fix-acp-multi-question-prompts.md new file mode 100644 index 0000000000..3acbe22bd9 --- /dev/null +++ b/.changeset/fix-acp-multi-question-prompts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix ACP clients dropping questions after the first prompt and preserve multi-select answers and skipped-question notes. diff --git a/.changeset/fix-bun-global-install-detection.md b/.changeset/fix-bun-global-install-detection.md new file mode 100644 index 0000000000..296072b9f3 --- /dev/null +++ b/.changeset/fix-bun-global-install-detection.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix install-source detection for bun global installs by recognizing the `~/.bun/node_modules/` layout used by recent bun versions. diff --git a/.changeset/fix-headless-signal-output.md b/.changeset/fix-headless-signal-output.md new file mode 100644 index 0000000000..35be4e0704 --- /dev/null +++ b/.changeset/fix-headless-signal-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve buffered headless output when a signal arrives during prompt cleanup. diff --git a/.changeset/fix-interrupted-thinking-resume.md b/.changeset/fix-interrupted-thinking-resume.md new file mode 100644 index 0000000000..f748fe8845 --- /dev/null +++ b/.changeset/fix-interrupted-thinking-resume.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kosong": patch +--- + +Fix resumed sessions failing after an assistant response is interrupted during thinking. diff --git a/.changeset/fix-kimi-mcp-required-schema.md b/.changeset/fix-kimi-mcp-required-schema.md new file mode 100644 index 0000000000..785726edcc --- /dev/null +++ b/.changeset/fix-kimi-mcp-required-schema.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Kimi-compatible APIs rejecting MCP tools with malformed required fields. diff --git a/.changeset/git-status-untracked-all.md b/.changeset/git-status-untracked-all.md new file mode 100644 index 0000000000..30897c4d43 --- /dev/null +++ b/.changeset/git-status-untracked-all.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Fix git status collapsing untracked directories: files created inside an untracked directory now appear individually in the Changes panel instead of being hidden behind the directory entry. diff --git a/.changeset/globstar-zero-directories.md b/.changeset/globstar-zero-directories.md new file mode 100644 index 0000000000..5807cf61ee --- /dev/null +++ b/.changeset/globstar-zero-directories.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix glob patterns with `**/` so they also match files in the starting directory. diff --git a/.changeset/inline-multi-skill-prompts.md b/.changeset/inline-multi-skill-prompts.md new file mode 100644 index 0000000000..d02cc98cd7 --- /dev/null +++ b/.changeset/inline-multi-skill-prompts.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add atomic multi-Skill activation within a single prompt. Type multiple `/skill:` tokens in one message to activate them together. diff --git a/.changeset/kkm-release-brand.md b/.changeset/kkm-release-brand.md new file mode 100644 index 0000000000..34985af907 --- /dev/null +++ b/.changeset/kkm-release-brand.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add the `kkm` command and publish native KKM release artifacts while retaining `kimi` as a compatibility alias. diff --git a/.changeset/mcp-http1-remote-transports.md b/.changeset/mcp-http1-remote-transports.md new file mode 100644 index 0000000000..364d0a62cb --- /dev/null +++ b/.changeset/mcp-http1-remote-transports.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix remote MCP servers over HTTP/2 hanging during startup: the stream and requests now use HTTP/1.1 connections. diff --git a/.changeset/pi-tui-above-viewport-inplace.md b/.changeset/pi-tui-above-viewport-inplace.md new file mode 100644 index 0000000000..011962fc47 --- /dev/null +++ b/.changeset/pi-tui-above-viewport-inplace.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Skip the destructive full redraw when lines above the viewport change in place, so scrollback and scroll position survive above-viewport status ticks. diff --git a/.changeset/quiet-pandas-rest.md b/.changeset/quiet-pandas-rest.md new file mode 100644 index 0000000000..a3279f44b2 --- /dev/null +++ b/.changeset/quiet-pandas-rest.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Python-based MCP servers failing to start under HTTP proxies while preserving Node launchers' IPv6 loopback bypass. diff --git a/.changeset/quiet-terminal-exits.md b/.changeset/quiet-terminal-exits.md new file mode 100644 index 0000000000..d113a99c18 --- /dev/null +++ b/.changeset/quiet-terminal-exits.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Persist terminal stream failure diagnostics before emergency exits. diff --git a/.changeset/skill-load-failed-warning.md b/.changeset/skill-load-failed-warning.md new file mode 100644 index 0000000000..a80ffbb938 --- /dev/null +++ b/.changeset/skill-load-failed-warning.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kap-server": patch +--- + +Fix a skill with a `SKILL.md` that fails to parse (e.g. invalid frontmatter) being dropped from the skill list with no signal. A session-start warning now names the skill file and the reason it was not loaded, in both the CLI/TUI engine and `kimi web`/print mode. diff --git a/.changeset/skill-load-warning.md b/.changeset/skill-load-warning.md new file mode 100644 index 0000000000..4dafada185 --- /dev/null +++ b/.changeset/skill-load-warning.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Surface a warning when a skill file fails to parse at session start, instead of dropping it silently. The warning names the offending file and appears in the TUI status line. diff --git a/.changeset/skip-legacy-wire-records.md b/.changeset/skip-legacy-wire-records.md new file mode 100644 index 0000000000..f26cb0e9a7 --- /dev/null +++ b/.changeset/skip-legacy-wire-records.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix spurious "Unknown wire record type" errors when restoring sessions whose history contains records the engine deliberately no longer replays, such as those from the retired micro-compaction experiment. diff --git a/.changeset/steady-session-metadata.md b/.changeset/steady-session-metadata.md new file mode 100644 index 0000000000..a6f9eb9682 --- /dev/null +++ b/.changeset/steady-session-metadata.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Write session metadata atomically to avoid unreadable sessions after interrupted updates. diff --git a/.changeset/stream-agent-task-events.md b/.changeset/stream-agent-task-events.md new file mode 100644 index 0000000000..238cadceaf --- /dev/null +++ b/.changeset/stream-agent-task-events.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stream live subagent events into the background task output buffer. While a subagent launched through the `Agent` tool is still running, `TaskOutput` and the `/tasks` panel now show its turns, tool calls, and thinking/assistant text as they happen, instead of only the final summary after completion. diff --git a/.changeset/stream-wire-jsonl-reads.md b/.changeset/stream-wire-jsonl-reads.md new file mode 100644 index 0000000000..1ba70b5530 --- /dev/null +++ b/.changeset/stream-wire-jsonl-reads.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Read session wire logs line-by-line instead of loading whole files into memory, cutting peak memory when serving session snapshots, history transcripts, and debug exports of long sessions. diff --git a/.changeset/strict-teams-attend.md b/.changeset/strict-teams-attend.md new file mode 100644 index 0000000000..e63188f981 --- /dev/null +++ b/.changeset/strict-teams-attend.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +fix(pi-tui): skip Kitty keyboard protocol on Windows Terminal to prevent Cyrillic doubling diff --git a/.changeset/tidy-rivers-merge.md b/.changeset/tidy-rivers-merge.md new file mode 100644 index 0000000000..ea80a31d6b --- /dev/null +++ b/.changeset/tidy-rivers-merge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix streamed assistant replies rendering one token per line with OpenAI-compatible providers that pad empty reasoning fields into every chunk. diff --git a/.changeset/tool-args-type-coercion.md b/.changeset/tool-args-type-coercion.md new file mode 100644 index 0000000000..3ec3cb1563 --- /dev/null +++ b/.changeset/tool-args-type-coercion.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix tool calls failing validation when a model sends numeric or boolean arguments as strings. diff --git a/.changeset/tui-usage-flicker.md b/.changeset/tui-usage-flicker.md new file mode 100644 index 0000000000..8b9433a388 --- /dev/null +++ b/.changeset/tui-usage-flicker.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the TUI flickering and losing scroll position when live agent status updates continue above the visible viewport, such as after opening /usage during a running task. diff --git a/.changeset/windows-git-bash-path-bridge.md b/.changeset/windows-git-bash-path-bridge.md new file mode 100644 index 0000000000..a2a1fc868f --- /dev/null +++ b/.changeset/windows-git-bash-path-bridge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix file tools failing to open files referenced by Git Bash paths such as /tmp or /home on Windows. diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index 7d09835032..c7976cbb93 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -7,7 +7,7 @@ on: description: 'Prefix for uploaded artifact name' required: false type: string - default: 'kimi-code-native' + default: 'kkm-native' retention-days: description: 'Artifact retention in days' required: false @@ -104,7 +104,7 @@ jobs: if: runner.os == 'macOS' && inputs.sign-macos uses: ./.github/actions/macos-notarize with: - binary-path: ${{ github.workspace }}/apps/kimi-code/dist-native/bin/${{ matrix.target }}/kimi + binary-path: ${{ github.workspace }}/apps/kimi-code/dist-native/bin/${{ matrix.target }}/kkm notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} @@ -125,6 +125,6 @@ jobs: name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }} retention-days: ${{ inputs.retention-days }} path: | - apps/kimi-code/dist-native/artifacts/kimi-code-${{ matrix.target }}.zip - apps/kimi-code/dist-native/artifacts/kimi-code-${{ matrix.target }}.zip.sha256 + apps/kimi-code/dist-native/artifacts/kkm-${{ matrix.target }}.zip + apps/kimi-code/dist-native/artifacts/kkm-${{ matrix.target }}.zip.sha256 if-no-files-found: ignore diff --git a/.github/workflows/kkm-release.yml b/.github/workflows/kkm-release.yml new file mode 100644 index 0000000000..d9bcc00477 --- /dev/null +++ b/.github/workflows/kkm-release.yml @@ -0,0 +1,66 @@ +name: KKM Release + +on: + push: + branches: + - main + paths: + - .github/workflows/kkm-release.yml + - apps/kimi-code/KKM_RELEASE + +permissions: + contents: write + +jobs: + native-artifacts: + name: Build KKM native artifacts + uses: ./.github/workflows/_native-build.yml + with: + upload-artifact-prefix: kkm-native + retention-days: 7 + sign-macos: false + + publish: + name: Publish KKM release + needs: native-artifacts + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download native artifacts + uses: actions/download-artifact@v8 + with: + pattern: kkm-native-* + path: dist-kkm-release + merge-multiple: true + + - name: Resolve release tag + id: release + shell: bash + run: echo "tag=$(tr -d '[:space:]' < apps/kimi-code/KKM_RELEASE)" >> "$GITHUB_OUTPUT" + + - name: Produce manifest + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + test "$(find dist-kkm-release -maxdepth 1 -name 'kkm-*.zip' | wc -l)" -eq 6 + test "$(find dist-kkm-release -maxdepth 1 -name 'kkm-*.zip.sha256' | wc -l)" -eq 6 + node apps/kimi-code/scripts/native/produce-manifest.mjs dist-kkm-release "$RELEASE_TAG" + + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" dist-kkm-release/* --clobber + else + gh release create "$RELEASE_TAG" dist-kkm-release/* \ + --target "$GITHUB_SHA" \ + --title "KKM ${RELEASE_TAG#v}" \ + --generate-notes + fi diff --git a/.github/workflows/manual-native-bundle.yml b/.github/workflows/manual-native-bundle.yml index 88bbf0d53b..aa0ac1c67c 100644 --- a/.github/workflows/manual-native-bundle.yml +++ b/.github/workflows/manual-native-bundle.yml @@ -10,7 +10,7 @@ jobs: build: uses: ./.github/workflows/_native-build.yml with: - upload-artifact-prefix: kimi-code-native + upload-artifact-prefix: kkm-native retention-days: 3 sign-macos: true secrets: diff --git a/.gitignore b/.gitignore index e62f42128f..b517518fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ coverage/ .vitest-results/ .vite/ .DS_Store +.idea/ .playwright-mcp/ .claude .conductor diff --git a/README.md b/README.md index e3e17adb8c..6fde433b17 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,42 @@ -# Kimi Code CLI +# KKM -[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://moonshotai.github.io/kimi-code/en/)
-[Documentation](https://moonshotai.github.io/kimi-code/en/) · [Issues](https://github.com/MoonshotAI/kimi-code/issues) · [中文](README.zh-CN.md) +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Release](https://img.shields.io/github/v/release/Pidbid/kkm)](https://github.com/Pidbid/kkm/releases/latest)
+[Releases](https://github.com/Pidbid/kkm/releases) · [Issues](https://github.com/Pidbid/kkm/issues) · [Upstream documentation](https://moonshotai.github.io/kimi-code/en/) · [中文](README.zh-CN.md) -![Demo of using Kimi Code](./docs/media/intro.gif) +![Demo of using KKM](./docs/media/intro.gif) -## What is Kimi Code CLI +## What is KKM -Kimi Code CLI is an AI coding agent that runs in your terminal — it can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with Moonshot AI’s Kimi models and can also be configured to use other compatible providers. +KKM is a portable fork of Kimi Code CLI with curated upstream fixes, multi-skill prompts, and native releases under the `kkm` command. It can read and edit code, run shell commands, search files, fetch web pages, and use Kimi or other compatible model providers. ## Install -Install with the official script. No Node.js required. +Download the archive for your platform from the [latest GitHub Release](https://github.com/Pidbid/kkm/releases/latest). Each archive contains a single `kkm` executable and is accompanied by a SHA-256 checksum. -- **macOS or Linux**: +- **Linux x64**: ```sh -curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash +unzip kkm-linux-x64.zip +chmod +x kkm +sudo install kkm /usr/local/bin/kkm ``` -- **Windows (PowerShell)**: +- **Windows x64 (PowerShell)**: ```powershell -irm https://code.kimi.com/kimi-code/install.ps1 | iex +Expand-Archive .\kkm-win32-x64.zip -DestinationPath .\kkm +.\kkm\kkm.exe --version ``` -> On Windows, install [Git for Windows](https://gitforwindows.org/) before first launch because Kimi Code CLI uses the bundled Git Bash as its shell environment. If Git Bash is installed in a custom location, set `KIMI_SHELL_PATH` to the absolute path of `bash.exe`. +> On Windows, install [Git for Windows](https://gitforwindows.org/) before first launch because KKM uses its Git Bash environment. If Git Bash is installed in a custom location, set `KIMI_SHELL_PATH` to the absolute path of `bash.exe`. -Then, run it with a new shell session: +Then run: ```sh -kimi --version +kkm --version ``` -For npm install, upgrade, uninstall, see [Getting Started](https://moonshotai.github.io/kimi-code/en/guides/getting-started). +The npm package retains `kimi` as a compatibility alias, while KKM releases use `kkm`. ## Quick Start @@ -41,10 +44,10 @@ Open a project and start the interactive UI: ```sh cd your-project -kimi +kkm ``` -On first launch, run `/login` inside Kimi Code CLI and choose either Kimi Code OAuth or a Moonshot AI Open Platform API key. After login, try your first task: +On first launch, run `/login` inside KKM and choose either Kimi Code OAuth or a Moonshot AI Open Platform API key. After login, try your first task: ``` Take a look at this project and explain its main directories. @@ -60,20 +63,20 @@ Take a look at this project and explain its main directories. - **Rich plugin ecosystem.** Install skills, MCP servers, and data sources from the marketplace or any GitHub repo, with each install's trust level surfaced up front. - **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated contexts while keeping the main conversation clean. - **Lifecycle hooks.** Run local commands at key points to gate risky tool calls, audit decisions, trigger desktop notifications, or connect to your own automation. -- **Editor & IDE integration (ACP).** Drive a Kimi Code CLI session straight from Zed, JetBrains, or any [Agent Client Protocol](https://agentclientprotocol.com/) client with `kimi acp`. +- **Editor & IDE integration (ACP).** Drive a KKM session straight from Zed, JetBrains, or any [Agent Client Protocol](https://agentclientprotocol.com/) client with `kkm acp`. ## Use it in your editor (ACP) -Kimi Code CLI speaks the [Agent Client Protocol](https://agentclientprotocol.com/), so ACP-compatible editors and IDEs (Zed, JetBrains, …) can drive a session over stdio. Log in once, then point your editor at the `kimi acp` subcommand — no extra login needed. +KKM speaks the [Agent Client Protocol](https://agentclientprotocol.com/), so ACP-compatible editors and IDEs (Zed, JetBrains, …) can drive a session over stdio. Log in once, then point your editor at the `kkm acp` subcommand — no extra login needed. For Zed, add this to `~/.config/zed/settings.json`: ```json { "agent_servers": { - "Kimi Code CLI": { + "KKM": { "type": "custom", - "command": "kimi", + "command": "kkm", "args": ["acp"], "env": {} } @@ -81,7 +84,7 @@ For Zed, add this to `~/.config/zed/settings.json`: } ``` -Then open a new conversation in Zed's Agent panel. See [Using in IDEs](https://moonshotai.github.io/kimi-code/en/guides/ides) for JetBrains setup and troubleshooting, and the [`kimi acp` reference](https://moonshotai.github.io/kimi-code/en/reference/kimi-acp) for the full capability matrix. +Then open a new conversation in Zed's Agent panel. See the upstream [Using in IDEs](https://moonshotai.github.io/kimi-code/en/guides/ides) guide for JetBrains setup and troubleshooting. ## Docs @@ -97,8 +100,8 @@ Then open a new conversation in Zed's Agent panel. See [Using in IDEs](https://m Requirements: Node.js ≥ 24.15.0, pnpm 10.33.0. ```sh -git clone https://github.com/MoonshotAI/kimi-code.git -cd kimi-code +git clone https://github.com/Pidbid/kkm.git +cd kkm pnpm install ``` @@ -114,12 +117,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contribution guide. ## Community -- [Issues](https://github.com/MoonshotAI/kimi-code/issues) +- [Issues](https://github.com/Pidbid/kkm/issues) - For security vulnerabilities, see [SECURITY.md](SECURITY.md). ## Acknowledgements -Our TUI is built on top of [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui). We thank the authors of `pi-tui` for their valuable work. +KKM is forked from [MoonshotAI/kimi-code](https://github.com/MoonshotAI/kimi-code). Its TUI is built on [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index cf583f9b85..6540bfb6b1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,42 +1,45 @@ -# Kimi Code CLI +# KKM -[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://moonshotai.github.io/kimi-code/zh/) +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Release](https://img.shields.io/github/v/release/Pidbid/kkm)](https://github.com/Pidbid/kkm/releases/latest) -[Documentation](https://moonshotai.github.io/kimi-code/zh/) · [Issues](https://github.com/MoonshotAI/kimi-code/issues) · [English](README.md) +[Release](https://github.com/Pidbid/kkm/releases) · [Issues](https://github.com/Pidbid/kkm/issues) · [上游文档](https://moonshotai.github.io/kimi-code/zh/) · [English](README.md) -![Kimi Code 的使用演示](./docs/media/intro.gif) +![KKM 的使用演示](./docs/media/intro.gif) -## 什么是 Kimi Code CLI +## 什么是 KKM -Kimi Code CLI 是一个运行在终端里的 AI 编程 agent,可以帮你读写代码、执行 shell 命令、检索文件、抓取网页,并根据反馈自主决定下一步动作。开箱即用对接 Moonshot AI 的 Kimi 模型,也可指向其他兼容厂商。 +KKM 是 Kimi Code CLI 的便携增强分支,筛选并合入通用上游修复,支持同轮多 Skill 对话,并以 `kkm` 命令发布原生可执行文件。它既可使用 Kimi 模型,也可配置其他兼容厂商。 ## 安装 -推荐使用官方安装脚本,不需要提前安装 Node.js。 +从 [GitHub Release](https://github.com/Pidbid/kkm/releases/latest) 下载对应平台的压缩包。每个压缩包只包含一个 `kkm` 可执行文件,并附带 SHA-256 校验文件。 -- **macOS / Linux**: +- **Linux x64**: ```sh -curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash +unzip kkm-linux-x64.zip +chmod +x kkm +sudo install kkm /usr/local/bin/kkm ``` -- **Windows(PowerShell)**: +- **Windows x64(PowerShell)**: ```powershell -irm https://code.kimi.com/kimi-code/install.ps1 | iex +Expand-Archive .\kkm-win32-x64.zip -DestinationPath .\kkm +.\kkm\kkm.exe --version ``` -> Windows 用户首次启动前还需要安装 [Git for Windows](https://gitforwindows.org/),Kimi Code CLI 会使用其中的 Git Bash 作为 Shell 环境。如果 Git Bash 安装在非标准路径,请把 `KIMI_SHELL_PATH` 设为 `bash.exe` 的绝对路径。 +> Windows 用户首次启动前还需要安装 [Git for Windows](https://gitforwindows.org/),KKM 会使用其中的 Git Bash 作为 Shell 环境。如果 Git Bash 安装在非标准路径,请把 `KIMI_SHELL_PATH` 设为 `bash.exe` 的绝对路径。 随后在新的终端会话中运行: ```sh -kimi --version +kkm --version ``` -npm 安装、升级、卸载方式,见[快速上手](https://moonshotai.github.io/kimi-code/zh/guides/getting-started)。 +npm 包仍保留 `kimi` 兼容命令;KKM Release 使用 `kkm`。 ## 快速开始 @@ -44,10 +47,10 @@ npm 安装、升级、卸载方式,见[快速上手](https://moonshotai.github ```sh cd your-project -kimi +kkm ``` -首次启动时,在 Kimi Code CLI 里输入 `/login`,选择 Kimi Code OAuth 或 Moonshot AI Open Platform API 密钥登录。登录完成后,可以先让它熟悉项目: +首次启动时,在 KKM 里输入 `/login`,选择 Kimi Code OAuth 或 Moonshot AI Open Platform API 密钥登录。登录完成后,可以先让它熟悉项目: ``` 帮我看一下这个项目的目录结构,简单介绍一下每个目录是做什么的 @@ -63,21 +66,21 @@ kimi - **丰富的插件生态** 从插件市场或任意 GitHub 仓库安装 skills、MCP 服务器和数据源,每次安装都会标明来源的信任级别。 - **子 Agent 聚焦并行工作** 内置 `coder`、`explore`、`plan` 子 Agent 在隔离上下文中处理子任务,主对话保持清爽。 - **生命周期 hooks** 在关键节点执行本地命令:拦截高风险工具调用、审计决策、发送桌面通知,或对接你自己的自动化脚本。 -- **编辑器 / IDE 集成(ACP)** 用 `kimi acp` 让 Zed、JetBrains 等任意 [Agent Client Protocol](https://agentclientprotocol.com/) 客户端直接驱动会话。 +- **编辑器 / IDE 集成(ACP)** 用 `kkm acp` 让 Zed、JetBrains 等任意 [Agent Client Protocol](https://agentclientprotocol.com/) 客户端直接驱动会话。 ## 在编辑器里使用(ACP) -Kimi Code CLI 支持 [Agent Client Protocol](https://agentclientprotocol.com/),ACP 兼容的编辑器 / IDE(Zed、JetBrains……)可以通过 stdio 直接驱动会话。登录一次后,把编辑器指向 `kimi acp` 子命令即可,无需重复登录。 +KKM 支持 [Agent Client Protocol](https://agentclientprotocol.com/),ACP 兼容的编辑器 / IDE(Zed、JetBrains……)可以通过 stdio 直接驱动会话。登录一次后,把编辑器指向 `kkm acp` 子命令即可,无需重复登录。 以 Zed 为例,在 `~/.config/zed/settings.json` 中加入: ```json { "agent_servers": { - "Kimi Code CLI": { + "KKM": { "type": "custom", - "command": "kimi", + "command": "kkm", "args": ["acp"], "env": {} } @@ -85,7 +88,7 @@ Kimi Code CLI 支持 [Agent Client Protocol](https://agentclientprotocol.com/) } ``` -随后在 Zed 的 Agent 面板新建对话即可。JetBrains 配置与排障见[在 IDE 中使用](https://moonshotai.github.io/kimi-code/zh/guides/ides),完整能力矩阵见 [`kimi acp` 参考](https://moonshotai.github.io/kimi-code/zh/reference/kimi-acp)。 +随后在 Zed 的 Agent 面板新建对话即可。JetBrains 配置与排障可参考上游的[在 IDE 中使用](https://moonshotai.github.io/kimi-code/zh/guides/ides)文档。 ## 文档 @@ -101,8 +104,8 @@ Kimi Code CLI 支持 [Agent Client Protocol](https://agentclientprotocol.com/) 环境要求:Node.js ≥ 24.15.0,pnpm 10.33.0。 ```sh -git clone https://github.com/MoonshotAI/kimi-code.git -cd kimi-code +git clone https://github.com/Pidbid/kkm.git +cd kkm pnpm install ``` @@ -118,12 +121,12 @@ pnpm build # 构建所有包 ## 社区 -- [Issues](https://github.com/MoonshotAI/kimi-code/issues) +- [Issues](https://github.com/Pidbid/kkm/issues) - 安全漏洞反馈,请见 [SECURITY.md](SECURITY.md)。 ## 致谢 -我们的 TUI 构建在 [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui) 之上。我们衷心感谢 `pi-tui` 作者的工作。 +KKM 基于 [MoonshotAI/kimi-code](https://github.com/MoonshotAI/kimi-code),其 TUI 构建在 [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui) 之上。 ## 许可证 diff --git a/apps/kimi-code/KKM_RELEASE b/apps/kimi-code/KKM_RELEASE new file mode 100644 index 0000000000..4091d47e7e --- /dev/null +++ b/apps/kimi-code/KKM_RELEASE @@ -0,0 +1 @@ +v0.29.2-kkm.1 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index ec17e64721..781d9a641c 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,19 +1,20 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.29.2", - "description": "The Starting Point for Next-Gen Agents", + "version": "0.29.2-kkm.1", + "description": "KKM, a portable Kimi Code fork with curated upstream fixes", "license": "MIT", "author": "Moonshot AI", - "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/apps/kimi-code#readme", + "homepage": "https://github.com/Pidbid/kkm#readme", "repository": { "type": "git", - "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "url": "git+https://github.com/Pidbid/kkm.git", "directory": "apps/kimi-code" }, "bugs": { - "url": "https://github.com/MoonshotAI/kimi-code/issues" + "url": "https://github.com/Pidbid/kkm/issues" }, "keywords": [ + "kkm", "kimi", "kimi-code", "cli", @@ -23,6 +24,7 @@ "tui" ], "bin": { + "kkm": "dist/main.mjs", "kimi": "dist/main.mjs" }, "files": [ diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 3f50b969c3..112c7a7935 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -65,7 +65,7 @@ const child = spawn( ); child.on('error', async (error) => { - console.error(`Failed to start Kimi Code dev CLI: ${error.message}`); + console.error(`Failed to start KKM dev CLI: ${error.message}`); await marketplaceServer?.close(); process.exit(1); }); diff --git a/apps/kimi-code/scripts/native/build.mjs b/apps/kimi-code/scripts/native/build.mjs index 3a02d6d1b2..360472d2ff 100644 --- a/apps/kimi-code/scripts/native/build.mjs +++ b/apps/kimi-code/scripts/native/build.mjs @@ -26,7 +26,7 @@ function ensureNodeVersion() { const [major, minor] = process.versions.node.split('.').map(Number); if (major < 24 || (major === 24 && minor < 15)) { console.error( - `Kimi Code native SEA build requires Node.js >=24.15.0, current ${process.versions.node}.`, + `KKM native SEA build requires Node.js >=24.15.0, current ${process.versions.node}.`, ); process.exit(1); } diff --git a/apps/kimi-code/scripts/native/package.mjs b/apps/kimi-code/scripts/native/package.mjs index e146af0abb..d04449dba2 100644 --- a/apps/kimi-code/scripts/native/package.mjs +++ b/apps/kimi-code/scripts/native/package.mjs @@ -14,7 +14,7 @@ const sourceBinary = nativeBinPath(target); const artifactsDir = nativeArtifactsDir(); // Flat-name archive for GH Release (GitHub Release assets do not support subdirectories). -const artifactName = `kimi-code-${target}.zip`; +const artifactName = `kkm-${target}.zip`; const artifactPath = resolve(artifactsDir, artifactName); const checksumPath = `${artifactPath}.sha256`; diff --git a/apps/kimi-code/scripts/native/paths.mjs b/apps/kimi-code/scripts/native/paths.mjs index ca04620724..7460f79244 100644 --- a/apps/kimi-code/scripts/native/paths.mjs +++ b/apps/kimi-code/scripts/native/paths.mjs @@ -7,7 +7,7 @@ export function targetTriple({ platform = process.platform, arch = process.arch, } export function executableName(platform = process.platform) { - return platform === 'win32' ? 'kimi.exe' : 'kimi'; + return platform === 'win32' ? 'kkm.exe' : 'kkm'; } export function nativeDistRoot() { @@ -31,7 +31,7 @@ export function nativeJsBundlePath() { } export function nativeBlobPath() { - return resolve(nativeIntermediatesDir(), 'kimi.blob'); + return resolve(nativeIntermediatesDir(), 'kkm.blob'); } export function nativeSeaConfigPath() { diff --git a/apps/kimi-code/scripts/native/produce-manifest.mjs b/apps/kimi-code/scripts/native/produce-manifest.mjs index 7718f64b11..2e9c530aa6 100644 --- a/apps/kimi-code/scripts/native/produce-manifest.mjs +++ b/apps/kimi-code/scripts/native/produce-manifest.mjs @@ -5,7 +5,7 @@ * Usage: * node produce-manifest.mjs * - * Input dir must contain files matching: kimi-code-.zip.sha256 + * Input dir must contain files matching: kkm-.zip.sha256 * (produced by package.mjs across the 6 native-build matrix runners). * * Output: @@ -26,10 +26,10 @@ if (!inputDir || !tag) { const version = tag.replace(/^@moonshot-ai\/kimi-code@/, '').replace(/^v/, ''); const entries = await readdir(inputDir); -const sumFiles = entries.filter((f) => /^kimi-code-[a-z0-9-]+\.zip\.sha256$/.test(f)); +const sumFiles = entries.filter((f) => /^kkm-[a-z0-9-]+\.zip\.sha256$/.test(f)); if (sumFiles.length === 0) { - console.error(`No kimi-code-.zip.sha256 files found in ${inputDir}`); + console.error(`No kkm-.zip.sha256 files found in ${inputDir}`); process.exit(1); } @@ -42,8 +42,8 @@ for (const sumFile of sumFiles.sort()) { process.exit(1); } const filename = basename(sumFile, '.sha256'); - // kimi-code-darwin-arm64.zip → darwin-arm64 - const target = filename.replace(/^kimi-code-/, '').replace(/\.zip$/, ''); + // kkm-darwin-arm64.zip → darwin-arm64 + const target = filename.replace(/^kkm-/, '').replace(/\.zip$/, ''); platforms[target] = { filename, checksum }; } diff --git a/apps/kimi-code/scripts/native/smoke.mjs b/apps/kimi-code/scripts/native/smoke.mjs index ed3a8624f7..15f6452f1a 100644 --- a/apps/kimi-code/scripts/native/smoke.mjs +++ b/apps/kimi-code/scripts/native/smoke.mjs @@ -68,10 +68,10 @@ const versionOutput = await runKimi(['--version']); assertIncludes(versionOutput, expectedVersion, '--version'); const helpOutput = await runKimi(['--help']); -assertIncludes(helpOutput, 'Usage: kimi', '--help'); +assertIncludes(helpOutput, 'Usage: kkm', '--help'); const exportHelpOutput = await runKimi(['export', '--help']); -assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); +assertIncludes(exportHelpOutput, 'Usage: kkm export', 'export --help'); const nativeAssetOutput = await runKimiWithEnv(['--version'], { KIMI_CODE_HOME: smokeHome, diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs index b4a5afa094..54e7cdea43 100644 --- a/apps/kimi-code/scripts/smoke.mjs +++ b/apps/kimi-code/scripts/smoke.mjs @@ -60,12 +60,12 @@ const versionOutput = await runBundle(['--version']); assertIncludes(versionOutput, expectedVersion, '--version'); const helpOutput = await runBundle(['--help']); -assertIncludes(helpOutput, 'Usage: kimi', '--help'); +assertIncludes(helpOutput, 'Usage: kkm', '--help'); const exportHelpOutput = await runBundle(['export', '--help']); -assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); +assertIncludes(exportHelpOutput, 'Usage: kkm export', 'export --help'); const webHelpOutput = await runBundle(['web', '--help']); -assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help'); +assertIncludes(webHelpOutput, 'Usage: kkm web', 'web --help'); console.log(`Bundle smoke passed: ${bundlePath}`); diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 6ed5551939..1a538bac72 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -24,13 +24,13 @@ export function createProgram( onUpgrade: UpgradeCommandHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) - .description('The Starting Point for Next-Gen Agents') + .description('KKM, a portable terminal coding agent') .version(version, '-V, --version') .allowUnknownOption(false) .configureHelp({ helpWidth: 100 }) .helpOption('-h, --help', 'Show help.') .usage('[options] [command]') - .addHelpText('after', '\nDocumentation: https://moonshotai.github.io/kimi-code/\n'); + .addHelpText('after', '\nRepository: https://github.com/Pidbid/kkm\n'); program .addOption( @@ -124,7 +124,7 @@ export function createProgram( program .command('upgrade') .alias('update') - .description('Upgrade Kimi Code to the latest version.') + .description('Show where to download the latest KKM release.') .action(async () => { await onUpgrade(); }); diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 6e422c3e27..25ee878979 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -1,3 +1,5 @@ +import { CLI_COMMAND_NAME } from '#/constant/app'; + import { isKimiV2Enabled } from './experimental-v2'; export type UIMode = 'shell' | 'print'; @@ -104,7 +106,7 @@ export function validateOptions( (!promptMode || !isKimiV2Enabled(env)) ) { throw new OptionConflictError( - '--agent/--agent-file are only available with the v2 engine (kimi -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).', + `--agent/--agent-file are only available with the v2 engine (${CLI_COMMAND_NAME} -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).`, ); } if (promptMode && opts.session === '') { diff --git a/apps/kimi-code/src/cli/prompt-render.ts b/apps/kimi-code/src/cli/prompt-render.ts index 0e2f35238d..8eeb5e805b 100644 --- a/apps/kimi-code/src/cli/prompt-render.ts +++ b/apps/kimi-code/src/cli/prompt-render.ts @@ -9,6 +9,8 @@ * event-filtering / completion flow intact. */ +import { CLI_COMMAND_NAME } from '#/constant/app'; + import type { PromptOutputFormat } from './options'; /** @@ -384,7 +386,7 @@ export function writeExperimentalVersion( stdout.write(`${JSON.stringify(message)}\n`); return; } - stderr.write(`kimi version ${version}\n`); + stderr.write(`${CLI_COMMAND_NAME} version ${version}\n`); } export function writeResumeHint( @@ -393,7 +395,7 @@ export function writeResumeHint( stdout: PromptOutput, stderr: PromptOutput, ): void { - const command = `kimi -r ${sessionId}`; + const command = `${CLI_COMMAND_NAME} -r ${sessionId}`; const content = `To resume this session: ${command}`; if (outputFormat === 'stream-json') { const message: PromptJsonResumeMetaMessage = { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index abee299622..6d8c8b97b2 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -14,11 +14,17 @@ import { type SessionStatus, type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; +import { Writable } from 'node:stream'; import { resolve } from 'pathe'; -import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; +import { + CLI_COMMAND_NAME, + CLI_SHUTDOWN_TIMEOUT_MS, + PROMPT_CLEANUP_TIMEOUT_MS, +} from '#/constant/app'; import { isKimiV2Enabled } from './experimental-v2'; +import { drainStdio } from './headless-exit'; import { resolveOutputFormat } from './options'; import type { CLIOptions, PromptOutputFormat } from './options'; import { @@ -113,6 +119,9 @@ export async function runPrompt( const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const promptProcess = io.process ?? process; + const streams = [stdout, stderr].filter( + (stream): stream is Writable => stream instanceof Writable, + ); const outputFormat = resolveOutputFormat(opts); const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); @@ -146,9 +155,9 @@ export async function runPrompt( let restorePromptSessionPermission = async (): Promise => {}; let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; + let outputDrainPromise: Promise | undefined; const cleanupPromptRun = async (): Promise => { const pending = (cleanupPromise ??= (async () => { - removeTerminationCleanup?.(); setCrashPhase('shutdown'); try { await restorePromptSessionPermission(); @@ -162,9 +171,24 @@ export async function runPrompt( // keep a completed headless run alive forever. The cleanup keeps running in // the background if it overruns; the caller (`kimi -p`) force-exits shortly // after, so any straggling work is torn down with the process. - await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); + const cleanupOutcome = await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + await (outputDrainPromise ??= drainStdio(streams).finally(() => { + // Keep the signal handlers installed through async cleanup and output + // flushing. Removing them earlier restores Node's default immediate + // signal exit, which can discard the final assistant and resume hint. + removeTerminationCleanup?.(); + })); + if (!cleanupOutcome.ok) { + throw cleanupOutcome.error; + } }; - removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun); + removeTerminationCleanup = installPromptTerminationCleanup( + promptProcess, + cleanupPromptRun, + ); try { await harness.ensureConfigFile(); @@ -306,7 +330,7 @@ async function resolvePromptSession( stderr.write( `${chalk.hex('#E8A838')( `Session "${opts.session}" was created under a different directory.\n` + - ` cd "${target.workDir}" && kimi -r ${opts.session}`, + ` cd "${target.workDir}" && ${CLI_COMMAND_NAME} -r ${opts.session}`, )}\n\n`, ); throw new Error( @@ -407,7 +431,7 @@ export function requireConfiguredModel(...models: readonly (string | undefined)[ const model = configuredModel(...models); if (model === undefined) { throw new Error( - 'No model configured. Run `kimi` and use /login to sign in, then retry; or set default_model in config.toml.', + `No model configured. Run \`${CLI_COMMAND_NAME}\` and use /login to sign in, then retry; or set default_model in config.toml.`, ); } return model; @@ -422,6 +446,10 @@ function installHeadlessHandlers(session: PromptSession): void { session.setQuestionHandler(() => null); } +/** + * Install one-shot signal handlers that clean up and flush queued output before + * forcing the process to exit with the signal's conventional exit code. + */ export function installPromptTerminationCleanup( promptProcess: PromptProcess, cleanup: () => Promise, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 7e0a8ce711..a9e2e3fc8a 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -17,12 +17,12 @@ import { withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; -import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { CLI_COMMAND_NAME, CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; import { detectPendingMigration } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; -import { KimiTUI } from '#/tui/index'; +import { KimiTUI, type DeadTerminalErrorContext } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; import { combineStartupNotice } from '#/tui/utils/startup'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; @@ -121,6 +121,37 @@ export async function runShell( }); setCrashPhase('runtime'); + tui.onDeadTerminalError = (context: DeadTerminalErrorContext): void => { + try { + log.error('terminal output stream failed, restoring terminal and exiting', { + error: context.error, + stream: context.stream, + errorCode: context.error.code, + errno: context.error.errno, + syscall: context.error.syscall, + sessionId: context.sessionId, + turnId: context.turnId, + step: context.step, + streamingPhase: context.streamingPhase, + exitCode: 129, + stdoutIsTTY: process.stdout.isTTY, + stderrIsTTY: process.stderr.isTTY, + stdoutDestroyed: process.stdout.destroyed, + stderrDestroyed: process.stderr.destroyed, + pid: process.pid, + ppid: process.ppid, + }); + } catch { + /* ignore */ + } + try { + // The TUI calls process.exit() immediately after this callback returns. + flushDiagnosticLogsSync(); + } catch { + /* ignore */ + } + }; + const trackLifecycleForSession = ( sessionId: string, event: string, @@ -207,7 +238,7 @@ export async function runShell( process.stdout.write(`${gutter}Bye!\n`); const hints: string[] = []; if (sessionId !== '' && hasContent) { - hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`); + hints.push(`${gutter}To resume this session: ${CLI_COMMAND_NAME} -r ${sessionId}`); } if (tui.exitOpenUrl !== undefined) { hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`); diff --git a/apps/kimi-code/src/cli/sub/acp.ts b/apps/kimi-code/src/cli/sub/acp.ts index a98991464f..4841a28567 100644 --- a/apps/kimi-code/src/cli/sub/acp.ts +++ b/apps/kimi-code/src/cli/sub/acp.ts @@ -29,8 +29,8 @@ import { } from '@moonshot-ai/acp-adapter'; import { createKimiHarness, type Session, type SkillSummary } from '@moonshot-ai/kimi-code-sdk'; -import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { createKimiCodeHostIdentity, getVersion } from '#/cli/version'; +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { buildSkillSlashCommands } from '#/tui/commands/skills'; import { runLoginFlow } from './login-flow'; @@ -110,7 +110,7 @@ export function registerAcpCommand(parent: Command): void { }; try { await runAcpServer(harness, { - agentInfo: { name: 'Kimi Code CLI', version: getVersion() }, + agentInfo: { name: 'KKM', version: getVersion() }, slashCommands: resolveSlashCommands, ...(terminalAuthEnv ? { terminalAuthEnv } : {}), ...(legacyCommand !== undefined && legacyCommand.length > 0 diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts index 0ccc38d281..19232832b5 100644 --- a/apps/kimi-code/src/cli/sub/doctor.ts +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -81,7 +81,7 @@ export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Pr export function registerDoctorCommand(parent: Command, deps?: Partial): void { const doctor = parent .command('doctor') - .description('Validate Kimi Code configuration files.') + .description('Validate KKM configuration files.') .action(async () => { await runDoctorCommand(deps, {}); }); diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 3ade36aa42..df508dba33 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -35,6 +35,7 @@ import { import type { Command } from 'commander'; import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version'; +import { CLI_COMMAND_NAME } from '#/constant/app'; interface WritableLike { write(chunk: string): boolean; @@ -356,7 +357,7 @@ export async function handleCatalogAdd( if (opts.defaultModel !== undefined && !models.some((m) => m.id === opts.defaultModel)) { deps.stderr.write( - `Model "${opts.defaultModel}" is not in provider "${providerId}". Run "kimi provider catalog list ${providerId}" to see available ids.\n`, + `Model "${opts.defaultModel}" is not in provider "${providerId}". Run "${CLI_COMMAND_NAME} provider catalog list ${providerId}" to see available ids.\n`, ); deps.exit(1); } diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts index c547106457..96ec587c52 100644 --- a/apps/kimi-code/src/cli/sub/upgrade.ts +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -80,7 +80,7 @@ export async function handleUpgrade( logUpgradeInfo(deps.logger, 'manual upgrade no update', { currentVersion, }); - deps.stdout.write(`Kimi Code is already up to date (${formatDisplayVersion(currentVersion)}).\n`); + deps.stdout.write(`KKM is already up to date (${formatDisplayVersion(currentVersion)}).\n`); return 0; } diff --git a/apps/kimi-code/src/cli/sub/vis.ts b/apps/kimi-code/src/cli/sub/vis.ts index 7e6f2e1f25..2759b36a17 100644 --- a/apps/kimi-code/src/cli/sub/vis.ts +++ b/apps/kimi-code/src/cli/sub/vis.ts @@ -11,6 +11,7 @@ import type { Command } from 'commander'; import { createCliTelemetryBootstrap } from '#/cli/telemetry'; +import { CLI_COMMAND_NAME } from '#/constant/app'; import { openUrl } from '#/utils/open-url'; interface WritableLike { @@ -76,7 +77,7 @@ export async function handleVis(deps: VisDeps, opts: VisOptions): Promise }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); - deps.stderr.write(`Failed to start kimi vis: ${msg}\n`); + deps.stderr.write(`Failed to start ${CLI_COMMAND_NAME} vis: ${msg}\n`); return deps.exit(1); } @@ -85,7 +86,7 @@ export async function handleVis(deps: VisDeps, opts: VisOptions): Promise ? server.url : `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`; - deps.stdout.write(`kimi vis is running at ${server.url}\n`); + deps.stdout.write(`${CLI_COMMAND_NAME} vis is running at ${server.url}\n`); deps.stdout.write('Press Ctrl-C to stop.\n'); if (opts.open) { diff --git a/apps/kimi-code/src/cli/sub/web/deprecated-server.ts b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts index 23c8e63b6f..a5157a1fd2 100644 --- a/apps/kimi-code/src/cli/sub/web/deprecated-server.ts +++ b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts @@ -17,15 +17,15 @@ import type { Command } from 'commander'; import { registerLegacyKillCommand } from './legacy-kill'; export const DEPRECATED_SERVER_NOTICE = - '`kimi server` has been deprecated and no longer works.\n' + - 'Use `kimi web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' + - 'To stop a server started by a version before 0.28.0, use `kimi server kill`.\n' + - 'This notice will be removed in the next major version of Kimi Code.\n'; + '`kkm server` has been deprecated and no longer works.\n' + + 'Use `kkm web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' + + 'To stop a server started by a version before 0.28.0, use `kkm server kill`.\n' + + 'This notice will be removed in the next major version of KKM.\n'; export function registerDeprecatedServerCommand(program: Command): void { const server = program .command('server') - .description('Deprecated — use `kimi web` instead.') + .description('Deprecated — use `kkm web` instead.') // Swallow every legacy subcommand/flag (`run`, `kill`, `--port`, …) so // they all land in the same notice instead of a commander parse error. .allowUnknownOption(true) diff --git a/apps/kimi-code/src/cli/sub/web/legacy-kill.ts b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts index 97cc1675a9..7f6803041d 100644 --- a/apps/kimi-code/src/cli/sub/web/legacy-kill.ts +++ b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts @@ -50,7 +50,7 @@ export const LEGACY_SERVER_MAX_VERSION = '0.28.0'; /** Deprecation notice printed on every `kimi server kill` run. */ export const DEPRECATED_KILL_NOTICE = - '`kimi server kill` is deprecated: it only stops servers started by a version before 0.28.0. Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.\n'; + '`kkm server kill` is deprecated: it only stops servers started by a version before 0.28.0. Servers started by `kkm web` run in the foreground — stop them with Ctrl+C.\n'; /** * The fields of the legacy `/server/lock` this command needs. The full @@ -83,7 +83,7 @@ export function registerLegacyKillCommand(server: Command): void { server .command('kill') .description( - 'Deprecated — stop a server started by a version before 0.28.0 (recorded in the legacy server lock). Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.', + 'Deprecated — stop a server started by a version before 0.28.0 (recorded in the legacy server lock). Servers started by `kkm web` run in the foreground — stop them with Ctrl+C.', ) // Swallow legacy argument shapes (`kimi server kill `, flags): // the legacy lock records a single server, so they carry no meaning here. diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 098899035c..65329626bc 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -185,7 +185,7 @@ export function renderInstallSuccessMessage(target: UpdateTarget): string { function renderBackgroundInstallSuccessNotice(version: string): string { const displayVersion = version.startsWith('v') ? version : `v${version}`; - return `Kimi Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`; + return `KKM updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`; } function refreshInBackground(): void { diff --git a/apps/kimi-code/src/cli/update/prompt.ts b/apps/kimi-code/src/cli/update/prompt.ts index 3b48ad0722..72498f11c7 100644 --- a/apps/kimi-code/src/cli/update/prompt.ts +++ b/apps/kimi-code/src/cli/update/prompt.ts @@ -70,7 +70,7 @@ function renderInstallPrompt( const command = chalk.hex(UPDATE_PROMPT_PRIMARY)(options.installCommand); const changelogText = chalk.hex(UPDATE_PROMPT_PRIMARY).underline(`View changelog: ${CHANGELOG_URL}`); const lines = [ - chalk.hex(UPDATE_PROMPT_PRIMARY).bold('Kimi Code Update Available'), + chalk.hex(UPDATE_PROMPT_PRIMARY).bold('KKM Update Available'), chalk.hex(UPDATE_PROMPT_MUTED)(`${PRODUCT_NAME} has a newer release ready.`), `]8;;${CHANGELOG_URL}\\${changelogText}]8;;\\`, '', diff --git a/apps/kimi-code/src/cli/update/source.ts b/apps/kimi-code/src/cli/update/source.ts index 7d6904b673..2a896ce80c 100644 --- a/apps/kimi-code/src/cli/update/source.ts +++ b/apps/kimi-code/src/cli/update/source.ts @@ -39,7 +39,7 @@ export function detectNativeInstall(): boolean { // Path heuristic markers (compared in lowercase; both forward and backward slashes accepted). const PNPM_PATH_SEGMENT = 'pnpm/global/'; const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/']; -const BUN_PATH_SEGMENT = '.bun/install/global/'; +const BUN_PATH_SEGMENTS = ['/.bun/install/global/', '/.bun/node_modules/']; // Homebrew installs formulae under its Cellar directory. Avoid matching the // broader /homebrew/ prefix — on Apple Silicon, npm itself lives under // /opt/homebrew/, so `npm install -g` paths also contain /homebrew/. @@ -60,7 +60,9 @@ export function classifyByPathHeuristic(packageRoot: string): InstallSource | nu for (const seg of YARN_PATH_SEGMENTS) { if (normalized.includes(seg)) return 'yarn-global'; } - if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global'; + for (const seg of BUN_PATH_SEGMENTS) { + if (normalized.includes(seg)) return 'bun-global'; + } if (normalized.includes(HOMEBREW_PATH_SEGMENT)) return 'homebrew'; return null; } diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index ff0c96c590..0f70a941e3 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -17,6 +17,7 @@ */ import { readFile } from 'node:fs/promises'; +import { Writable } from 'node:stream'; import { IAgentGoalService, @@ -61,6 +62,7 @@ import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi- import { resolve } from 'pathe'; import { + CLI_COMMAND_NAME, CLI_SHUTDOWN_TIMEOUT_MS, CLI_USER_AGENT_PRODUCT, PROMPT_CLEANUP_TIMEOUT_MS, @@ -73,6 +75,7 @@ import { parseHeadlessGoalCreate, type HeadlessGoalCreate, } from '../goal-prompt'; +import { drainStdio } from '../headless-exit'; import { type PromptRunIO, configuredModel, @@ -112,6 +115,9 @@ export async function runV2Print( const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const promptProcess = io.process ?? process; + const streams = [stdout, stderr].filter( + (stream): stream is Writable => stream instanceof Writable, + ); const outputFormat = resolveOutputFormat(opts); const workDir = process.cwd(); @@ -164,10 +170,10 @@ export async function runV2Print( let restorePermission = async (): Promise => {}; let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; + let outputDrainPromise: Promise | undefined; let telemetryService: ITelemetryService | undefined; const cleanup = async (): Promise => { const pending = (cleanupPromise ??= (async () => { - removeTerminationCleanup?.(); try { await restorePermission(); } finally { @@ -177,7 +183,19 @@ export async function runV2Print( app.dispose(); } })()); - await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); + const cleanupOutcome = await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + await (outputDrainPromise ??= drainStdio(streams).finally(() => { + // Keep the signal handlers installed through async cleanup and output + // flushing. Removing them earlier restores Node's default immediate + // signal exit, which can discard the final assistant and resume hint. + removeTerminationCleanup?.(); + })); + if (!cleanupOutcome.ok) { + throw cleanupOutcome.error; + } }; removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup); @@ -346,7 +364,7 @@ async function resolveNativeSession( if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) { stderr.write( `Session "${opts.session}" was created under a different directory.\n` + - ` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`, + ` cd "${target.cwd}" && ${CLI_COMMAND_NAME} -r ${opts.session}\n\n`, ); throw new Error(`Session "${opts.session}" was created under a different directory.`); } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 59aa187974..1b4f764d6d 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -1,8 +1,9 @@ import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk'; -export const PRODUCT_NAME = 'Kimi Code'; -export const CLI_COMMAND_NAME = 'kimi'; -export const PROCESS_NAME = 'kimi-code'; +export const PRODUCT_NAME = 'KKM'; +export const CLI_COMMAND_NAME = 'kkm'; +export const PROCESS_NAME = 'kkm'; +export const KKM_RELEASES_URL = 'https://github.com/Pidbid/kkm/releases/latest'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli'; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 28ed63a900..36bb23b457 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -34,7 +34,13 @@ import { handleUpgrade } from './cli/sub/upgrade'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './cli/telemetry'; import { runUpdatePreflight } from './cli/update/preflight'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; -import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; +import { + CLI_COMMAND_NAME, + CLI_SHUTDOWN_TIMEOUT_MS, + CLI_UI_MODE, + KKM_RELEASES_URL, + PROCESS_NAME, +} from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; @@ -66,12 +72,16 @@ export async function handleMainCommand( throw error; } - const preflightResult = await runUpdatePreflight( - version, - validated.uiMode === 'print' ? { track, isTTY: false } : { track }, - ); - if (preflightResult === 'exit') { - process.exit(0); + // KKM releases are published from this fork, while the inherited updater + // targets the upstream Kimi package/CDN. Do not allow it to replace KKM. + if (CLI_COMMAND_NAME !== 'kkm') { + const preflightResult = await runUpdatePreflight( + version, + validated.uiMode === 'print' ? { track, isTTY: false } : { track }, + ); + if (preflightResult === 'exit') { + process.exit(0); + } } if (validated.uiMode === 'print') { @@ -89,6 +99,11 @@ async function handleMigrateCommand(version: string): Promise { } export async function handleUpgradeCommand(version: string): Promise { + if (CLI_COMMAND_NAME === 'kkm') { + process.stdout.write(`Download the latest KKM release: ${KKM_RELEASES_URL}\n`); + process.exit(0); + } + const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { track, diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..ae6f1620f9 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -45,6 +45,7 @@ import { handleProviderCommand } from './provider'; import type { BuiltinSlashCommandName } from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; +import { findInlineSkillNames } from './skills'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -207,6 +208,15 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.showError(LLM_NOT_SET_MESSAGE); return; } + const inlineSkillNames = findInlineSkillNames(input, host.skillCommandMap); + if (inlineSkillNames.length > 1) { + host.track('input_command', { + command: 'multi-skill', + skill_names: inlineSkillNames, + }); + host.sendNormalUserInput(input); + return; + } host.track('input_command', { command: intent.commandName, skill_name: intent.skillName, @@ -264,7 +274,7 @@ async function handleBuiltInSlashCommand( host.showHelpPanel(); return; case 'version': - host.showStatus(`Kimi Code v${host.state.appState.version}`); + host.showStatus(`KKM v${host.state.appState.version}`); return; case 'new': await host.createNewSession(); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..31b358ca50 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -335,7 +335,7 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'feedback', aliases: [], - description: 'Send feedback to make Kimi Code better', + description: 'Send feedback to make KKM better', priority: 60, availability: 'always', }, diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 2f0870db0d..7badcb033b 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'; import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { detectInstallSource } from '#/cli/update/source'; +import { CLI_COMMAND_NAME } from '#/constant/app'; import { detectShellEnvironment } from '#/utils/process/shell-env'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; @@ -70,7 +71,7 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P try { await host.switchToSession( forked, - `Session forked (${forked.id}). To return to the original session: kimi -r ${session.id}`, + `Session forked (${forked.id}). To return to the original session: ${CLI_COMMAND_NAME} -r ${session.id}`, ); } catch (error) { const msg = formatErrorMessage(error); diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts index 2997a8b151..fada3956ee 100644 --- a/apps/kimi-code/src/tui/commands/skills.ts +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -6,6 +6,7 @@ export type SkillListSession = Pick; export interface SkillSlashCommands { readonly commands: readonly KimiSlashCommand[]; + readonly inlineCommands: readonly KimiSlashCommand[]; readonly commandMap: ReadonlyMap; } @@ -32,17 +33,47 @@ function getSkillSlashCommandGroup(source: SkillSummary['source']): number { export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { const commandMap = new Map(); const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); - const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { + const activatableSkills = sortedSkills.filter(isUserActivatableSkill); + const commands = activatableSkills.map((skill) => { const commandName = skill.source === 'builtin' || skill.isSubSkill === true ? skill.name : `skill:${skill.name}`; commandMap.set(commandName, skill.name); + commandMap.set(`skill:${skill.name}`, skill.name); return { name: commandName, aliases: [], description: skill.description ?? '', }; }); - return { commands, commandMap }; + const inlineCommands = activatableSkills.map((skill) => ({ + name: `skill:${skill.name}`, + aliases: [], + description: skill.description ?? '', + })); + return { commands, inlineCommands, commandMap }; +} + +export function findInlineSkillNames( + input: string, + skillCommandMap: ReadonlyMap, +): readonly string[] { + const names: string[] = []; + const seen = new Set(); + const matches = input.matchAll( + /(?:^|[\t\n\r ])\/(skill:[A-Za-z0-9][A-Za-z0-9._-]*)(?=$|[\t\n\r ,.?!;()[\]{}])/g, + ); + for (const match of matches) { + let commandName = match[1]!; + let skillName = skillCommandMap.get(commandName); + while (skillName === undefined && commandName.endsWith('.')) { + commandName = commandName.slice(0, -1); + skillName = skillCommandMap.get(commandName); + } + if (skillName === undefined || seen.has(skillName)) continue; + seen.add(skillName); + names.push(skillName); + } + return names; } diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 10cdecbdb2..588d6281aa 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -30,7 +30,7 @@ export class WelcomeComponent implements Component { const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel); if (safeWidth < 24) { - const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'); + const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to KKM!'); const prompt = isLoggedOut ? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.') : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); @@ -52,7 +52,7 @@ export class WelcomeComponent implements Component { const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); const rightRow0 = truncateToWidth( - chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'), + chalk.bold.hex(currentTheme.palette.primary)('Welcome to KKM!'), textWidth, '…', ); diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index c1f108dd79..e20b6ea35d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -26,7 +26,7 @@ export type FeedbackInputDialogResult = | { readonly kind: 'ok'; readonly value: string } | { readonly kind: 'cancel' }; -const TITLE = 'Send feedback to Kimi Code'; +const TITLE = 'Send feedback to KKM'; const SUBTITLE_DEFAULT = "Tell us what's working or what's not."; const SUBTITLE_EMPTY = 'Feedback cannot be empty.'; const FOOTER = 'Enter to submit · Esc to cancel'; diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index e60d85ce02..d0313e4aac 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -16,19 +16,19 @@ export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ value: 'auto', label: 'Switch to Auto and start', description: - 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + 'Best if you want KKM to keep working while you are away. Tools are approved automatically, and questions are skipped.', }, { value: 'yolo', label: 'Switch to YOLO and start', description: - 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + 'Tools and plan changes are approved automatically. KKM may still ask you questions.', }, { value: 'manual', label: 'Start in Manual', description: - 'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.', + 'Keep approvals on. KKM will ask before risky actions, so the goal may stop and wait for you.', }, { value: 'cancel', @@ -42,13 +42,13 @@ export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ value: 'auto', label: 'Switch to Auto and start', description: - 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + 'Best if you want KKM to keep working while you are away. Tools are approved automatically, and questions are skipped.', }, { value: 'yolo', label: 'Keep YOLO and start', description: - 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + 'Tools and plan changes stay approved automatically. KKM may still ask you questions.', }, { value: 'cancel', @@ -66,7 +66,7 @@ const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS; const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS; const MANUAL_NOTICE_LINES = [ - 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', + 'Manual mode asks you before KKM runs commands, edits files, or takes other risky actions.', 'Manual mode is not suitable for unattended goal work.', 'You can go back without losing your command.', ] as const; diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 64ec286148..cf0e9dfe94 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -39,7 +39,7 @@ const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { source: WEB_BRIDGE_URL, tier: 'official', homepage: WEB_BRIDGE_URL, - description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot', + description: 'Control your real browser from KKM — navigate, click, type, and screenshot', }; // Only the hardcoded pinned row should open the WebBridge install page. Match diff --git a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts index 694c0c0e6f..14f48f3a51 100644 --- a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -21,18 +21,18 @@ const OPTIONS: readonly StartPermissionOption[] = [ value: 'yolo', label: 'Switch to YOLO and start', description: - 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + 'Tools and plan changes are approved automatically. KKM may still ask you questions.', }, { value: 'manual', label: 'Start in Manual', description: - 'Keep approvals on. Kimi Code may stop and wait for you during the swarm task.', + 'Keep approvals on. KKM may stop and wait for you during the swarm task.', }, ]; const NOTICE_LINES = [ - 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', + 'Manual mode asks you before KKM runs commands, edits files, or takes other risky actions.', 'Manual mode can block swarm work while agents are running.', 'You can go back without losing your command.', ] as const; diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 722682db60..9ecad3dc58 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -45,6 +45,7 @@ export class FileMentionProvider implements AutocompleteProvider { private readonly fdPath: string | null, additionalDirs: readonly string[] = [], private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', + private readonly inlineSkillCommands: readonly SlashAutocompleteCommand[] = [], ) { this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); // Build an expanded list that includes alias entries so that @@ -100,6 +101,22 @@ export class FileMentionProvider implements AutocompleteProvider { } } + if ( + this.getInputMode() !== 'bash' && + options.force !== true && + this.inlineSkillCommands.length > 0 + ) { + const inlineSkillPrefix = extractInlineSkillPrefix(lines, cursorLine, cursorCol); + if (inlineSkillPrefix !== null) { + const slashArgumentSuggestions = await getSlashArgumentSuggestions( + this.slashCommands, + textBeforeCursor, + ); + if (slashArgumentSuggestions !== null) return slashArgumentSuggestions; + return getInlineSkillSuggestions(this.inlineSkillCommands, inlineSkillPrefix); + } + } + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { return null; } @@ -206,6 +223,12 @@ export class FileMentionProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { + if ( + this.getInputMode() !== 'bash' && + isInlineSkillCompletion(lines, cursorLine, cursorCol, prefix, this.inlineSkillCommands) + ) { + return applyInlineSkillCompletion(lines, cursorLine, cursorCol, item, prefix); + } // In bash mode a leading `/` is a path, but pi-tui's applyCompletion // mistakes it for a slash command (prefix starts with `/`, nothing before // it, no second `/`) and prepends another `/`, producing e.g. @@ -219,6 +242,85 @@ export class FileMentionProvider implements AutocompleteProvider { } } +export function extractInlineSkillPrefix( + lines: readonly string[], + cursorLine: number, + cursorCol: number, +): string | null { + const textBeforeCursor = (lines[cursorLine] ?? '').slice(0, cursorCol); + const match = textBeforeCursor.match(/(?:^|[\t ])(\/[^\t ]*)$/); + if (match === null) return null; + const prefix = match[1]!; + const prefixStart = textBeforeCursor.length - prefix.length; + if (cursorLine === 0 && prefixStart === 0) return null; + return prefix; +} + +function getInlineSkillSuggestions( + commands: readonly SlashAutocompleteCommand[], + prefix: string, +): AutocompleteSuggestions | null { + const query = prefix.slice(1); + const tokens = query + .trim() + .split(/\s+/) + .filter((token) => token.length > 0); + const matches = commands + .map((command) => ({ command, score: scoreTokens(tokens, command.name) })) + .filter( + ( + match, + ): match is { + command: SlashAutocompleteCommand; + score: number; + } => match.score !== null, + ) + .toSorted((a, b) => a.score - b.score); + if (matches.length === 0) return null; + return { + prefix, + items: matches.map(({ command }) => ({ + value: command.name, + label: command.name, + description: formatSlashCommandDescription(command), + })), + }; +} + +function isInlineSkillCompletion( + lines: readonly string[], + cursorLine: number, + cursorCol: number, + prefix: string, + commands: readonly SlashAutocompleteCommand[], +): boolean { + if (!prefix.startsWith('/')) return false; + const inlinePrefix = extractInlineSkillPrefix(lines, cursorLine, cursorCol); + if (inlinePrefix !== prefix) return false; + return commands.length > 0; +} + +function applyInlineSkillCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] ?? ''; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const replacement = `/${item.value} `; + const afterCursor = currentLine.slice(cursorCol); + const newLines = [...lines]; + newLines[cursorLine] = + beforePrefix + replacement + (afterCursor.startsWith(' ') ? afterCursor.slice(1) : afterCursor); + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + replacement.length, + }; +} + export function extractAtPrefix(text: string): string | null { let tokenStart = 0; for (let i = text.length - 1; i >= 0; i -= 1) { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 5b6a35d7f5..50b0a2e701 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -581,7 +581,7 @@ export class StreamingUIController { this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); notifyTerminalOnce(state, `turn-complete:${completedTurnKey}`, { - title: 'Kimi Code task complete', + title: 'KKM task complete', body: state.appState.sessionTitle ?? undefined, }); } diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index 15e3608f83..c5ef465397 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -119,7 +119,7 @@ export function renderDanceWelcomeHeader( const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); const gap = ' '; const rightRow0 = truncateToWidth( - rainbowText('Welcome to Kimi Code!', palette, phase + 2, true), + rainbowText('Welcome to KKM!', palette, phase + 2, true), textWidth, '…', ); diff --git a/apps/kimi-code/src/tui/index.ts b/apps/kimi-code/src/tui/index.ts index 06af194eca..5ae02866db 100644 --- a/apps/kimi-code/src/tui/index.ts +++ b/apps/kimi-code/src/tui/index.ts @@ -1,3 +1,3 @@ export { KimiTUI } from './kimi-tui'; -export type { KimiTUIStartupInput } from './kimi-tui'; +export type { DeadTerminalErrorContext, KimiTUIStartupInput } from './kimi-tui'; export type { KimiTUIOptions } from './types'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f0764c82b9..72a9a74439 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -23,6 +23,7 @@ import { import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; +import { CLI_COMMAND_NAME } from '#/constant/app'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; @@ -38,6 +39,7 @@ import { BUILTIN_SLASH_COMMANDS, buildPluginSlashCommands, buildSkillSlashCommands, + findInlineSkillNames, isExperimentalFlagEnabled, setExperimentalFeatures, sortSlashCommands, @@ -184,6 +186,15 @@ export interface KimiTUIStartupInput { readonly migrateOnly?: boolean; } +export interface DeadTerminalErrorContext { + readonly stream: 'stdout' | 'stderr'; + readonly error: NodeJS.ErrnoException; + readonly sessionId?: string; + readonly turnId?: string; + readonly step: number; + readonly streamingPhase: AppState['streamingPhase']; +} + type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; type LoadingTipKind = 'moon' | 'composing'; @@ -243,6 +254,7 @@ interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; readonly hasMedia?: boolean; + readonly skillNames?: readonly string[]; } /** @@ -301,6 +313,7 @@ export class KimiTUI { private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly KimiSlashCommand[] = []; + private inlineSkillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map(); private pluginCommands: readonly KimiSlashCommand[] = []; readonly pluginCommandMap = new Map(); @@ -359,6 +372,9 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise; + /** Called synchronously before a dead output stream triggers the emergency exit. */ + public onDeadTerminalError?: (context: DeadTerminalErrorContext) => void; + /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; @@ -453,6 +469,7 @@ export class KimiTUI { this.fdPath, this.state.appState.additionalDirs, () => this.state.appState.inputMode, + this.inlineSkillCommands, ); this.state.editor.setAutocompleteProvider(provider); @@ -474,6 +491,7 @@ export class KimiTUI { async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { this.skillCommands = []; + this.inlineSkillCommands = []; this.skillCommandMap.clear(); this.setupAutocomplete(); return; @@ -487,6 +505,7 @@ export class KimiTUI { } const skillCommands = buildSkillSlashCommands(skills); this.skillCommands = skillCommands.commands; + this.inlineSkillCommands = skillCommands.inlineCommands; this.skillCommandMap.clear(); for (const [commandName, skillName] of skillCommands.commandMap) { this.skillCommandMap.set(commandName, skillName); @@ -770,7 +789,7 @@ export class KimiTUI { `${currentTheme.fg( 'warning', `Session "${startup.sessionFlag}" was created under a different directory.\n` + - ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, + ` cd "${target.workDir}" && ${CLI_COMMAND_NAME} -r ${startup.sessionFlag}`, )}\n\n`, ); throw new Error( @@ -906,18 +925,35 @@ export class KimiTUI { }); } - const terminalErrorHandler = (error: Error): void => { - if (isDeadTerminalError(error)) { + const createTerminalErrorHandler = + (stream: DeadTerminalErrorContext['stream']) => + (error: Error): void => { + if (!isDeadTerminalError(error)) return; + try { + const sessionId = this.getCurrentSessionId(); + const { turnId, step } = this.streamingUI.getTurnContext(); + this.onDeadTerminalError?.({ + stream, + error: error as NodeJS.ErrnoException, + sessionId: sessionId === '' ? undefined : sessionId, + turnId, + step, + streamingPhase: this.state.appState.streamingPhase, + }); + } catch { + // Diagnostic recording is best-effort and must not block emergency exit. + } this.emergencyTerminalExit(); - } - }; - process.stdout.on('error', terminalErrorHandler); - process.stderr.on('error', terminalErrorHandler); + }; + const stdoutErrorHandler = createTerminalErrorHandler('stdout'); + const stderrErrorHandler = createTerminalErrorHandler('stderr'); + process.stdout.on('error', stdoutErrorHandler); + process.stderr.on('error', stderrErrorHandler); this.signalCleanupHandlers.push(() => { - process.stdout.off('error', terminalErrorHandler); + process.stdout.off('error', stdoutErrorHandler); }); this.signalCleanupHandlers.push(() => { - process.stderr.off('error', terminalErrorHandler); + process.stderr.off('error', stderrErrorHandler); }); } @@ -1153,12 +1189,16 @@ export class KimiTUI { this.showError(LLM_NOT_SET_MESSAGE); return; } + const skillNames = findInlineSkillNames(text, this.skillCommandMap); if (extraction.hasMedia) { this.sendMessage(session, text, { hasMedia: true, parts: extraction.parts, imageAttachmentIds: extraction.imageAttachmentIds, + skillNames, }); + } else if (skillNames.length > 0) { + this.sendMessage(session, text, { skillNames }); } else { this.sendMessage(session, text); } @@ -1243,6 +1283,7 @@ export class KimiTUI { text, agentId: this.harness.interactiveAgentId, parts: options?.parts, + skillNames: options?.skillNames, imageAttachmentIds: options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds @@ -1284,6 +1325,7 @@ export class KimiTUI { this.sendMessageInternal(session, item.text, { parts: item.parts, imageAttachmentIds: item.imageAttachmentIds, + skillNames: item.skillNames, }); }); } @@ -1314,7 +1356,10 @@ export class KimiTUI { // continuation boundary and is rejected with `turn.agent_busy`, dropping // the message. Steer instead: the engine buffers it into the running goal // turn, or launches a turn of its own if the loop just ended. - if (this.state.appState.goal?.status === 'active') { + if ( + this.state.appState.goal?.status === 'active' && + (options?.skillNames === undefined || options.skillNames.length === 0) + ) { void session.steer(sdkInput).catch((error: unknown) => { const message = formatErrorMessage(error); // Same reset as the prompt path: beginSessionRequest already moved the @@ -1325,6 +1370,18 @@ export class KimiTUI { }); return; } + if (options?.skillNames !== undefined && options.skillNames.length > 0) { + void session + .promptWithSkills( + options.skillNames.map((name) => ({ name })), + sdkInput, + ) + .catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Failed to send: ${message}`); + }); + return; + } void session.prompt(sdkInput).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Failed to send: ${message}`); @@ -1709,7 +1766,7 @@ export class KimiTUI { private async showResumeOtherWorkDirHint(session: SessionRow): Promise { this.hideSessionPicker(); - const command = `cd ${quoteShellArg(session.work_dir)} && kimi --resume ${quoteShellArg(session.id)}`; + const command = `cd ${quoteShellArg(session.work_dir)} && ${CLI_COMMAND_NAME} --resume ${quoteShellArg(session.id)}`; const message = `Current session is in a different working directory.\n To resume, run: ${command}`; try { await copyTextToClipboard(command); @@ -2995,7 +3052,7 @@ export class KimiTUI { private showApprovalPanel(payload: ApprovalPanelData): void { this.patchLivePane({ pendingApproval: { data: payload } }); notifyTerminalOnce(this.state, `approval:${payload.id}`, { - title: 'Kimi Code approval required', + title: 'KKM approval required', body: payload.tool_name, }); const panel = new ApprovalPanelComponent( @@ -3062,7 +3119,7 @@ export class KimiTUI { private showQuestionDialog(payload: QuestionPanelData): void { this.patchLivePane({ pendingQuestion: { data: payload } }); notifyTerminalOnce(this.state, `question:${payload.id}`, { - title: 'Kimi Code needs your answer', + title: 'KKM needs your answer', body: payload.questions[0]?.question, }); const dialog = new QuestionDialogComponent( diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..4f3c7d1176 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -208,6 +208,7 @@ export interface QueuedMessage { readonly agentId?: string; readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; + readonly skillNames?: readonly string[]; /** `bash` for a `!` shell command queued while another command is running; * undefined (=`prompt`) for a normal message. */ readonly mode?: 'prompt' | 'bash'; diff --git a/apps/kimi-code/src/tui/utils/tmux-keyboard.ts b/apps/kimi-code/src/tui/utils/tmux-keyboard.ts index 88897fa741..c100de882b 100644 --- a/apps/kimi-code/src/tui/utils/tmux-keyboard.ts +++ b/apps/kimi-code/src/tui/utils/tmux-keyboard.ts @@ -6,7 +6,7 @@ export const TMUX_EXTENDED_KEYS_OFF_WARNING = 'tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.'; export const TMUX_EXTENDED_KEYS_FORMAT_XTERM_WARNING = - 'tmux extended-keys-format is xterm. Kimi Code works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux.'; + 'tmux extended-keys-format is xterm. KKM works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux.'; export type TmuxOptionReader = (option: string) => Promise; diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts index 6aae761c44..b5428164f4 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -128,8 +128,11 @@ function selectPreferredImageMimeType(candidates: string[]): string | null { const match = normalized.find((t) => t.base === preferred); if (match !== undefined) return match.raw; } - const anyImage = normalized.find((t) => t.base.startsWith('image/')); - return anyImage?.raw ?? null; + // Only accept formats the image pipeline can actually decode. Anything + // else (e.g. WSLg bridges Windows clipboard images as image/bmp only) + // would be dropped downstream anyway — and would short-circuit the WSL + // PowerShell fallback that could have produced a PNG. + return null; } function videoMimeFromPath(path: string): string | null { @@ -241,8 +244,8 @@ function runCommand(command: string, args: string[], options?: RunCommandOptions }); } -function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { - const list = runCommand('wl-paste', ['--list-types'], { +function readClipboardFileMediaViaWlPaste(run: RunCommand): ClipboardMedia | null { + const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!list.ok) return null; @@ -251,12 +254,12 @@ function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { const uriType = types.find((t) => baseMimeType(t) === 'text/uri-list'); if (uriType === undefined) return null; - const uris = runCommand('wl-paste', ['--type', uriType, '--no-newline']); + const uris = run('wl-paste', ['--type', uriType, '--no-newline']); return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; } -function readClipboardImageViaWlPaste(): ClipboardImage | null { - const list = runCommand('wl-paste', ['--list-types'], { +function readClipboardImageViaWlPaste(run: RunCommand): ClipboardImage | null { + const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!list.ok) return null; @@ -264,13 +267,13 @@ function readClipboardImageViaWlPaste(): ClipboardImage | null { const selected = selectPreferredImageMimeType(parseTargetList(list.stdout)); if (selected === null) return null; - const data = runCommand('wl-paste', ['--type', selected, '--no-newline']); + const data = run('wl-paste', ['--type', selected, '--no-newline']); if (!data.ok || data.stdout.length === 0) return null; return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(selected) }; } -function readClipboardFileMediaViaXclip(): ClipboardMedia | null { - const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { +function readClipboardFileMediaViaXclip(run: RunCommand): ClipboardMedia | null { + const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!targets.ok) return null; @@ -279,12 +282,12 @@ function readClipboardFileMediaViaXclip(): ClipboardMedia | null { const uriType = candidates.find((t) => baseMimeType(t) === 'text/uri-list'); if (uriType === undefined) return null; - const uris = runCommand('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); + const uris = run('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; } -function readClipboardImageViaXclip(): ClipboardImage | null { - const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { +function readClipboardImageViaXclip(run: RunCommand): ClipboardImage | null { + const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); @@ -296,7 +299,7 @@ function readClipboardImageViaXclip(): ClipboardImage | null { : [...SUPPORTED_IMAGE_MIME_TYPES]; for (const mime of tryTypes) { - const data = runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); + const data = run('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); if (data.ok && data.stdout.length > 0) { return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(mime) }; } @@ -310,27 +313,29 @@ function readClipboardImageViaXclip(): ClipboardImage | null { * we round-trip via a temp PNG because binary stdout is unreliable * across the WSL interop boundary. */ -function readClipboardImageViaPowerShell(): ClipboardImage | null { +function readClipboardImageViaPowerShell(run: RunCommand): ClipboardImage | null { const tmpFile = join(tmpdir(), `kimi-wsl-clip-${randomUUID()}.png`); try { - const winPathResult = runCommand('wslpath', ['-w', tmpFile], { + const winPathResult = run('wslpath', ['-w', tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!winPathResult.ok) return null; const winPath = winPathResult.stdout.toString('utf-8').trim(); if (winPath.length === 0) return null; + // The temp path is embedded in the script text: WSL only forwards + // environment variables listed in WSLENV to Win32 processes, so an + // env-var hand-off would silently arrive empty without user config. const psScript = [ 'Add-Type -AssemblyName System.Windows.Forms', 'Add-Type -AssemblyName System.Drawing', - '$path = $env:KIMI_WSL_CLIPBOARD_IMAGE_PATH', + `$path = '${winPath.replaceAll("'", "''")}'`, '$img = [System.Windows.Forms.Clipboard]::GetImage()', "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }", ].join('; '); - const result = runCommand('powershell.exe', ['-NoProfile', '-Command', psScript], { + const result = run('powershell.exe', ['-NoProfile', '-Command', psScript], { timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, - env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath }, }); if (!result.ok) return null; if (result.stdout.toString('utf-8').trim() !== 'ok') return null; @@ -419,12 +424,12 @@ export async function readClipboardMedia(options?: { const wsl = isWSL(env); if (wayland || wsl) { - const fileMedia = readClipboardFileMediaViaWlPaste() ?? readClipboardFileMediaViaXclip(); + const fileMedia = readClipboardFileMediaViaWlPaste(run) ?? readClipboardFileMediaViaXclip(run); if (fileMedia !== null) return fileMedia; - image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + image = readClipboardImageViaWlPaste(run) ?? readClipboardImageViaXclip(run); } if (image === null && wsl) { - image = readClipboardImageViaPowerShell(); + image = readClipboardImageViaPowerShell(run); } if (image === null && !wayland) { const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip); diff --git a/apps/kimi-code/test/cli/acp.test.ts b/apps/kimi-code/test/cli/acp.test.ts index 85366252ea..383d6c9b7a 100644 --- a/apps/kimi-code/test/cli/acp.test.ts +++ b/apps/kimi-code/test/cli/acp.test.ts @@ -63,7 +63,7 @@ describe('kimi acp', () => { const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1]; expect(optsArg).toEqual( expect.objectContaining({ - agentInfo: { name: 'Kimi Code CLI', version: expect.any(String) }, + agentInfo: { name: 'KKM', version: expect.any(String) }, }), ); expect(exitSpy).toHaveBeenCalledWith(0); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 8e058068a4..d7450fe334 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -222,7 +222,7 @@ describe('main entry command handling', () => { mocks.flushDiagnosticLogs.mockResolvedValue(undefined); }); - it('runs update preflight before starting the shell', async () => { + it('starts the KKM shell without running the upstream update preflight', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); mocks.runUpdatePreflight.mockResolvedValue('continue'); @@ -232,10 +232,7 @@ describe('main entry command handling', () => { expect(exitCode).toBeNull(); expect(validateOptions).toHaveBeenCalledWith(opts); - expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { track: expect.any(Function) }); - expect(mocks.runUpdatePreflight.mock.invocationCallOrder[0]).toBeLessThan( - mocks.runShell.mock.invocationCallOrder[0]!, - ); + expect(runUpdatePreflight).not.toHaveBeenCalled(); expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); }); @@ -251,10 +248,7 @@ describe('main entry command handling', () => { const exitCode = await runHandleMainCommand(opts); expect(exitCode).toBeNull(); - expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { - track: expect.any(Function), - isTTY: false, - }); + expect(runUpdatePreflight).not.toHaveBeenCalled(); expect(runPrompt).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); expect(runShell).not.toHaveBeenCalled(); }); @@ -334,7 +328,7 @@ describe('main entry command handling', () => { } }); - it('keeps shell mode update preflight interactive by default', async () => { + it('keeps the upstream update preflight disabled in shell mode', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); mocks.runUpdatePreflight.mockResolvedValue('continue'); @@ -343,9 +337,7 @@ describe('main entry command handling', () => { const exitCode = await runHandleMainCommand(opts); expect(exitCode).toBeNull(); - expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { - track: expect.any(Function), - }); + expect(runUpdatePreflight).not.toHaveBeenCalled(); expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); }); @@ -365,13 +357,13 @@ describe('main entry command handling', () => { process.title = 'kimi-test-runner'; main(); - expect(process.title).toBe('kimi-code'); + expect(process.title).toBe('kkm'); } finally { process.title = originalTitle; } }); - it('exits early when update preflight requests process exit', async () => { + it('ignores an upstream preflight result for KKM', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); mocks.runUpdatePreflight.mockResolvedValue('exit'); @@ -379,46 +371,22 @@ describe('main entry command handling', () => { const exitCode = await runHandleMainCommand(opts); - expect(exitCode).toBe(0); - expect(runShell).not.toHaveBeenCalled(); + expect(exitCode).toBeNull(); + expect(runUpdatePreflight).not.toHaveBeenCalled(); + expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); }); - it('initializes and flushes telemetry around the upgrade command', async () => { + it('directs the KKM upgrade command to this fork releases', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const exitCode = await runHandleUpgradeCommand(); expect(exitCode).toBe(0); - expect(mocks.createCliTelemetryBootstrap).toHaveBeenCalledTimes(1); - expect(mocks.createKimiHarness).toHaveBeenCalledWith(expect.objectContaining({ - homeDir: '/tmp/kimi-home', - telemetry: { - track: mocks.track, - withContext: mocks.withTelemetryContext, - setContext: mocks.setTelemetryContext, - }, - })); - expect(mocks.harness.ensureConfigFile).toHaveBeenCalledTimes(1); - expect(mocks.initializeCliTelemetry).toHaveBeenCalledWith(expect.objectContaining({ - harness: expect.objectContaining({ - homeDir: '/tmp/kimi-home', - }), - bootstrap: { - homeDir: '/tmp/kimi-home', - deviceId: 'device-id', - firstLaunch: false, - }, - config: { - defaultModel: 'kimi-k2', - telemetry: true, - }, - version: '0.0.1-alpha.2', - uiMode: 'shell', - })); - expect(mocks.handleUpgrade).toHaveBeenCalledWith('0.0.1-alpha.2', { - track: mocks.track, - logger: mocks.log, - }); - expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 }); - expect(mocks.harness.close).toHaveBeenCalledTimes(1); + expect(stdoutSpy).toHaveBeenCalledWith( + 'Download the latest KKM release: https://github.com/Pidbid/kkm/releases/latest\n', + ); + expect(mocks.createCliTelemetryBootstrap).not.toHaveBeenCalled(); + expect(mocks.handleUpgrade).not.toHaveBeenCalled(); + stdoutSpy.mockRestore(); }); it('formats Kimi startup errors with structured fields', () => { diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index ce2951e640..70323f7b94 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -733,7 +733,7 @@ describe('kimi provider catalog add', () => { expect(exitCodes).toEqual([1]); const err = stderr.join(''); expect(err).toContain('"does-not-exist" is not in provider "anthropic"'); - expect(err).toContain('kimi provider catalog list anthropic'); + expect(err).toContain('kkm provider catalog list anthropic'); }); it('preserves an existing default_model when re-importing the same provider without --default-model', async () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 4bad127d89..8d45c2aba0 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1,4 +1,5 @@ import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; +import { Writable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runPrompt } from '#/cli/run-prompt'; @@ -92,15 +93,15 @@ const mocks = vi.hoisted(() => { role: 'meta', type: 'session.resume_hint', session_id: 'ses_prompt', - command: 'kimi -r ses_prompt', - content: 'To resume this session: kimi -r ses_prompt', + command: 'kkm -r ses_prompt', + content: 'To resume this session: kkm -r ses_prompt', })}\n`, ); return; } stderr.write(`kimi version ${version}\n`); stdout.write('• hello world\n\n'); - stderr.write('To resume this session: kimi -r ses_prompt\n'); + stderr.write('To resume this session: kkm -r ses_prompt\n'); }, ), initializeTelemetry: vi.fn(), @@ -274,7 +275,7 @@ describe('runPrompt', () => { expect(mocks.session.setQuestionHandler).toHaveBeenCalledWith(expect.any(Function)); expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); expect(stdout.text()).toBe('• hello world\n\n'); - expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(stderr.text()).toBe('To resume this session: kkm -r ses_prompt\n'); expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'ses_prompt' }), ); @@ -479,7 +480,7 @@ describe('runPrompt', () => { await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); expect(stderr.text()).toBe( - '• The user wants an exact reply.\n No tools are needed.\n\nTo resume this session: kimi -r ses_prompt\n', + '• The user wants an exact reply.\n No tools are needed.\n\nTo resume this session: kkm -r ses_prompt\n', ); expect(stdout.text()).toBe('• prompt-mode-ok\n\n'); expect(stderr.write).toHaveBeenNthCalledWith(1, '• The user wants an exact reply.'); @@ -511,7 +512,7 @@ describe('runPrompt', () => { await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); expect(stdout.text()).toBe('• UserPromptSubmit hook\n\n {}\n\n• answer\n\n'); - expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(stderr.text()).toBe('To resume this session: kkm -r ses_prompt\n'); }); it('wraps transcript blocks with hanging indentation when terminal width is known', async () => { @@ -530,7 +531,7 @@ describe('runPrompt', () => { await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); - expect(stderr.text()).toBe('• thinking\n -wrap\n\nTo resume this session: kimi -r ses_prompt\n'); + expect(stderr.text()).toBe('• thinking\n -wrap\n\nTo resume this session: kkm -r ses_prompt\n'); expect(stdout.text()).toBe('• answer-w\n rap\n\n'); }); @@ -568,7 +569,7 @@ describe('runPrompt', () => { await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); expect(stdout.text()).toBe('• main answer\n\n'); - expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(stderr.text()).toBe('To resume this session: kkm -r ses_prompt\n'); }); it('ignores child-agent error events while the main turn continues', async () => { @@ -597,7 +598,7 @@ describe('runPrompt', () => { await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); expect(stdout.text()).toBe('• main recovered\n\n'); - expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(stderr.text()).toBe('To resume this session: kkm -r ses_prompt\n'); }); it('resumes a concrete session and forces auto permission before prompting', async () => { @@ -673,7 +674,7 @@ describe('runPrompt', () => { expect(stdout.text()).toBe( [ '{"role":"assistant","content":"hello world"}', - '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}', + '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kkm -r ses_prompt","content":"To resume this session: kkm -r ses_prompt"}', '', ].join('\n'), ); @@ -718,7 +719,7 @@ describe('runPrompt', () => { '{"role":"assistant","content":"checking","tool_calls":[{"type":"function","id":"tc_1","function":{"name":"Shell","arguments":"{\\"command\\":\\"ls\\"}"}}]}', '{"role":"tool","tool_call_id":"tc_1","content":"file1.py\\nfile2.py"}', '{"role":"assistant","content":"done"}', - '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}', + '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kkm -r ses_prompt","content":"To resume this session: kkm -r ses_prompt"}', '', ].join('\n'), ); @@ -768,7 +769,7 @@ describe('runPrompt', () => { [ retryMeta, '{"role":"assistant","content":"final answer"}', - '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}', + '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kkm -r ses_prompt","content":"To resume this session: kkm -r ses_prompt"}', '', ].join('\n'), ); @@ -1033,6 +1034,106 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalledTimes(1); }); + it('preserves completed stream-json output when signalled during cleanup', async () => { + let releaseCleanup: (() => void) | undefined; + mocks.harnessClose.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseCleanup = resolve; + }), + ); + + const chunks: string[] = []; + let blocked = true; + let releaseBufferedWrite: (() => void) | undefined; + const stdout = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + if (!blocked) { + callback(); + return; + } + releaseBufferedWrite = callback; + }, + }); + + const processMock = fakeProcess(); + const run = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr: writer(), + process: processMock, + } as Parameters[2] & { process: ReturnType }); + + await waitForAssertion(() => { + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + expect(stdout.writableNeedDrain).toBe(true); + const signalHandler = processMock.listener('SIGTERM'); + if (signalHandler === undefined) { + blocked = false; + releaseBufferedWrite?.(); + releaseCleanup?.(); + await run; + } + expect(signalHandler).toBeDefined(); + + const termination = signalHandler?.(); + releaseCleanup?.(); + await Promise.resolve(); + expect(processMock.exit).not.toHaveBeenCalled(); + + blocked = false; + releaseBufferedWrite?.(); + await termination; + await run; + + expect(chunks.join('')).toContain('{"role":"assistant","content":"hello world"}\n'); + expect(chunks.join('')).toContain('"type":"session.resume_hint"'); + expect(processMock.exit).toHaveBeenCalledWith(143); + }); + + it('propagates cleanup failures after buffered output finishes draining', async () => { + vi.useFakeTimers(); + try { + mocks.harnessClose.mockRejectedValueOnce(new Error('close failed')); + + let releaseBufferedWrite: (() => void) | undefined; + let blocked = true; + const stdout = new Writable({ + highWaterMark: 1, + write(_chunk, _encoding, callback) { + if (!blocked) { + callback(); + return; + } + releaseBufferedWrite = callback; + }, + }); + const run = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr: writer(), + }); + + for ( + let attempt = 0; + attempt < 20 && mocks.harnessClose.mock.calls.length === 0; + attempt += 1 + ) { + await Promise.resolve(); + } + expect(mocks.harnessClose).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS); + blocked = false; + releaseBufferedWrite?.(); + + await expect(run).rejects.toThrow('close failed'); + } finally { + vi.useRealTimers(); + } + }); + it('waits for the pending auto permission write before signal restore', async () => { let releaseAutoPermission!: () => void; let releasePrompt!: () => void; @@ -1107,7 +1208,7 @@ describe('runPrompt', () => { stderr: { write: vi.fn(() => true) }, }), ).rejects.toThrow( - 'No model configured. Run `kimi` and use /login to sign in, then retry; or set default_model in config.toml.', + 'No model configured. Run `kkm` and use /login to sign in, then retry; or set default_model in config.toml.', ); expect(mocks.harnessClose).toHaveBeenCalled(); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 2275d3a267..c8bf2ffd75 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: CLI shell startup, shutdown, and fatal-diagnostic wiring. + * Contract: fatal records are persisted synchronously before immediate process exits. + * Boundaries: harness, TUI, telemetry, logger, child_process, and process I/O are mocked. + * Run: pnpm exec vitest run apps/kimi-code/test/cli/run-shell.test.ts + */ import { execSync } from 'node:child_process'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; @@ -9,6 +15,22 @@ import { captureProcessWrite, ExitCalled, mockProcessExit } from '../helpers/pro type CreateKimiDeviceId = typeof createKimiDeviceIdFn; +const initialUncaughtExceptionListeners = new Set(process.listeners('uncaughtException')); +const initialUnhandledRejectionListeners = new Set(process.listeners('unhandledRejection')); + +function restoreProcessCrashListeners(): void { + for (const listener of process.listeners('uncaughtException')) { + if (!initialUncaughtExceptionListeners.has(listener)) { + process.off('uncaughtException', listener); + } + } + for (const listener of process.listeners('unhandledRejection')) { + if (!initialUnhandledRejectionListeners.has(listener)) { + process.off('unhandledRejection', listener); + } + } +} + const mocks = vi.hoisted(() => { type TuiConfigFallback = { theme: 'dark' | 'light' | 'auto'; @@ -59,6 +81,8 @@ const mocks = vi.hoisted(() => { })), resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'), flushDiagnosticLogsSync: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), harnessCreatesDeviceIdOnConstruction: false, execSync: vi.fn(), TuiConfigParseError, @@ -71,6 +95,13 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { ...actual, resolveKimiHome: mocks.resolveKimiHome, flushDiagnosticLogsSync: mocks.flushDiagnosticLogsSync, + log: { + error: mocks.logError, + warn: actual.log.warn.bind(actual.log), + info: mocks.logInfo, + debug: actual.log.debug.bind(actual.log), + createChild: actual.log.createChild.bind(actual.log), + }, createKimiHarness: (...args: unknown[]) => { const options = args[0] as { readonly homeDir?: string } | undefined; const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; @@ -121,6 +152,7 @@ vi.mock('../../src/tui/config', () => ({ vi.mock('../../src/tui/index', () => ({ KimiTUI: class { onExit?: () => Promise; + onDeadTerminalError?: (context: Record) => void; constructor(...args: unknown[]) { mocks.kimiTuiConstructor(this, ...args); @@ -147,6 +179,7 @@ vi.mock('node:child_process', () => ({ describe('runShell', () => { afterEach(() => { + restoreProcessCrashListeners(); vi.clearAllMocks(); mocks.harnessGetConfig.mockResolvedValue({ providers: {}, @@ -577,6 +610,79 @@ describe('runShell', () => { } }); + it('flushes a structured dead-terminal record synchronously', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }, + '1.2.3-test', + ); + + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + const error = Object.assign(new Error('write EPIPE'), { + code: 'EPIPE', + errno: -32, + syscall: 'write', + }); + const record = ( + tui as { + onDeadTerminalError: (context: Record) => void; + } + ).onDeadTerminalError; + + record({ + stream: 'stdout', + error, + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + }); + + expect(mocks.logError).toHaveBeenCalledWith( + 'terminal output stream failed, restoring terminal and exiting', + expect.objectContaining({ + error, + stream: 'stdout', + errorCode: 'EPIPE', + errno: -32, + syscall: 'write', + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + exitCode: 129, + stdoutIsTTY: process.stdout.isTTY, + stderrIsTTY: process.stderr.isTTY, + stdoutDestroyed: expect.any(Boolean), + stderrDestroyed: expect.any(Boolean), + pid: process.pid, + ppid: process.ppid, + }), + ); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(mocks.logError.mock.invocationCallOrder[0]!).toBeLessThan( + mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!, + ); + }); + it('flushes diagnostic logs synchronously before exiting on an unhandled rejection', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -706,7 +812,7 @@ describe('runShell', () => { expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); expect(stdout.text()).toBe(' Bye!\n'); - expect(stderr.text()).toContain(' To resume this session: kimi -r ses-1'); + expect(stderr.text()).toContain(' To resume this session: kkm -r ses-1'); } finally { exitSpy.mockRestore(); stdout.restore(); @@ -753,7 +859,7 @@ describe('runShell', () => { ExitCalled, ); - expect(stderr.text()).toContain(' To resume this session: kimi -r ses-1'); + expect(stderr.text()).toContain(' To resume this session: kkm -r ses-1'); expect(stderr.text()).toContain('open '); expect(stderr.text()).toContain(openedUrl); } finally { diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f51b..6cfa0863c7 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -822,7 +822,7 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.5.0', { ...options, track, logger })).resolves.toBe('continue'); const rendered = stdout.join(''); - expect(rendered).toContain('Kimi Code updated to v0.5.0'); + expect(rendered).toContain('KKM updated to v0.5.0'); expect(rendered).toContain( 'https://moonshotai.github.io/kimi-code/en/release-notes/changelog.html', ); @@ -857,7 +857,7 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); - expect(stdout.join('')).toContain('Kimi Code updated to v0.5.0'); + expect(stdout.join('')).toContain('KKM updated to v0.5.0'); expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ active: null, lastFailure: null, diff --git a/apps/kimi-code/test/cli/update/source.test.ts b/apps/kimi-code/test/cli/update/source.test.ts index dd88d32c3c..bd6c12d5f0 100644 --- a/apps/kimi-code/test/cli/update/source.test.ts +++ b/apps/kimi-code/test/cli/update/source.test.ts @@ -41,12 +41,24 @@ describe('classifyByPathHeuristic', () => { ).toBe('yarn-global'); }); - it('detects bun global', () => { + it('detects bun global (install/global layout)', () => { expect( classifyByPathHeuristic('/Users/me/.bun/install/global/node_modules/@moonshot-ai/kimi-code'), ).toBe('bun-global'); }); + it('detects bun global (node_modules layout)', () => { + expect( + classifyByPathHeuristic('/Users/me/.bun/node_modules/@moonshot-ai/kimi-code'), + ).toBe('bun-global'); + }); + + it('does not treat a local project named foo.bun as bun global', () => { + expect( + classifyByPathHeuristic('/work/foo.bun/node_modules/@moonshot-ai/kimi-code'), + ).toBeNull(); + }); + it('detects homebrew on macOS (Cellar path)', () => { expect( classifyByPathHeuristic('/opt/homebrew/Cellar/kimi-code/0.5.0/libexec/lib/node_modules/@moonshot-ai/kimi-code'), @@ -108,7 +120,7 @@ describe('detectInstallSource', () => { ).resolves.toBe('yarn-global'); }); - it('returns bun-global when packageRoot matches bun heuristic', async () => { + it('returns bun-global when packageRoot matches bun heuristic (install/global layout)', async () => { await expect( detectInstallSource({ getPackageRoot: () => '/Users/me/.bun/install/global/node_modules/@moonshot-ai/kimi-code', @@ -119,6 +131,17 @@ describe('detectInstallSource', () => { ).resolves.toBe('bun-global'); }); + it('returns bun-global when packageRoot matches bun heuristic (node_modules layout)', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/.bun/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('bun-global'); + }); + it('returns npm-global when packageRoot matches npm prefix', async () => { await expect( detectInstallSource({ diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 31b53b1d98..71ce90fcee 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -139,7 +139,7 @@ describe('handleUpgrade', () => { expect(deps.track).toHaveBeenCalledWith('upgrade_command_no_update', expect.objectContaining({ current_version: '0.4.0', })); - expect(stdout.join('')).toContain('Kimi Code is already up to date (v0.4.0).'); + expect(stdout.join('')).toContain('KKM is already up to date (v0.4.0).'); }); it('prints the manual update command when the install source cannot be auto-installed', async () => { diff --git a/apps/kimi-code/test/cli/vis.test.ts b/apps/kimi-code/test/cli/vis.test.ts index b611e6a372..8d525c3372 100644 --- a/apps/kimi-code/test/cli/vis.test.ts +++ b/apps/kimi-code/test/cli/vis.test.ts @@ -104,7 +104,7 @@ describe('handleVis', () => { waitForShutdown: vi.fn(async () => {}), }); await handleVis(deps, { open: true, port: 4321 }); - expect(errored.join('')).toContain('Failed to start kimi vis'); + expect(errored.join('')).toContain('Failed to start kkm vis'); expect(errored.join('')).toContain('EADDRINUSE'); expect(deps.exit).toHaveBeenCalledWith(1); // Nothing past the failed start should run. diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index d23497b5d1..cc56e6001d 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -137,9 +137,9 @@ describe('kimi web', () => { exitSpy.mockRestore(); expect(exitCalls).toEqual([1]); - expect(stderr).toContain('`kimi server` has been deprecated and no longer works.'); - expect(stderr).toContain('kimi web'); - expect(stderr).toContain('kimi server kill'); + expect(stderr).toContain('`kkm server` has been deprecated and no longer works.'); + expect(stderr).toContain('kkm web'); + expect(stderr).toContain('kkm server kill'); expect(stderr).toContain('0.28.0'); expect(stderr).toContain('next major version'); } diff --git a/apps/kimi-code/test/scripts/native/paths.test.ts b/apps/kimi-code/test/scripts/native/paths.test.ts index 826ade0bac..677bdd1988 100644 --- a/apps/kimi-code/test/scripts/native/paths.test.ts +++ b/apps/kimi-code/test/scripts/native/paths.test.ts @@ -43,13 +43,13 @@ describe('targetTriple', () => { }); describe('executableName', () => { - it('returns kimi.exe on win32', () => { - expect(executableName('win32')).toBe('kimi.exe'); + it('returns kkm.exe on win32', () => { + expect(executableName('win32')).toBe('kkm.exe'); }); - it('returns kimi on other platforms', () => { - expect(executableName('darwin')).toBe('kimi'); - expect(executableName('linux')).toBe('kimi'); + it('returns kkm on other platforms', () => { + expect(executableName('darwin')).toBe('kkm'); + expect(executableName('linux')).toBe('kkm'); }); }); @@ -64,16 +64,16 @@ describe('path helpers', () => { it('returns absolute bin path with executable name', () => { expect(nativeBinPath('darwin-arm64', 'darwin')).toBe( - p('dist-native/bin/darwin-arm64/kimi'), + p('dist-native/bin/darwin-arm64/kkm'), ); expect(nativeBinPath('win32-x64', 'win32')).toBe( - p('dist-native/bin/win32-x64/kimi.exe'), + p('dist-native/bin/win32-x64/kkm.exe'), ); }); it('returns intermediate artifact paths', () => { expect(nativeJsBundlePath()).toBe(p('dist-native/intermediates/main.cjs')); - expect(nativeBlobPath()).toBe(p('dist-native/intermediates/kimi.blob')); + expect(nativeBlobPath()).toBe(p('dist-native/intermediates/kkm.blob')); expect(nativeSeaConfigPath()).toBe( p('dist-native/intermediates/sea-config.json'), ); diff --git a/apps/kimi-code/test/scripts/native/release-artifacts.test.ts b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts index 791fd14e0d..c730fe73cb 100644 --- a/apps/kimi-code/test/scripts/native/release-artifacts.test.ts +++ b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts @@ -16,7 +16,7 @@ const packageScript = resolve(appRoot, 'scripts/native/package.mjs'); const manifestScript = resolve(appRoot, 'scripts/native/produce-manifest.mjs'); const artifactsDir = resolve(appRoot, 'dist-native/artifacts'); const target = 'test-zip-artifact'; -const executableName = process.platform === 'win32' ? 'kimi.exe' : 'kimi'; +const executableName = process.platform === 'win32' ? 'kkm.exe' : 'kkm'; const fakeBinary = resolve(appRoot, 'dist-native/bin', target, executableName); function sha256(bytes: Buffer | string): string { @@ -92,8 +92,8 @@ function findEndOfCentralDirectory(zip: Buffer): number { describe('native release artifacts', () => { afterEach(() => { rmSync(resolve(appRoot, 'dist-native/bin', target), { recursive: true, force: true }); - rmSync(resolve(artifactsDir, `kimi-code-${target}.zip`), { force: true }); - rmSync(resolve(artifactsDir, `kimi-code-${target}.zip.sha256`), { force: true }); + rmSync(resolve(artifactsDir, `kkm-${target}.zip`), { force: true }); + rmSync(resolve(artifactsDir, `kkm-${target}.zip.sha256`), { force: true }); }); it('packages the native binary as a zip archive and checksums the archive', async () => { @@ -106,14 +106,14 @@ describe('native release artifacts', () => { env: { ...process.env, KIMI_CODE_BUILD_TARGET: target }, }); - const archivePath = resolve(artifactsDir, `kimi-code-${target}.zip`); + const archivePath = resolve(artifactsDir, `kkm-${target}.zip`); const checksumPath = `${archivePath}.sha256`; expect(existsSync(archivePath)).toBe(true); expect(existsSync(checksumPath)).toBe(true); expect(zipEntryNames(archivePath)).toEqual([executableName]); expect(readZipEntry(archivePath, executableName).toString('utf-8')).toBe(binaryContent); expect(readFileSync(checksumPath, 'utf-8')).toBe( - `${sha256(readFileSync(archivePath))} kimi-code-${target}.zip\n`, + `${sha256(readFileSync(archivePath))} kkm-${target}.zip\n`, ); }); @@ -121,10 +121,10 @@ describe('native release artifacts', () => { const releaseDir = await mkdtemp(join(tmpdir(), 'kimi-manifest-zip-')); const archiveBytes = Buffer.from('fake zip bytes'); const checksum = sha256(archiveBytes); - await writeFile(join(releaseDir, 'kimi-code-darwin-arm64.zip'), archiveBytes); + await writeFile(join(releaseDir, 'kkm-darwin-arm64.zip'), archiveBytes); await writeFile( - join(releaseDir, 'kimi-code-darwin-arm64.zip.sha256'), - `${checksum} kimi-code-darwin-arm64.zip\n`, + join(releaseDir, 'kkm-darwin-arm64.zip.sha256'), + `${checksum} kkm-darwin-arm64.zip\n`, ); await execFileAsync(process.execPath, [manifestScript, releaseDir, '@moonshot-ai/kimi-code@0.5.0']); @@ -141,7 +141,7 @@ describe('native release artifacts', () => { tag: '@moonshot-ai/kimi-code@0.5.0', platforms: { 'darwin-arm64': { - filename: 'kimi-code-darwin-arm64.zip', + filename: 'kkm-darwin-arm64.zip', checksum, }, }, diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts index b9cf46bd60..51c364c3bc 100644 --- a/apps/kimi-code/test/tui/commands/skills.test.ts +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -1,4 +1,8 @@ -import { buildSkillSlashCommands, isUserActivatableSkill } from '#/tui/commands/index'; +import { + buildSkillSlashCommands, + findInlineSkillNames, + isUserActivatableSkill, +} from '#/tui/commands/index'; import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; @@ -76,10 +80,18 @@ describe('skill slash commands', () => { ]); expect([...built.commandMap.entries()]).toEqual([ ['mcp-config', 'mcp-config'], + ['skill:mcp-config', 'mcp-config'], ['update-config', 'update-config'], + ['skill:update-config', 'update-config'], ['skill:alpha', 'alpha'], ['skill:zeta', 'zeta'], ]); + expect(built.inlineCommands.map((command) => command.name)).toEqual([ + 'skill:mcp-config', + 'skill:update-config', + 'skill:alpha', + 'skill:zeta', + ]); }); it('keeps disableModelInvocation skills slash-invocable', () => { @@ -102,4 +114,20 @@ describe('skill slash commands', () => { expect(built.commands.map((command) => command.name)).toEqual(['outer.inner']); expect(built.commandMap.get('outer.inner')).toBe('outer.inner'); }); + + it('finds unique inline skill tokens in first-occurrence order', () => { + const built = buildSkillSlashCommands([ + skill('review', 'prompt', { source: 'project' }), + skill('commit', 'flow', { source: 'user' }), + skill('mcp-config', 'inline', { source: 'builtin' }), + ]); + + expect( + findInlineSkillNames( + 'Use /skill:review, then /skill:mcp-config and /skill:review before /skill:commit.', + built.commandMap, + ), + ).toEqual(['review', 'mcp-config', 'commit']); + expect(findInlineSkillNames('Keep /skill:missing as text.', built.commandMap)).toEqual([]); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 7ab1b33121..3ace3d785a 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -41,7 +41,7 @@ describe('FeedbackInputDialogComponent', () => { expect(text).toContain('╮'); expect(text).toContain('╰'); expect(text).toContain('╯'); - expect(text).toContain('Send feedback to Kimi Code'); + expect(text).toContain('Send feedback to KKM'); expect(text).toContain("Tell us what's working or what's not."); expect(text).toContain('Enter to submit'); expect(text).toContain('Esc to cancel'); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 8fced41768..c84343abb8 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -57,9 +57,9 @@ describe('ModelSelectorComponent', () => { const out = text(picker); // Model name on the left, provider on the right, with the current marker. - expect(out).toMatch(/❯ Kimi K2\s+Kimi Code ← current/); + expect(out).toMatch(/❯ Kimi K2\s+KKM ← current/); // Provider is no longer inlined in parentheses next to the name. - expect(out).not.toContain('Kimi K2 (Kimi Code)'); + expect(out).not.toContain('Kimi K2 (KKM)'); }); it('toggles thinking with Left/Right (not with "/")', () => { diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index b53b49a830..ed824b635f 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -270,6 +270,66 @@ describe('FileMentionProvider', () => { expect(result).toBeNull(); }); + it('offers only canonical skill commands after whitespace and on later lines', async () => { + const inlineSkills = [ + { name: 'skill:review', aliases: [], description: 'Review code' }, + { name: 'skill:commit', aliases: [], description: 'Commit changes' }, + ]; + const provider = new FileMentionProvider( + [HELP_COMMAND], + workDir, + NO_FD, + [], + () => 'prompt', + inlineSkills, + ); + + const inline = await provider.getSuggestions(['Please use /sk'], 0, 14, { signal: ctrl() }); + const multiline = await provider.getSuggestions(['Please use', '/sk'], 1, 3, { + signal: ctrl(), + }); + + expect(inline).toMatchObject({ + prefix: '/sk', + items: [ + expect.objectContaining({ value: 'skill:review' }), + expect.objectContaining({ value: 'skill:commit' }), + ], + }); + expect(multiline?.items.map((item) => item.value)).toEqual([ + 'skill:review', + 'skill:commit', + ]); + }); + + it('inserts an inline skill token without replacing the surrounding prompt', () => { + const inlineSkills = [ + { name: 'skill:review', aliases: [], description: 'Review code' }, + ]; + const provider = new FileMentionProvider( + [], + workDir, + NO_FD, + [], + () => 'prompt', + inlineSkills, + ); + + expect( + provider.applyCompletion( + ['Please /sk then continue'], + 0, + 'Please /sk'.length, + { value: 'skill:review', label: 'skill:review' }, + '/sk', + ), + ).toEqual({ + lines: ['Please /skill:review then continue'], + cursorLine: 0, + cursorCol: 'Please /skill:review '.length, + }); + }); + it('still allows forced root path completion after leading whitespace', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl(), force: true }); diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 34eaaed7dd..3a13f63edd 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -51,7 +51,7 @@ describe('status panel report lines', () => { }).map(strip); const output = lines.join('\n'); - expect(output).toContain('>_ Kimi Code (v1.2.3)'); + expect(output).toContain('>_ KKM (v1.2.3)'); expect(output).toContain('Model Kimi K2 (thinking high)'); expect(output).toContain('Directory /tmp/project'); expect(output).toContain('Permissions auto'); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 13ee90ceba..f9f46185e7 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -96,6 +96,7 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + skillCommandMap: Map; streamingUI: StreamingUIController; sessionEventHandler: { startSubscription(): void; @@ -232,6 +233,7 @@ function makeSession(overrides: Record = {}) { reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), reloadSession: vi.fn(async () => ({})), activateSkill: vi.fn(async () => {}), + promptWithSkills: vi.fn(async () => {}), getPluginInfo: vi.fn(async (id: string) => ({ id, displayName: id, @@ -2102,6 +2104,40 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); }); + it('submits inline skills once in first-occurrence order with the original prompt', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.skillCommandMap.set('skill:review', 'review'); + driver.skillCommandMap.set('skill:commit', 'commit'); + + driver.handleUserInput( + 'Use /skill:review, fix the issue, then /skill:review and /skill:commit.', + ); + + expect(session.promptWithSkills).toHaveBeenCalledWith( + [{ name: 'review' }, { name: 'commit' }], + 'Use /skill:review, fix the issue, then /skill:review and /skill:commit.', + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('submits multiple skills as one prompt when the first skill starts the message', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.skillCommandMap.set('skill:review', 'review'); + driver.skillCommandMap.set('skill:commit', 'commit'); + + driver.handleUserInput('/skill:review check this, then /skill:commit'); + + expect(session.promptWithSkills).toHaveBeenCalledWith( + [{ name: 'review' }, { name: 'commit' }], + '/skill:review check this, then /skill:commit', + ); + expect(session.activateSkill).not.toHaveBeenCalled(); + }); + it('shows an error instead of throwing when plugin command media materialization fails', async () => { const activatePluginCommand = vi.fn(async () => {}); const session = makeSession({ activatePluginCommand }); @@ -4046,7 +4082,7 @@ command = "vim" expect(getStatus).toHaveBeenCalledTimes(previousStatusCalls + 1); const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); expect(output).toContain(' Status '); - expect(output).toContain('>_ Kimi Code'); + expect(output).toContain('>_ KKM'); expect(output).toContain('Model'); expect(output).toContain('thinking high'); expect(output).toContain('Permissions auto'); @@ -4796,8 +4832,8 @@ command = "vim" }); const picker = driver.state.editorContainer.children[0]; const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(pickerOutput).toMatch(/Kimi K2\s+Kimi Code ← current/); - expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi Code/); + expect(pickerOutput).toMatch(/Kimi K2\s+KKM ← current/); + expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+KKM/); (picker as TabbedModelSelectorComponent).handleInput('t'); (picker as TabbedModelSelectorComponent).handleInput('u'); const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); @@ -5274,7 +5310,7 @@ command = "vim" expect(forked.onEvent).toHaveBeenCalledOnce(); expect(harness.resumeSession).not.toHaveBeenCalled(); expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( - 'Session forked (ses-fork). To return to the original session: kimi -r ses-source', + 'Session forked (ses-fork). To return to the original session: kkm -r ses-source', ); } finally { process.title = originalTitle; diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79f8cfb552..ff8a8b40cf 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -899,7 +899,7 @@ describe('KimiTUI startup', () => { expect(resumeSession).not.toHaveBeenCalled(); expect(driver.state.activeDialog).toBeNull(); - const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kkm --resume ${quoteShellArg('ses-other-cwd')}`; expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); const transcript = driver.state.transcriptContainer.render(160).join('\n'); expect(transcript).toContain('Current session is in a different working directory.'); @@ -937,7 +937,7 @@ describe('KimiTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj$(touch /tmp/pwned)')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj$(touch /tmp/pwned)')} && kkm --resume ${quoteShellArg('ses-other-cwd')}`; expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); const transcript = driver.state.transcriptContainer.render(160).join('\n'); expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); @@ -974,7 +974,7 @@ describe('KimiTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kkm --resume ${quoteShellArg('ses-other-cwd')}`; expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); expect(stop).toHaveBeenCalledOnce(); expect(stop).toHaveBeenCalledWith(0); diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts index cdc8709c36..31ffac56a7 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -240,21 +240,21 @@ describe('approval adapter', () => { response: 'approved', selected_label: 'auto', description: - 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + 'Best if you want KKM to keep working while you are away. Tools are approved automatically, and questions are skipped.', }, { label: 'Switch to YOLO and start', response: 'approved', selected_label: 'yolo', description: - 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + 'Tools and plan changes are approved automatically. KKM may still ask you questions.', }, { label: 'Start in Manual', response: 'approved', selected_label: 'manual', description: - 'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.', + 'Keep approvals on. KKM will ask before risky actions, so the goal may stop and wait for you.', }, { label: 'Do not start', @@ -284,14 +284,14 @@ describe('approval adapter', () => { response: 'approved', selected_label: 'auto', description: - 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + 'Best if you want KKM to keep working while you are away. Tools are approved automatically, and questions are skipped.', }, { label: 'Keep YOLO and start', response: 'approved', selected_label: 'yolo', description: - 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + 'Tools and plan changes stay approved automatically. KKM may still ask you questions.', }, { label: 'Do not start', diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 91dc5beacf..63633a9657 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: KimiTUI process-signal and terminal-output failure handling. + * Contract: preserve exit codes and record dead-stream context before emergency exit. + * Boundaries: process listeners and process.exit are mocked; KimiTUI lifecycle code is real. + * Run: pnpm exec vitest run apps/kimi-code/test/tui/signal-handlers.test.ts + */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; @@ -246,29 +252,86 @@ describe('KimiTUI signal handlers', () => { expect(exitSpy).toHaveBeenCalledWith(143); }); - it('stdout EIO error triggers emergency exit; ENOENT does not', () => { - const { driver } = makeDriver(); + it('records stdout EIO context before exiting with code 129', () => { + const { driver, tui } = makeDriver(); + driver.state.appState.sessionId = 'ses-dead-terminal'; + driver.state.appState.streamingPhase = 'thinking'; + tui.streamingUI.setTurnId('turn-28'); + tui.streamingUI.setStep(13); + const record = vi.fn(); + tui.onDeadTerminalError = record; const captured = captureHandlers(driver); - const eio = Object.assign(new Error('write EIO'), { code: 'EIO' }); + const eio = Object.assign(new Error('write EIO'), { + code: 'EIO', + errno: -5, + syscall: 'write', + }); captured.stdoutErrorHandler?.(eio); + + expect(record).toHaveBeenCalledWith({ + stream: 'stdout', + error: eio, + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + }); expect(exitSpy).toHaveBeenCalledWith(129); + expect(record.mock.invocationCallOrder[0]!).toBeLessThan(exitSpy.mock.invocationCallOrder[0]); + + captured.restore(); + driver.unregisterSignalHandlers(); + }); + + it('keeps running when stdout reports an unrelated error', () => { + const { driver, tui } = makeDriver(); + const record = vi.fn(); + tui.onDeadTerminalError = record; + const captured = captureHandlers(driver); - exitSpy.mockClear(); const enoent = Object.assign(new Error('not found'), { code: 'ENOENT' }); captured.stdoutErrorHandler?.(enoent); + + expect(record).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); captured.restore(); driver.unregisterSignalHandlers(); }); - it('stderr EPIPE error triggers emergency exit', () => { - const { driver } = makeDriver(); + it('records stderr EPIPE context before exiting with code 129', () => { + const { driver, tui } = makeDriver(); + const record = vi.fn(); + tui.onDeadTerminalError = record; const captured = captureHandlers(driver); const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); captured.stderrErrorHandler?.(epipe); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + stream: 'stderr', + error: epipe, + streamingPhase: 'idle', + }), + ); + expect(exitSpy).toHaveBeenCalledWith(129); + + captured.restore(); + driver.unregisterSignalHandlers(); + }); + + it('exits when dead-terminal recording throws', () => { + const { driver, tui } = makeDriver(); + tui.onDeadTerminalError = () => { + throw new Error('log unavailable'); + }; + const captured = captureHandlers(driver); + + const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); + captured.stdoutErrorHandler?.(epipe); + expect(exitSpy).toHaveBeenCalledWith(129); captured.restore(); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts index 7e802db6ad..9b12294311 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts @@ -159,3 +159,76 @@ describe('readClipboardMedia', () => { } }); }); + +describe('readClipboardMedia on Linux/WSL', () => { + // WSLg bridges Windows clipboard images into the Wayland clipboard as + // image/bmp only, which the image pipeline cannot decode. + function wslgBmpOnlyClipboard(clipTmp: { path: string }) { + return vi.fn((command: string, args: string[]) => { + if (command === 'wl-paste' && args.includes('--list-types')) { + return { stdout: Buffer.from('image/bmp\n'), ok: true }; + } + if (command === 'wl-paste') { + return { stdout: Buffer.from([0x42, 0x4d, 0x00]), ok: true }; + } + if (command === 'xclip') return { stdout: Buffer.alloc(0), ok: false }; + if (command === 'wslpath') { + clipTmp.path = args[1] ?? ''; + return { stdout: Buffer.from('C:\\Temp\\kimi-wsl-clip-test.png\n'), ok: true }; + } + if (command === 'powershell.exe') { + writeFileSync(clipTmp.path, png(3, 2)); + return { stdout: Buffer.from('ok\n'), ok: true }; + } + return { stdout: Buffer.alloc(0), ok: false }; + }); + } + + it('skips undecodable image/bmp and falls through to the WSL PowerShell fallback', async () => { + const clipTmp = { path: '' }; + const runCommand = wslgBmpOnlyClipboard(clipTmp); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { WSL_DISTRO_NAME: 'Ubuntu-24.04', WAYLAND_DISPLAY: 'wayland-0' }, + clipboard: null, + runCommand, + }); + + expect(media).toEqual({ kind: 'image', bytes: png(3, 2), mimeType: 'image/png' }); + + // The BMP payload must never be read, and the PowerShell fallback must + // receive the temp path embedded in the script (env vars do not cross + // the WSL boundary unless listed in WSLENV). + const calls = runCommand.mock.calls; + expect( + calls.some((c) => c[0] === 'wl-paste' && (c[1] as string[]).includes('--type')), + ).toBe(false); + const psCall = calls.find((c) => c[0] === 'powershell.exe'); + expect(psCall?.[1]?.join(' ')).toContain("$path = 'C:\\Temp\\kimi-wsl-clip-test.png'"); + }); + + it('returns null for a BMP-only clipboard when not on WSL', async () => { + const runCommand = vi.fn((command: string, args: string[]) => { + if (command === 'wl-paste' && args.includes('--list-types')) { + return { stdout: Buffer.from('image/bmp\n'), ok: true }; + } + if (command === 'wl-paste') { + return { stdout: Buffer.from([0x42, 0x4d, 0x00]), ok: true }; + } + return { stdout: Buffer.alloc(0), ok: false }; + }); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-0' }, + clipboard: null, + runCommand, + }); + + expect(media).toBeNull(); + expect( + runCommand.mock.calls.some((c) => c[0] === 'wl-paste' && (c[1] as string[]).includes('--type')), + ).toBe(false); + }); +}); diff --git a/apps/vscode/src/KimiWebviewProvider.ts b/apps/vscode/src/KimiWebviewProvider.ts index 2fed3fa09d..efdf66baf1 100644 --- a/apps/vscode/src/KimiWebviewProvider.ts +++ b/apps/vscode/src/KimiWebviewProvider.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import * as vscode from "vscode"; import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; import { Events } from "../shared/bridge"; @@ -49,7 +50,7 @@ export class KimiWebviewProvider implements vscode.WebviewViewProvider { } resolveWebviewView(webviewView: vscode.WebviewView): void { - const webviewId = `sidebar_${crypto.randomUUID()}`; + const webviewId = `sidebar_${randomUUID()}`; this.setupWebview(webviewId, webviewView.webview); webviewView.onDidDispose(() => { @@ -59,7 +60,7 @@ export class KimiWebviewProvider implements vscode.WebviewViewProvider { } createPanel(): vscode.WebviewPanel { - const webviewId = `panel_${crypto.randomUUID()}`; + const webviewId = `panel_${randomUUID()}`; const panel = vscode.window.createWebviewPanel("kimiPanel", "Kimi Code", vscode.ViewColumn.One, { enableScripts: true, diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 37972f5116..d6c9129376 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -109,7 +109,7 @@ Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuest **`TaskList`** returns the list of background tasks. Optional parameters: `active_only` (defaults to true; lists only running tasks) and `limit` (defaults to 20; range 1–100). -**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. Optional `block` (defaults to false) and `timeout` (seconds to wait; defaults to 30; range 0–3600) parameters allow waiting for the task to complete before returning. +**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. For `Agent` subagent tasks, the output streams live: turns, tool calls, and thinking/assistant text appear as they happen, so this is how you watch a subagent work in real time. Optional `block` (defaults to false) and `timeout` (seconds to wait; defaults to 30; range 0–3600) parameters allow waiting for the task to complete before returning. **`TaskStop`** accepts a `task_id` and optional `reason` (defaults to `Stopped by TaskStop`). Safe to call on tasks that are already in a terminal state. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index f993fa7bb6..d71264118a 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -109,7 +109,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`TaskList`** 返回后台任务列表。可选参数 `active_only`(默认 true,仅列出运行中的任务)和 `limit`(默认 20,取值范围 1–100)。 -**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。 +**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。对于 `Agent` 子代理任务,输出是实时流式的:轮次、工具调用和思考/回复内容都会随执行即时写入,可以用它实时观察子代理的工作过程。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。 **`TaskStop`** 接受 `task_id` 和可选的 `reason`(默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用。 diff --git a/packages/acp-adapter/src/question.ts b/packages/acp-adapter/src/question.ts index 2270d8105f..46595113ee 100644 --- a/packages/acp-adapter/src/question.ts +++ b/packages/acp-adapter/src/question.ts @@ -4,58 +4,35 @@ import type { } from '@agentclientprotocol/sdk'; import type { QuestionAnswers, QuestionItem } from '@moonshot-ai/kimi-code-sdk'; -/** - * `optionId` namespace for the AskUserQuestion bridge. - * - * The wire-level `PermissionOption.optionId` is opaque to the client (it - * round-trips back via `RequestPermissionResponse.outcome.optionId`), so - * the adapter is free to pick any stable string. We embed the - * `questionIndex` in the prefix so multi-question support (when it - * arrives — Phase 13.1 still degrades to single-question) does not need - * a wire-format change: `q0_opt_*` / `q1_opt_*` are already - * non-conflicting. The skip option follows the same scheme so a single - * regex (`/^q(\d+)_(opt_(\d+)|skip)$/`) can parse any future surface. - */ function optOptionId(questionIndex: number, optionIndex: number): string { return `q${questionIndex}_opt_${optionIndex}`; } +function doneOptionId(questionIndex: number): string { + return `q${questionIndex}_done`; +} + function skipOptionId(questionIndex: number): string { return `q${questionIndex}_skip`; } -/** - * Map a tool-side {@link QuestionItem} into ACP - * {@link PermissionOption}[]. - * - * Layout: - * - One `allow_once` option per `question.options[i]` (label preserved - * verbatim — it is the same string we surface back to the SDK as a - * `QuestionAnswers` value, so any UI normalisation belongs on the - * tool side, not here). - * - One trailing `reject_once` "Skip" option so the user can dismiss - * the prompt without forcing an answer. The SDK's ask-user tool - * already understands dismissal (`packages/agent-core/src/tools/builtin/collaboration/ask-user.ts:126` - * emits `question_dismissed` and resolves with a null result); the - * Skip surface is the user-facing path into that branch. - * - * `questionIndex` is currently always `0` (Phase 13.1 degrades - * multi-question to single-question), but the namespace is wired in so - * future multi-question support is a pure handler change with no wire - * format break. - * - * Returned `readonly` because callers treat it as a constant lookup - * table — they do not mutate it. - */ export function questionItemToPermissionOptions( question: QuestionItem, questionIndex: number, + selectedOptions: ReadonlySet = new Set(), ): readonly PermissionOption[] { const options: PermissionOption[] = question.options.map((opt, i) => ({ optionId: optOptionId(questionIndex, i), - name: opt.label, + name: selectedOptions.has(i) ? `✓ ${opt.label}` : opt.label, kind: 'allow_once' as const, })); + if (question.multiSelect === true && selectedOptions.size > 0) { + options.push({ + optionId: doneOptionId(questionIndex), + name: 'Done', + kind: 'allow_once' as const, + }); + } options.push({ optionId: skipOptionId(questionIndex), name: 'Skip', @@ -64,36 +41,38 @@ export function questionItemToPermissionOptions( return options; } -/** - * Reverse-map an ACP {@link RequestPermissionResponse} into a tool-side - * {@link QuestionAnswers} payload, returning `null` when the user - * dismissed (skip, cancel) or selected an unknown option. - * - * Dismissal semantics align with the existing ask-user tool path: - * `null` causes the SDK to resolve the tool with the canonical - * "user dismissed" branch (mirrors `rpc.ts:567` — `requestQuestion` - * returning `null` is the dismissed signal). - * - * Defensive on out-of-bounds / unknown optionIds: returning `null` - * rather than throwing keeps the bridge robust against stale or custom - * options surfaced by the client. - */ -export function outcomeToQuestionAnswer( +export type QuestionPermissionOutcome = + | { readonly kind: 'option'; readonly optionIndex: number } + | { readonly kind: 'done' } + | { readonly kind: 'skip' } + | { readonly kind: 'cancelled' }; + +export function permissionResponseToQuestionOutcome( question: QuestionItem, + questionIndex: number, response: RequestPermissionResponse, -): QuestionAnswers | null { - if (response.outcome.outcome === 'cancelled') return null; +): QuestionPermissionOutcome { + if (response.outcome.outcome === 'cancelled') return { kind: 'cancelled' }; const optionId = response.outcome.optionId; - // Skip — explicit dismissal path; treat the same as `cancelled`. - if (optionId === skipOptionId(0)) return null; - // Selected option — parse the `q0_opt_` shape and look up the - // matching label. Reject anything that does not match the namespace - // (or whose index is out of bounds) defensively rather than crashing. - const match = /^q0_opt_(\d+)$/.exec(optionId); - if (!match) return null; + if (optionId === skipOptionId(questionIndex)) return { kind: 'skip' }; + if (question.multiSelect === true && optionId === doneOptionId(questionIndex)) { + return { kind: 'done' }; + } + const match = new RegExp(`^q${String(questionIndex)}_opt_(\\d+)$`).exec(optionId); + if (!match) return { kind: 'cancelled' }; const optionIndex = Number(match[1]); - if (!Number.isInteger(optionIndex) || optionIndex < 0) return null; - const selected = question.options[optionIndex]; - if (!selected) return null; + if (!Number.isInteger(optionIndex) || optionIndex < 0 || !question.options[optionIndex]) { + return { kind: 'cancelled' }; + } + return { kind: 'option', optionIndex }; +} + +export function outcomeToQuestionAnswer( + question: QuestionItem, + response: RequestPermissionResponse, +): QuestionAnswers | null { + const outcome = permissionResponseToQuestionOutcome(question, 0, response); + if (outcome.kind !== 'option') return null; + const selected = question.options[outcome.optionIndex]!; return { [question.question]: selected.label }; } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..64f33df5d3 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -21,7 +21,9 @@ import { type McpServerInfo, type PromptPart, type QuestionAnswers, + type QuestionItem, type QuestionRequest, + type QuestionResult, type Session, type SessionStatus, type SessionUsage, @@ -56,7 +58,10 @@ import { turnEndReasonToStopReason, } from './events-map'; import { acpModeToToggles, DEFAULT_MODE_ID, isAcpModeId, type AcpModeId } from './modes'; -import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from './question'; +import { + permissionResponseToQuestionOutcome, + questionItemToPermissionOptions, +} from './question'; import { detectSlashIntent } from './slash'; /** @@ -71,6 +76,10 @@ export type TelemetryTrackFn = ( properties?: Record, ) => void; +function formatQuestionCount(count: number): string { + return `${String(count)} ${count === 1 ? 'question' : 'questions'}`; +} + /** * Adapter-side wrapper around a {@link Session} from the Kimi node SDK. * @@ -1347,86 +1356,138 @@ export class AcpSession { } } - /** - * Bridge an SDK {@link QuestionRequest} (the AskUserQuestion tool's - * reverse-RPC) through the same ACP - * `session/request_permission` surface used by approvals. - * - * ACP currently has no dedicated `session/request_question` method, so - * the adapter re-uses `requestPermission` and tags the options with a - * `q{n}_*` namespace so the round-trip is unambiguous. - * - * Degradation rules: - * - `req.questions.length > 1` → only the first question is asked; - * telemetry records the dropped count so we can observe how often - * multi-question prompts land in the wild. - * - `q.multiSelect === true` → still asked as single-select; the - * SDK's ask-user tool tolerates a single-key answer for a multi- - * select prompt so this is a graceful narrow rather than a hard - * fail. - * - * Error policy mirrors {@link handleApproval}: any RPC failure logs - * a warning and returns `null` so the SDK resolves the tool with the - * canonical "user dismissed" branch (`rpc.ts:567`). Returning `null` - * is strictly safer than fabricating an answer the user did not give. - */ - private async handleQuestion(req: QuestionRequest): Promise { + private async handleQuestion(req: QuestionRequest): Promise { const questions = req.questions; if (questions.length === 0) { - // Pathological input — log and dismiss. No telemetry: the SDK - // would never emit an empty `questions` payload in practice. log.warn('acp: handleQuestion received empty questions array', { sessionId: this.id, }); return null; } - if (questions.length > 1) { - log.warn('acp: handleQuestion degrading to first question only', { - sessionId: this.id, - dropped: questions.length - 1, - }); - this.emitTelemetry('question_degraded', { - reason: 'multi_question', - dropped: questions.length - 1, - }); - } - const q = questions[0]!; - if (q.multiSelect === true) { - this.emitTelemetry('question_degraded', { reason: 'multi_select' }); - } - const options = questionItemToPermissionOptions(q, 0); const rawToolCallId = req.toolCallId ?? 'ask-user'; const toolCallId = this.currentTurnId !== undefined ? acpToolCallId(this.currentTurnId, rawToolCallId) : rawToolCallId; + const answers: QuestionAnswers = {}; + const skippedQuestions: string[] = []; + const clientUnansweredQuestions: string[] = []; + let questionIndex = 0; try { + for (; questionIndex < questions.length; questionIndex++) { + const question = questions[questionIndex]!; + const result = await this.handleQuestionItem( + question, + questionIndex, + questions.length, + toolCallId, + ); + if (result.answer !== undefined) { + answers[question.question] = result.answer; + } else { + skippedQuestions.push(question.question); + } + if (result.cancelRemaining) { + skippedQuestions.push( + ...questions.slice(questionIndex + 1).map((item) => item.question), + ); + break; + } + } + } catch (error) { + clientUnansweredQuestions.push( + ...questions.slice(questionIndex).map((item) => item.question), + ); + log.warn('acp: requestPermission (question) failed; dismissing remaining questions', { + sessionId: this.id, + toolCallId: req.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + } + + const answered = Object.keys(answers).length; + if (answered === 0) { + this.emitTelemetry('question_dismissed'); + } else { + this.emitTelemetry('question_answered', { answered }); + } + const noteParts: string[] = []; + if (skippedQuestions.length > 0) { + noteParts.push( + `User skipped ${formatQuestionCount(skippedQuestions.length)}: ${skippedQuestions.map((question) => JSON.stringify(question)).join(', ')}.`, + ); + } + if (clientUnansweredQuestions.length > 0) { + noteParts.push( + `The client stopped before ${formatQuestionCount(clientUnansweredQuestions.length)} could be answered.`, + ); + } + if (noteParts.length === 0) return answers; + return { + answers, + note: noteParts.join(' '), + }; + } + + private async handleQuestionItem( + question: QuestionItem, + questionIndex: number, + questionCount: number, + toolCallId: string, + ): Promise<{ readonly answer?: string; readonly cancelRemaining: boolean }> { + const selectedOptions = new Set(); + while (true) { + const options = questionItemToPermissionOptions( + question, + questionIndex, + selectedOptions, + ); const response = await this.conn.requestPermission({ sessionId: this.id, options: [...options], toolCall: { toolCallId, - title: 'AskUserQuestion', - content: [{ type: 'content', content: { type: 'text', text: q.question } }], + title: + questionCount === 1 + ? 'AskUserQuestion' + : `AskUserQuestion (${String(questionIndex + 1)}/${String(questionCount)})`, + content: [ + { type: 'content', content: { type: 'text', text: question.question } }, + ], }, }); - const answer = outcomeToQuestionAnswer(q, response); - if (answer === null) { - // Dismissed via skip / cancel / unknown optionId — telemetry - // matches the ask-user tool's existing `question_dismissed` - // event so dashboards stay coherent. - this.emitTelemetry('question_dismissed'); + const outcome = permissionResponseToQuestionOutcome( + question, + questionIndex, + response, + ); + if (outcome.kind === 'cancelled') { + return { cancelRemaining: true }; + } + if (outcome.kind === 'skip') { + return { cancelRemaining: false }; + } + if (outcome.kind === 'done') { + if (selectedOptions.size === 0) { + return { cancelRemaining: true }; + } + const answer = question.options + .filter((_option, optionIndex) => selectedOptions.has(optionIndex)) + .map((option) => option.label) + .join(', '); + return { answer, cancelRemaining: false }; + } + if (question.multiSelect !== true) { + return { + answer: question.options[outcome.optionIndex]!.label, + cancelRemaining: false, + }; + } + if (selectedOptions.has(outcome.optionIndex)) { + selectedOptions.delete(outcome.optionIndex); } else { - this.emitTelemetry('question_answered', { answered: Object.keys(answer).length }); + selectedOptions.add(outcome.optionIndex); } - return answer; - } catch (err) { - log.warn('acp: requestPermission (question) failed; dismissing', { - sessionId: this.id, - toolCallId: req.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - return null; } } diff --git a/packages/acp-adapter/test/question.test.ts b/packages/acp-adapter/test/question.test.ts index 8b4d22dc74..8b9cd5ded7 100644 --- a/packages/acp-adapter/test/question.test.ts +++ b/packages/acp-adapter/test/question.test.ts @@ -5,7 +5,11 @@ import type { import type { QuestionItem } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; -import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from '../src/question'; +import { + outcomeToQuestionAnswer, + permissionResponseToQuestionOutcome, + questionItemToPermissionOptions, +} from '../src/question'; const sampleQuestion: QuestionItem = { question: 'Pick a flavour', @@ -62,6 +66,18 @@ describe('questionItemToPermissionOptions', () => { kind: 'reject_once', }); }); + + it('marks selected multi-select options and exposes Done after the first choice', () => { + const multi: QuestionItem = { ...sampleQuestion, multiSelect: true }; + const opts = questionItemToPermissionOptions(multi, 2, new Set([0, 2])); + expect(opts.map((option) => [option.optionId, option.name])).toEqual([ + ['q2_opt_0', '✓ Vanilla'], + ['q2_opt_1', 'Chocolate'], + ['q2_opt_2', '✓ Mint chip'], + ['q2_done', 'Done'], + ['q2_skip', 'Skip'], + ]); + }); }); describe('outcomeToQuestionAnswer', () => { @@ -98,4 +114,23 @@ describe('outcomeToQuestionAnswer', () => { it('defensively maps an out-of-bounds index to null', () => { expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_99'))).toBeNull(); }); + + it('parses question-indexed options, Done, Skip, and cancellation', () => { + const multi: QuestionItem = { ...sampleQuestion, multiSelect: true }; + expect(permissionResponseToQuestionOutcome(multi, 2, selected('q2_opt_1'))).toEqual({ + kind: 'option', + optionIndex: 1, + }); + expect(permissionResponseToQuestionOutcome(multi, 2, selected('q2_done'))).toEqual({ + kind: 'done', + }); + expect(permissionResponseToQuestionOutcome(multi, 2, selected('q2_skip'))).toEqual({ + kind: 'skip', + }); + expect( + permissionResponseToQuestionOutcome(multi, 2, { + outcome: { outcome: 'cancelled' }, + }), + ).toEqual({ kind: 'cancelled' }); + }); }); diff --git a/packages/acp-adapter/test/session-question-handler.test.ts b/packages/acp-adapter/test/session-question-handler.test.ts index 28536a308c..59fa0112f2 100644 --- a/packages/acp-adapter/test/session-question-handler.test.ts +++ b/packages/acp-adapter/test/session-question-handler.test.ts @@ -63,6 +63,7 @@ function makeQuestionSession(sessionId: string): { */ class CapturingConn { readonly permissionRequests: RequestPermissionRequest[] = []; + replies: Array = []; reply: RequestPermissionResponse = { outcome: { outcome: 'selected', optionId: 'q0_opt_0' }, }; @@ -73,7 +74,9 @@ class CapturingConn { if (this.shouldThrow) { throw new Error('client unreachable'); } - return this.reply; + const nextReply = this.replies.shift(); + if (nextReply instanceof Error) throw nextReply; + return nextReply ?? this.reply; } async sessionUpdate(): Promise { /* not exercised */ @@ -166,7 +169,7 @@ describe('AcpSession.handleQuestion', () => { expect(trackCalls).toEqual([{ event: 'question_answered', properties: { answered: 1 } }]); }); - it('skip: q0_skip resolves to null with question_dismissed telemetry', async () => { + it('skip: q0_skip resolves with a skipped-question note', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-skip'); raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_skip' } }; @@ -174,11 +177,14 @@ describe('AcpSession.handleQuestion', () => { const answer = await handle.invokeHandler(makeReq()); - expect(answer).toBeNull(); + expect(answer).toEqual({ + answers: {}, + note: 'User skipped 1 question: "哪个口味?".', + }); expect(trackCalls).toEqual([{ event: 'question_dismissed', properties: undefined }]); }); - it('cancelled: outcome cancelled resolves to null with question_dismissed', async () => { + it('cancelled: reports the current and remaining questions as skipped', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-cancel'); raw.reply = { outcome: { outcome: 'cancelled' } }; @@ -186,14 +192,21 @@ describe('AcpSession.handleQuestion', () => { const answer = await handle.invokeHandler(makeReq()); - expect(answer).toBeNull(); + expect(answer).toEqual({ + answers: {}, + note: 'User skipped 1 question: "哪个口味?".', + }); expect(trackCalls).toEqual([{ event: 'question_dismissed', properties: undefined }]); }); - it('multi-question degradation: 3 questions → only first asked + question_degraded', async () => { + it('asks every question in order and returns all non-skipped answers', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-multi'); - raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_1' } }; + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_opt_1' } }, + { outcome: { outcome: 'selected', optionId: 'q1_opt_0' } }, + { outcome: { outcome: 'selected', optionId: 'q2_skip' } }, + ]; new AcpSession(conn, handle.session, undefined, track); const extra1: QuestionItem = { question: 'Q2', options: [{ label: 'a' }] }; @@ -202,24 +215,36 @@ describe('AcpSession.handleQuestion', () => { makeReq({ questions: [sampleQuestion, extra1, extra2] }), ); - expect(answer).toEqual({ '哪个口味?': '巧克力' }); - expect(raw.permissionRequests).toHaveLength(1); - // Telemetry: degraded(multi_question) first, then answered. + expect(answer).toEqual({ + answers: { '哪个口味?': '巧克力', Q2: 'a' }, + note: 'User skipped 1 question: "Q3".', + }); + expect(raw.permissionRequests).toHaveLength(3); + expect(raw.permissionRequests.map((request) => request.toolCall.title)).toEqual([ + 'AskUserQuestion (1/3)', + 'AskUserQuestion (2/3)', + 'AskUserQuestion (3/3)', + ]); + expect(raw.permissionRequests.map((request) => request.options[0]?.optionId)).toEqual([ + 'q0_opt_0', + 'q1_opt_0', + 'q2_opt_0', + ]); expect(trackCalls).toEqual([ - { event: 'question_degraded', properties: { reason: 'multi_question', dropped: 2 } }, - { event: 'question_answered', properties: { answered: 1 } }, + { event: 'question_answered', properties: { answered: 2 } }, ]); - // log.warn fired with the dropped count. - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('degrading to first question only'), - expect.objectContaining({ dropped: 2 }), - ); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('multiSelect degradation: still asks the question + question_degraded', async () => { + it('toggles multi-select choices until Done and returns the remaining selections', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-multisel'); - raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }; + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }, + { outcome: { outcome: 'selected', optionId: 'q0_opt_1' } }, + { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }, + { outcome: { outcome: 'selected', optionId: 'q0_done' } }, + ]; new AcpSession(conn, handle.session, undefined, track); const multi: QuestionItem = { @@ -232,15 +257,87 @@ describe('AcpSession.handleQuestion', () => { questions: [multi], }); - expect(answer).toEqual({ 'Pick any': 'a' }); - expect(raw.permissionRequests).toHaveLength(1); + expect(answer).toEqual({ 'Pick any': 'b' }); + expect(raw.permissionRequests).toHaveLength(4); + expect(raw.permissionRequests[1]?.options.map((option) => option.name)).toEqual([ + '✓ a', + 'b', + 'Done', + 'Skip', + ]); + expect(raw.permissionRequests[2]?.options.map((option) => option.name)).toEqual([ + '✓ a', + '✓ b', + 'Done', + 'Skip', + ]); + expect(raw.permissionRequests[3]?.options.map((option) => option.name)).toEqual([ + 'a', + '✓ b', + 'Done', + 'Skip', + ]); + expect(trackCalls).toEqual([ + { event: 'question_answered', properties: { answered: 1 } }, + ]); + }); + + it('continues to later questions after Skip', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-skip-continue'); + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_skip' } }, + { outcome: { outcome: 'selected', optionId: 'q1_opt_0' } }, + ]; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler( + makeReq({ + questions: [ + sampleQuestion, + { question: 'Q2', options: [{ label: 'second answer' }] }, + ], + }), + ); + + expect(answer).toEqual({ + answers: { Q2: 'second answer' }, + note: 'User skipped 1 question: "哪个口味?".', + }); + expect(raw.permissionRequests).toHaveLength(2); expect(trackCalls).toEqual([ - { event: 'question_degraded', properties: { reason: 'multi_select' } }, { event: 'question_answered', properties: { answered: 1 } }, ]); }); - it('requestPermission throw → log.warn + null', async () => { + it('distinguishes a skipped question from a later client failure', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-skip-then-throw'); + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_skip' } }, + new Error('client unreachable'), + ]; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler( + makeReq({ + questions: [ + sampleQuestion, + { question: 'Q2', options: [{ label: 'second answer' }] }, + ], + }), + ); + + expect(answer).toEqual({ + answers: {}, + note: + 'User skipped 1 question: "哪个口味?". ' + + 'The client stopped before 1 question could be answered.', + }); + expect(raw.permissionRequests).toHaveLength(2); + }); + + it('requestPermission throw logs a warning and returns a client-stopped note', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-throw'); raw.shouldThrow = true; @@ -248,14 +345,17 @@ describe('AcpSession.handleQuestion', () => { const answer = await handle.invokeHandler(makeReq()); - expect(answer).toBeNull(); + expect(answer).toEqual({ + answers: {}, + note: 'The client stopped before 1 question could be answered.', + }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('requestPermission (question) failed'), expect.objectContaining({ toolCallId: 'tc-ask-1' }), ); - // No question_answered / question_dismissed emitted on throw — the - // RPC failure is its own observability path (log.warn above). - expect(trackCalls).toEqual([]); + expect(trackCalls).toEqual([ + { event: 'question_dismissed', properties: undefined }, + ]); }); it('no track sink: handler still runs without emitting telemetry', async () => { diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 90050ef3ba..f3829ee52d 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1010,7 +1010,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map/tmp`). `toShellPath` renders native paths for bash command + * lines; `fromShellPath` resolves model/shell-supplied paths for fs access, + * translating drive-letter forms lexically and other root-relative paths + * through `cygpath -w` next to the probed bash. Anything unconvertible + * passes through unchanged, and both directions are identity outside win32 + * bash. + * + * Vendored from `@moonshot-ai/kaos` `shell-path-bridge.ts` — kept as a pure + * helper with no DI dependencies. + */ + +import { execFileSync as nodeExecFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as nodePath from 'node:path'; + +import type { HostEnvironmentInfo } from './environmentProbe'; + +export interface ShellPathBridge { + toShellPath(nativePath: string): string; + fromShellPath(path: string): string; +} + +export type ShellPathBridgeEnv = Pick; + +export interface ShellPathBridgeDeps { + readonly execFileSync: (file: string, args: readonly string[]) => string; + readonly isFile: (path: string) => boolean; +} + +const CYGPATH_TIMEOUT_MS = 5_000; + +const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/; +const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/; +const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/; + +const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/']; + +const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/; + +function joinDrive(letter: string, rest: string): string { + const normalizedRest = rest.replaceAll('\\', '/'); + return normalizedRest === '' + ? `${letter.toUpperCase()}:/` + : `${letter.toUpperCase()}:${normalizedRest}`; +} + +export function translateShellDrivePath(path: string): string { + const colonMatch = DRIVE_COLON_RE.exec(path); + if (colonMatch !== null) { + return joinDrive(colonMatch[1]!, path.slice(3)); + } + const cygdriveMatch = CYGDRIVE_RE.exec(path); + if (cygdriveMatch !== null) { + return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length)); + } + const driveMatch = DRIVE_RE.exec(path); + if (driveMatch !== null) { + return joinDrive(driveMatch[1]!, path.slice(2)); + } + return path; +} + +export function createShellPathBridge( + env: ShellPathBridgeEnv, + deps: ShellPathBridgeDeps, +): ShellPathBridge { + const enabled = env.osKind === 'Windows' && env.shellName === 'bash'; + + let cygpathExe: string | null | undefined; + const segmentCache = new Map(); + + function locateCygpath(): string | null { + if (cygpathExe !== undefined) return cygpathExe; + const shellDir = nodePath.win32.dirname(env.shellPath); + const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')]; + if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') { + candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe')); + } + cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null; + return cygpathExe; + } + + function resolveRootSegment(firstSegment: string): string | null { + const cached = segmentCache.get(firstSegment); + if (cached !== undefined) return cached; + + const exe = locateCygpath(); + if (exe === null) return null; + let resolved: string; + try { + const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]); + const trimmed = output.replace(/\r?\n$/, ''); + if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null; + resolved = trimmed.replace(/[\\/]$/, ''); + } catch { + return null; + } + segmentCache.set(firstSegment, resolved); + return resolved; + } + + function fromShellPath(path: string): string { + if (!enabled) return path; + + if (path.startsWith('//')) return path; + + if (path.startsWith('/')) { + const normalized = nodePath.posix.normalize(path); + const lexical = translateShellDrivePath(normalized); + if (lexical !== normalized) return lexical; + if (normalized === '/') return normalized; + if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized; + const firstSegment = normalized.slice(1).split('/')[0]!; + const prefix = resolveRootSegment(firstSegment); + if (prefix === null) return normalized; + const remainder = normalized.slice(firstSegment.length + 1); + const joined = `${prefix}${remainder}`.replaceAll('\\', '/'); + return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined; + } + + return path; + } + + function toShellPath(nativePath: string): string { + if (!enabled) return nativePath; + + if (nativePath.startsWith('\\\\')) { + return nativePath.replaceAll('\\', '/'); + } + + const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath); + if (driveMatch !== null) { + const drive = driveMatch[1]!.toLowerCase(); + const rest = nativePath.slice(2).replaceAll('\\', '/'); + return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`; + } + + return nativePath.replaceAll('\\', '/'); + } + + return { toShellPath, fromShellPath }; +} + +const bridgeCache = new WeakMap(); + +export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge { + const cached = bridgeCache.get(env); + if (cached !== undefined) return cached; + const bridge = createShellPathBridge(env, { + execFileSync: (file, args) => + nodeExecFileSync(file, [...args], { + encoding: 'utf8', + timeout: CYGPATH_TIMEOUT_MS, + windowsHide: true, + }), + isFile: (path) => existsSync(path), + }); + bridgeCache.set(env, bridge); + return bridge; +} diff --git a/packages/agent-core-v2/src/_base/utils/abort.ts b/packages/agent-core-v2/src/_base/utils/abort.ts index 09b2860fb0..be74262747 100644 --- a/packages/agent-core-v2/src/_base/utils/abort.ts +++ b/packages/agent-core-v2/src/_base/utils/abort.ts @@ -31,15 +31,28 @@ export function isUserCancellation(value: unknown): value is UserCancellationErr } export function abortable(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortReason(signal)); return new Promise((resolve, reject) => { const onAbort = () => { reject(abortReason(signal)); }; - signal.addEventListener('abort', onAbort, { once: true }); - promise.then(resolve, reject).finally(() => { + const cleanup = () => { signal.removeEventListener('abort', onAbort); - }); + }; + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); }); } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 057bebd882..32924ef110 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -8,8 +8,10 @@ * params, then drives a bounded request chain through the `ModelRequester` * resolved from `IModelCatalog`: one primary `requester.request(input, signal, * params)` attempt plus projection rebuilds for request structure or media - * compatibility; general retry policy remains in the loop's `stepRetry` - * plugin. Before each request the projected messages pass through `media`'s + * compatibility and a one-shot completion-budget clamp when the provider + * rejects `max_tokens` (the server-declared ceiling is remembered per model, + * so each model pays for at most one rejected-and-resent request); general + * retry policy remains in the loop's `stepRetry` plugin. Before each request the projected messages pass through `media`'s * video resolver, which rewrites every `kimi-file://` prompt-video reference * to a provider-acceptable part (uploaded `ms://`, inline base64, or a * `