diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..8aaa746e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dsgui-site", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "/Users/zxy/codeproject/ds_project/dsgui-admin"], + "autoPort": true + }, + { + "name": "renderer-live", + "runtimeExecutable": "sleep", + "runtimeArgs": ["86400"], + "port": 5173 + } + ] +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b62f8c88..08d9df01 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible bug or regression in DeepSeek GUI +description: Report a reproducible bug or regression in Kun title: "[Bug] " labels: - bug @@ -7,7 +7,7 @@ body: - type: markdown attributes: value: | - Thanks for helping us improve DeepSeek GUI. + Thanks for helping us improve Kun. Please include enough detail for someone else to reproduce the issue. - type: textarea id: summary @@ -45,7 +45,7 @@ body: - type: input id: version attributes: - label: DeepSeek GUI version + label: Kun version placeholder: e.g. v0.1.0 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 40d7af20..6dfc808e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: mailto:security@deepseek-gui.com about: Please report security issues privately instead of opening a public issue. - name: Contribution guide - url: https://github.com/XingYu-Zhong/DeepSeek-GUI/blob/master/docs/CONTRIBUTING.md + url: https://github.com/KunAgent/Kun/blob/master/docs/CONTRIBUTING.md about: Read the contribution workflow and validation expectations before opening a PR. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 66920eeb..683b80c0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,7 @@ ## Validation +- [ ] I agree that this contribution is submitted under the [Contributor License Agreement](https://github.com/KunAgent/Kun/blob/develop/CLA.md). - [ ] `npm run test` - [ ] `npm run typecheck` - [ ] `npm run build` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 550a5637..134884c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,8 @@ jobs: runs-on: macos-latest needs: prepare env: + KUN_APP_VERSION: ${{ needs.prepare.outputs.version }} + KUN_UPDATE_CHANNEL: stable DEEPSEEK_GUI_APP_VERSION: ${{ needs.prepare.outputs.version }} DEEPSEEK_GUI_UPDATE_CHANNEL: stable RELEASE_CHANNEL: stable @@ -127,9 +129,9 @@ jobs: if-no-files-found: error retention-days: 7 path: | - dist/DeepSeek-GUI-*-mac-*.dmg - dist/DeepSeek-GUI-*-mac-*.zip - dist/DeepSeek-GUI-*-mac-*.blockmap + dist/Kun-*-mac-*.dmg + dist/Kun-*-mac-*.zip + dist/Kun-*-mac-*.blockmap dist/latest-mac.yml build-windows: @@ -137,6 +139,8 @@ jobs: runs-on: windows-latest needs: prepare env: + KUN_APP_VERSION: ${{ needs.prepare.outputs.version }} + KUN_UPDATE_CHANNEL: stable DEEPSEEK_GUI_APP_VERSION: ${{ needs.prepare.outputs.version }} DEEPSEEK_GUI_UPDATE_CHANNEL: stable RELEASE_CHANNEL: stable @@ -167,8 +171,8 @@ jobs: if-no-files-found: error retention-days: 7 path: | - dist/DeepSeek-GUI-*-win-x64.exe - dist/DeepSeek-GUI-*-win-x64.exe.blockmap + dist/Kun-*-win-x64.exe + dist/Kun-*-win-x64.exe.blockmap dist/latest.yml build-linux: @@ -176,6 +180,8 @@ jobs: runs-on: ubuntu-latest needs: prepare env: + KUN_APP_VERSION: ${{ needs.prepare.outputs.version }} + KUN_UPDATE_CHANNEL: stable DEEPSEEK_GUI_APP_VERSION: ${{ needs.prepare.outputs.version }} DEEPSEEK_GUI_UPDATE_CHANNEL: stable RELEASE_CHANNEL: stable @@ -211,8 +217,8 @@ jobs: if-no-files-found: error retention-days: 7 path: | - dist/DeepSeek-GUI-*-linux-x86_64.AppImage - dist/DeepSeek-GUI-*-linux-x86_64.AppImage.blockmap + dist/Kun-*-linux-x86_64.AppImage + dist/Kun-*-linux-x86_64.AppImage.blockmap dist/latest-linux.yml publish: @@ -230,6 +236,7 @@ jobs: RELEASE_NAME: ${{ needs.prepare.outputs.release_name }} PREVIOUS_TAG: ${{ needs.prepare.outputs.previous_tag }} RELEASE_CHANNEL: stable + KUN_UPDATE_CHANNEL: stable DEEPSEEK_GUI_UPDATE_CHANNEL: stable R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} @@ -265,12 +272,12 @@ jobs: run: | set -euo pipefail required=( - "DeepSeek-GUI-*-mac-arm64.dmg" - "DeepSeek-GUI-*-mac-x64.dmg" - "DeepSeek-GUI-*-mac-arm64.zip" - "DeepSeek-GUI-*-mac-x64.zip" - "DeepSeek-GUI-*-win-x64.exe" - "DeepSeek-GUI-*-linux-x86_64.AppImage" + "Kun-*-mac-arm64.dmg" + "Kun-*-mac-x64.dmg" + "Kun-*-mac-arm64.zip" + "Kun-*-mac-x64.zip" + "Kun-*-win-x64.exe" + "Kun-*-linux-x86_64.AppImage" "latest-mac.yml" "latest.yml" "latest-linux.yml" @@ -348,7 +355,7 @@ jobs: set -euo pipefail mapfile -d '' assets < <( find release-artifacts -type f \ - \( -name 'DeepSeek-GUI-*' -o -name 'latest*.yml' \) \ + \( -name 'Kun-*' -o -name 'latest*.yml' \) \ -print0 ) diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000..6f23c904 --- /dev/null +++ b/CLA.md @@ -0,0 +1,85 @@ +# Contributor License Agreement + +Version 1.0 + +This Contributor License Agreement ("Agreement") applies to any contribution +submitted to Kun, including code, documentation, design assets, tests, examples, +configuration, issue text, pull request text, and any other material submitted +for inclusion in the project ("Contribution"). + +By submitting a Contribution, you agree to the terms below. + +## 1. Ownership + +You retain copyright and any other rights you have in your Contribution. +This Agreement does not transfer ownership of your Contribution to the project +owner. + +## 2. Copyright License + +You grant the project owner a perpetual, worldwide, non-exclusive, +irrevocable, royalty-free, sublicensable, transferable, and relicensable +copyright license to use, reproduce, modify, prepare derivative works of, +publicly display, publicly perform, distribute, sublicense, and otherwise +exploit your Contribution, in whole or in part, under any license terms. + +This license includes the right for the project owner to license, sublicense, +or relicense your Contribution as part of Kun or related works under +noncommercial, commercial, proprietary, source-available, open-source, or other +license terms, without needing additional permission from you. + +## 3. Patent License + +If your Contribution is covered by patent claims that you can license, you +grant the project owner a perpetual, worldwide, non-exclusive, irrevocable, +royalty-free, sublicensable, transferable, and relicensable patent license to +make, have made, use, sell, offer for sale, import, and otherwise transfer your +Contribution and derivative works of it. + +## 4. Moral Rights + +To the maximum extent allowed by law, you waive and agree not to assert any +moral rights, author's rights, or similar rights that would interfere with the +project owner's exercise of the licenses granted in this Agreement. + +## 5. Right To Submit + +You represent that: + +- you have the legal right to submit the Contribution and grant these licenses; +- the Contribution is your original work, or you have sufficient rights to + submit it under this Agreement; +- if your employer, client, school, or another party may have rights in the + Contribution, you have received any necessary permission before submitting it; +- the Contribution does not knowingly violate any third-party intellectual + property right, confidentiality obligation, or legal restriction. + +## 6. Project License + +The project owner may make the project available under the license stated in +the repository, currently the PolyForm Noncommercial License 1.0.0, and may +also offer separate commercial licenses or other license terms. + +You understand that commercial licensing decisions for Kun are controlled by +the project owner and do not require additional approval from contributors who +submitted Contributions under this Agreement. + +## 7. No Obligation + +The project owner is not required to accept, use, publish, maintain, or +distribute any Contribution. + +## 8. No Warranty + +You provide your Contribution "as is", without warranties or conditions of any +kind, express or implied, to the maximum extent allowed by law. + +## 9. Not A Contribution + +If you want to submit material that is not covered by this Agreement, clearly +mark it as "Not a Contribution" in writing before or at the time you submit it. + +## 10. Agreement Scope + +This Agreement applies to all Contributions you submit to Kun unless the +project owner agrees in writing to different terms. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 1c9bb069..ec0a27bc 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,7 +2,7 @@ ## Our Pledge -We as contributors and maintainers pledge to make participation in the DeepSeek GUI community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. +We as contributors and maintainers pledge to make participation in the Kun community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. diff --git a/CODE_OF_CONDUCT.zh-CN.md b/CODE_OF_CONDUCT.zh-CN.md index 1f7b8fc2..4b9f9b0e 100644 --- a/CODE_OF_CONDUCT.zh-CN.md +++ b/CODE_OF_CONDUCT.zh-CN.md @@ -2,7 +2,7 @@ ## 我们的承诺 -作为贡献者和维护者,我们承诺让每个人都能在 DeepSeek GUI 社区的参与中获得无骚扰的体验,无论其年龄、体型、明显或不可见的残疾、种族、性别特征、性别认同和表达、经验水平、教育程度、社会经济地位、国籍、个人外表、种族、宗教或性认同和取向。 +作为贡献者和维护者,我们承诺让每个人都能在 Kun 社区的参与中获得无骚扰的体验,无论其年龄、体型、明显或不可见的残疾、种族、性别特征、性别认同和表达、经验水平、教育程度、社会经济地位、国籍、个人外表、种族、宗教或性认同和取向。 我们承诺以有助于建设开放、热情、多元化、包容和健康社区的方式行事和互动。 diff --git a/DESIGN.md b/DESIGN.md index 11924860..1ba1a4e9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5,7 +5,7 @@ # invented. Anything not in this block is editorial, not authoritative. schema_version: 1 -project: DeepSeek-GUI +project: Kun single_runtime: kun themes: [light, dark, system] @@ -224,8 +224,8 @@ window: app_region: drag # html/body/-webkit-app-region no_drag_class: ds-no-drag # add to anything clickable in the title bar macos_top_inset_px: 42 # safe area for traffic-light controls - app_icon: src/asset/img/deepseek.png - secondary_logos: [deepseek.svg] + app_icon: src/asset/img/kun.png + secondary_logos: [kun_mac.png, kun_tray.png] # ---------- 9. Iconography ---------- icons: @@ -286,7 +286,7 @@ i18n: # ---------- 13. Brand & voice ---------- brand: - product_name: "DeepSeek GUI" + product_name: "Kun" tagline: "把 Kun 的本地智能体能力带进桌面窗口" hero_kw: [Code, Write, Connect phone] pillars: @@ -323,7 +323,7 @@ dont: - "Use a border radius smaller than 4px on a clickable surface." --- -# DeepSeek GUI — DESIGN.md +# Kun — DESIGN.md > 单一权威设计文档。所有屏幕、所有组件、所有视觉决策,都从这里出。 @@ -353,8 +353,8 @@ the frontmatter wins, and the markdown needs an update. ## 1. Project at a glance -DeepSeek GUI is a local desktop workbench for the **Kun** -runtime. The desktop shell is Electron; the runtime is a TypeScript +Kun (formerly DeepSeek GUI) is a local desktop workbench built +around its namesake **Kun** runtime. The desktop shell is Electron; the runtime is a TypeScript package that speaks HTTP/SSE; the renderer is React 19 + Zustand 5; the visual system is TailwindCSS 3 with a hand-built token layer on top. @@ -588,7 +588,7 @@ containing many cells. Do not animate the composer. ### 3.9 Layout grammar -Every screen in DeepSeek GUI follows the same macro-grammar: +Every screen in Kun follows the same macro-grammar: - **Topbar**: a translucent strip with the back button, session title, mode switcher, and right-side action cluster. The topbar @@ -619,7 +619,8 @@ first. Write, and Connect phone"), second person for the user. No emoji. No marketing language. Error messages are full sentences ending in punctuation; never a raw stack trace. -- The product name is "DeepSeek GUI". The runtime is "Kun". +- The product name is "Kun" (formerly "DeepSeek GUI"). The bundled + runtime shares the name; say "Kun runtime" when the distinction matters. The main workbenches are "Code" and "Write"; the phone/IM surface is "Connect phone" in English and "连接手机" in zh copy. Internal code may still say `claw`, but production copy should not expose it as the product name. @@ -1118,7 +1119,8 @@ only which renderer and local workflow state the store pulls in. | GUI logs | OS app-data dir / `log/` | NDJSON | `logger.ts` | | Inline completion debug | OS app-data dir | NDJSON | `write-inline-completion-service.ts` | -Default OS app-data paths: +Default OS app-data paths (derived from the Electron `productName`, +which current builds still ship as `DeepSeek GUI`): - macOS: `~/Library/Application Support/DeepSeek GUI` - Windows: `%APPDATA%\DeepSeek GUI` diff --git a/DESIGN.zh-CN.md b/DESIGN.zh-CN.md index 34d8c0f6..8ccd3893 100644 --- a/DESIGN.zh-CN.md +++ b/DESIGN.zh-CN.md @@ -5,7 +5,7 @@ # invented. Anything not in this block is editorial, not authoritative. schema_version: 1 -project: DeepSeek-GUI +project: Kun single_runtime: kun themes: [light, dark, system] @@ -224,8 +224,8 @@ window: app_region: drag # html/body/-webkit-app-region no_drag_class: ds-no-drag # add to anything clickable in the title bar macos_top_inset_px: 42 # safe area for traffic-light controls - app_icon: src/asset/img/deepseek.png - secondary_logos: [deepseek.svg] + app_icon: src/asset/img/kun.png + secondary_logos: [kun_mac.png, kun_tray.png] # ---------- 9. Iconography ---------- icons: @@ -286,7 +286,7 @@ i18n: # ---------- 13. Brand & voice ---------- brand: - product_name: "DeepSeek GUI" + product_name: "Kun" tagline: "把 Kun 的本地智能体能力带进桌面窗口" hero_kw: [Code, Write, Connect phone] pillars: @@ -323,7 +323,7 @@ dont: - "Use a border radius smaller than 4px on a clickable surface." --- -# DeepSeek GUI — DESIGN.md +# Kun — DESIGN.md > 单一权威设计文档。所有屏幕、所有组件、所有视觉决策,都从这里出。 @@ -353,8 +353,8 @@ the frontmatter wins, and the markdown needs an update. ## 1. Project at a glance -DeepSeek GUI is a local desktop workbench for the **Kun** -runtime. The desktop shell is Electron; the runtime is a TypeScript +Kun (formerly DeepSeek GUI) is a local desktop workbench built +around its namesake **Kun** runtime. The desktop shell is Electron; the runtime is a TypeScript package that speaks HTTP/SSE; the renderer is React 19 + Zustand 5; the visual system is TailwindCSS 3 with a hand-built token layer on top. @@ -588,7 +588,7 @@ containing many cells. Do not animate the composer. ### 3.9 Layout grammar -Every screen in DeepSeek GUI follows the same macro-grammar: +Every screen in Kun follows the same macro-grammar: - **Topbar**: a translucent strip with the back button, session title, mode switcher, and right-side action cluster. The topbar @@ -619,7 +619,8 @@ first. Write, and Connect phone"), second person for the user. No emoji. No marketing language. Error messages are full sentences ending in punctuation; never a raw stack trace. -- The product name is "DeepSeek GUI". The runtime is "Kun". +- The product name is "Kun" (formerly "DeepSeek GUI"). The bundled + runtime shares the name; say "Kun runtime" when the distinction matters. The main workbenches are "Code" and "Write"; the phone/IM surface is "Connect phone" in English and "连接手机" in zh copy. Internal code may still say `claw`, but production copy should not expose it as the product name. @@ -1118,7 +1119,8 @@ only which renderer and local workflow state the store pulls in. | GUI logs | OS app-data dir / `log/` | NDJSON | `logger.ts` | | Inline completion debug | OS app-data dir | NDJSON | `write-inline-completion-service.ts` | -Default OS app-data paths: +Default OS app-data paths (derived from the Electron `productName`, +which current builds still ship as `DeepSeek GUI`): - macOS: `~/Library/Application Support/DeepSeek GUI` - Windows: `%APPDATA%\DeepSeek GUI` diff --git a/LICENSE b/LICENSE index d688a63d..52c91ecb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,134 @@ -MIT License - -Copyright (c) 2026 xingyu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +PolyForm Noncommercial License 1.0.0 + +Required Notice: Copyright (c) 2026 xingyu + +Kun is available for noncommercial use under the PolyForm Noncommercial +License 1.0.0. Commercial use, commercial distribution, SaaS or hosted +service use, resale, or integration into commercial products requires a +separate written commercial license from the copyright holder. + +Educational institutions and public-interest educational organizations may use +Kun for noncommercial teaching, research, coursework, experiments, and +learning/reference purposes without requesting separate authorization. This +permission is not pass-through: it may not be extended to downstream customers, +partners, commercial projects, hosted services, redistribution, sublicensing, +or use as part of any commercial product or commercial service. + +https://polyformproject.org/licenses/noncommercial/1.0.0 + +## Acceptance + +In order to get any license under these terms, you must agree to them +as both strict obligations and conditions to all your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software to do +everything you might do with the software that would otherwise infringe +the licensor's copyright in it for any permitted purpose. However, you +may only distribute the software according to Distribution License and +make changes or new works based on the software according to Changes and +New Works License. + +## Distribution License + +The licensor grants you an additional copyright license to distribute +copies of the software. Your license to distribute covers distributing +the software with changes and new works permitted by Changes and New +Works License. + +## Notices + +You must ensure that anyone who gets a copy of any part of the software +from you also gets a copy of these terms or the URL for them above, as +well as copies of any plain-text lines beginning with `Required Notice:` +that the licensor provided with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to make changes +and new works based on the software for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that covers +patent claims the licensor can license, or becomes able to license, that +you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the benefit of +public knowledge, personal study, private entertainment, hobby projects, +amateur pursuits, or religious observance, without any anticipated +commercial application, is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, public +research organization, public safety or health organization, +environmental protection organization, or government institution is use +for a permitted purpose regardless of the source of funding or +obligations resulting from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the law. These +terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your +licenses to anyone else, or prevent the licensor from granting licenses +to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes +to infringement of any patent, your patent license for the software +granted under these terms ends immediately. If your company makes such a +claim, your patent license ends immediately for work on behalf of your +company. + +## Violations + +The first time you are notified in writing that you have violated any of +these terms, or done anything with the software not covered by your +licenses, your licenses can nonetheless continue if you come into full +compliance with these terms, and take practical steps to correct past +violations, within 32 days of receiving notice. Otherwise, all your +licenses end immediately. + +## No Liability + +As far as the law allows, the software comes as is, without any warranty +or condition, and the licensor will not be liable to you for any damages +arising out of these terms or the use or nature of the software, under +any kind of legal claim. + +## Definitions + +The licensor is the individual or entity offering these terms, and the +software is the software the licensor makes available under these terms. + +You refers to the individual or entity agreeing to these terms. + +Your company is any legal entity, sole proprietorship, or other kind of +organization that you work for, plus all organizations that have control +over, are under the control of, or are under common control with that +organization. Control means ownership of substantially all the assets of +an entity, or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +Your licenses are all the licenses granted to you for the software under +these terms. + +Use means anything you do with the software requiring one of your +licenses. + +© PolyForm Project Inc. diff --git a/README.en.md b/README.en.md index effc68af..439e7955 100644 --- a/README.en.md +++ b/README.en.md @@ -1,442 +1,228 @@

- DeepSeek GUI icon + Kun icon

-# DeepSeek GUI +

Kun

-[简体中文](./README.md) | English +

+ An experiment in requirement-first coding for the next programming paradigm.
+ Use DeepSeek, Xiaomi MiMo, and MiniMax to connect requirement clarification, design drafts, plans, and agent coding into one loop. +

+ +

+ 简体中文 +  ·  + English +  ·  + Download +  ·  + Docs +  ·  + Run from source +

-> Bring Kun's high-token-ROI local agent runtime into a desktop workbench: **Code** for project work, **Write** for documents, and **Connect phone** for IM automation and scheduled tasks. Every token is steered toward requirements, code, decisions, and results. +

+ GitHub release + License: PolyForm Noncommercial 1.0.0 + Platform + Electron 34 + React 19 +

-[Website](https://deepseek-gui.com) | [Download](https://deepseek-gui.com) +Kun is a product experiment for the future of programming: instead of starting from “ask the agent to edit code,” it starts from requirement clarification and connects requirement documents, design drafts, interactive prototypes, implementation plans, todos, agent coding, and change review in one GUI workflow. -[![GitHub release](https://img.shields.io/github/v/release/XingYu-Zhong/DeepSeek-GUI?label=github)](https://github.com/XingYu-Zhong/DeepSeek-GUI/releases) -[![License](https://img.shields.io/github/license/XingYu-Zhong/DeepSeek-GUI)](./LICENSE) +Kun is for users who want to put AI agents into real everyday work. It is not just a chat client, and it is not only a CLI shell for programmers: you can hand it a local folder for code, requirements, plans, and change review, or use the dedicated Write workspace for long-form Markdown, editing, and document export. -DeepSeek GUI is a local desktop workbench for developers and frequent AI users. It uses Kun as the only runtime and turns the terminal agent experience into an easier, longer-lived app: choose a workspace, start a task, watch reasoning and tool calls stream in, review file changes, and approve sensitive actions when needed. +This is also why Kun treats DeepSeek, Xiaomi MiMo, and MiniMax as the default first-class model stack, not just ordinary optional providers. Requirement-first coding requires more rounds of clarification, research, structuring, planning, execution, and verification. If model cost is too high, that richer workflow cannot become an everyday habit. Kun chooses three cost-efficient Chinese model providers so the full loop is affordable to run, repeat, and refine. -The goal is not to ship another chat wrapper. The goal is to make DeepSeek feel like a reliable desktop partner for real project work. Kun's core advantage is high token ROI: the same context budget spends less on repeated prefixes, giant tool catalogs, and runaway output, and more on the information that actually moves the task forward. +Kun includes the local `kun serve` runtime for the desktop app. Preferences, sessions, logs, and runtime config stay on your machine; model calls use your own provider credentials. For workflows that can read/write files or run commands, Kun gives you tool approvals, filesystem permission modes, inline diffs, and a change-review panel. ---

- DeepSeek GUI Code mode demo + Kun Code mode demo - DeepSeek GUI Write mode demo - -

- -## More Demos - -

- - Feishu / Lark / WeChat connection demo - -

-

Feishu / Lark / WeChat connection demo.

- -

- - Requirement drafting and planning demo + Kun Write mode demo

-

Requirement drafting and planning demo.

-

- - Web tools demo - -

-

Web tools demo.

+## Requirement-First Coding -## Why Kun Delivers High Token ROI +Kun explores a next-generation programming workflow: **requirement -> design -> plan -> code -> verify**. It is not just a chat box attached to an IDE. -Kun makes token economy the default behavior of the agent loop, not a cleanup step after the fact. It does more than compress text: before each model call, it decides which information is worth entering context. - -| Kun advantage | Where the ROI comes from | +| Stage | Kun's approach | | --- | --- | -| **Cache-first agent loop** | Stable system prompts, tool schemas, and immutable prefixes make DeepSeek-native cache hits more likely, so long sessions do not keep paying for the same background. | -| **Tool context on demand** | When MCP catalogs are large, Kun can search for relevant tools first, then describe and call the target tool instead of sending every tool schema on every turn. | -| **Context hygiene** | Long tool results, long arguments, base64 payloads, repeated tool loops, and low-value history are bounded while code, paths, errors, decisions, and open tasks are preserved. | -| **Visible usage payback** | Runtime telemetry tracks cache hit/miss, token usage, and estimated savings; the GUI surfaces Token economy savings so cost return is observable over time. | - -The result: Kun is built for real project work with long tasks, long sessions, and many tools. It keeps the model's attention on high-value context, helping the same API budget produce more useful progress. - -## What We Built - -- A desktop app around the Kun local runtime, with default runtime auto-start and management. -- A full chat workbench with multiple sessions, streaming output, history, interruption, and resend flows. -- Local workspace integration so the agent can read, edit, and create files in real projects. -- Change review surfaces that make every file modification visible and inspectable. -- First-run onboarding, settings, language/theme/font controls, notifications, local logs, and update entry points. -- Graphical Skill and MCP management so users can extend the agent without hand-editing every config file. -- Connect phone automation with Feishu / Lark / WeChat integration, dedicated IM agents, local webhook / relay support, and scheduled tasks. -- A dedicated Write workbench with writing spaces, a Markdown file tree, live editing/preview, inline completion, and selection-based inline agent actions. -- New requirement drafts, plans, thread todos, long-running goals, and code review so tasks can move from idea to execution to review. -- Pre-built macOS, Windows, and Linux installers; source builds remain available. - -## Highlights - -- **Desktop chat workbench**: multi-session chat with streamed replies, reasoning, tool calls, approval requests, and file changes in one place. -- **Project workspaces**: choose a local directory for each task, organize sessions by workspace, preview files, open files in your editor, and pick Git branches. -- **New requirements**: draft background, goals, and acceptance criteria; ask Requirement AI to clarify missing questions or research options; then generate an implementation plan. -- **Plans and todos**: `/plan` and New requirement both create editable plan files, while the right-side Plan panel syncs thread todos for trackable execution. -- **Goals**: `/goal` sets a long-running objective for the current thread, with pause, resume, clear, and complete states so the agent can keep working toward the same outcome. -- **Code review**: `/review` can inspect current uncommitted changes, a base branch diff, a commit, or custom review instructions, with findings shown as review cards. -- **Side conversations and thread control**: `/btw` opens a context-inheriting side conversation; threads also support compact, fork, archive, and restore flows. -- **Change review**: inline diffs and a side review panel help you understand exactly what the agent changed. -- **Controlled permissions**: choose read-only, workspace-write, full-access, or external sandbox modes, and decide when tool calls require approval. -- **Managed runtime**: use the bundled Kun by default, or point the app at your own `kun` executable. -- **Skill and MCP support**: create Skills, edit MCP config, add common tools, and open the related folders from the UI. -- **Feature-flagged agent extensions**: Kun can enable MCP, web fetch/search, Skills, standalone CLI use, image attachments, cross-session memory, and delegated subagents by config; Settings shows the runtime-reported capability and diagnostics state. -- **Connect phone**: run a background agent alongside normal chat, with current support for Feishu / Lark / WeChat, IM webhook / relay flows, and scheduled tasks. -- **Scheduled tasks**: create one-time, daily, interval, or manual tasks with their own workspace, model, and reasoning effort so Kun can run while the computer is awake. -- **Write mode**: manage `~/.deepseekgui/write_workspace` and custom writing spaces, browse Markdown files, use live Markdown editing, preview relative images, get DeepSeek FIM short completion / inspiration completion with optional cross-document BM25 + keyword retrieval, export the current document as `HTML / PDF / DOC / DOCX`, and invoke the writing assistant directly from selected text. -- **High token ROI**: Kun keeps prompt prefixes stable, tracks DeepSeek-native cache hit/miss fields, compacts context and tool output, and uses MCP search to discover tools progressively so tokens stay focused on requirements, code, decisions, and results. -- **Friendly first launch**: choose language, add your DeepSeek API key, and optionally set a compatible Base URL. -- **Local-first**: preferences, sessions, logs, and runtime config stay on your machine; model calls use your own DeepSeek API key. -- **English and Chinese UI**: switch languages from Settings at any time. -- **Cross-platform use**: macOS `.dmg/.zip`, Windows `.exe`, and Linux `.AppImage`; source builds remain available. - -## Runtime: Kun - -The only active local agent runtime in DeepSeek-GUI today is -**Kun** (shipped under `kun/`), a self-contained -TypeScript package that boots a local HTTP/SSE server as the -single boundary between the GUI and the agent loop. - -The name Kun is inspired by the great fish in Zhuangzi's line, -"In the northern sea there is a fish; its name is Kun." The idea is -not a temporary chat shell, but a deeper local runtime that can carry -longer context, richer tools, and sustained project collaboration. - -Kun's operating principle is to raise the ROI of every token. The -user's context budget should go toward requirements, code, decisions, -and results, not repeated tool schemas, runaway tool output, invalid -history, or prefixes that could have been reused from cache. It is -optimized less for one-off questions and more for real workflows that -read and write projects, call tools repeatedly, and carry context over -long sessions. - -Kun fuses a design that has been battle-tested in the -wild: - -- **The cache-first agent loop borrowed from Reasonix**: immutable prompt prefix (with sha256 fingerprint), append-only session log, bounded TTL/LRU cache, inflight tracking with guaranteed cleanup, mid-turn steering queue, context compaction that preserves pinned constraints, and cache/usage telemetry. -- **Token economy and tool-context optimization**: Kun stabilizes system prompts and tool schemas, reads DeepSeek-native cache hit/miss fields, bounds long tool results, long arguments, base64 payloads, and repeated tool loops, and can use `mcp_search` / `mcp_describe` / `mcp_call` to discover MCP tools progressively when a tool catalog is too large to advertise all at once. - -> Thanks to the Reasonix team for sharing the runnable references -> that made this design pillar testable in the first place. Nearly -> every performance trait of Kun — cache hit rate, token replay, -> reconnect, and interruptable approvals — can be traced back to -> this project. The full design rationale -> and the borrow map live in -> [`docs/kun-architecture.md`](docs/kun-architecture.md). - -If you want the dedicated write-up for cache behavior, including -stable prefixes, tool schema canonicalization, DeepSeek native -hit/miss accounting, tool-pair healing, and validation strategy, see -[`docs/kun-cache-optimization.md`](docs/kun-cache-optimization.md). - -Kun's larger agent capabilities are controlled by feature flags: -`capabilities.mcp` connects third-party MCP servers, -`capabilities.web` exposes `web_fetch` / `web_search`, -`capabilities.skills` discovers `skill.json` and legacy `SKILL.md`, -`capabilities.attachments` enables image attachments with text-model fallback, `capabilities.memory` -enables cross-session recall, and `capabilities.subagents` allows -budgeted delegated child runs. `kun run`, `kun chat`, and `kun exec` -can run without the GUI. The GUI reads `/v1/runtime/info` and -`/v1/runtime/tools` in Settings to show what is actually available. -These capabilities are off by config or limited by model capability -until explicitly enabled; examples and troubleshooting live in -[`kun/README.md`](kun/README.md). - -Simplified architecture: - -```text -Renderer (React) - → KunRuntimeProvider - → preload: dsGui.runtimeRequest / startSse - → main: LocalHttpRuntimeAdapter - → kun serve (HTTP + SSE) - → cache-first AgentLoop -``` - -Settings live under **Settings → Agent runtime**: binary path, port, -auto-start, API key, base URL, runtime token, data dir, model, -approval policy, sandbox mode, and the insecure switch. If an older -provider was saved before, settings are migrated into -`agents.kun` on load; after saving, only Kun settings -remain. +| **Clarify** | Create requirement drafts in the GUI and ask Requirement AI to find missing questions, research options, and shape boundaries | +| **Document** | Save drafts as `.kunsdd/draft/.../requirement.md`, with structured requirement blocks, acceptance criteria, and requirement history | +| **Design** | Generate UI design drafts, infographics, or interactive HTML prototypes from requirement selections, so requirements become more than text | +| **Plan** | Use `/plan` and `create_plan` to produce GUI-owned `.kunsdd/plan/...` implementation plans linked back to requirements | +| **Code** | Move from plan into todos, file edits, command execution, and change review; when requirements change, Kun can surface affected replanning | +| **Verify** | Bring requirement blocks, acceptance criteria, plan state, and `/review` back together to answer whether the original requirement is done | -The full endpoint list, CLI flags, environment variables, data dir -layout, and SSE event schema are documented in -[`kun/README.md`](kun/README.md). +This is Kun's most important product direction: moving AI coding from instant Q&A into a requirement-driven software production workflow. Models, writing, planning, review, and automation all serve that line. -## Who It Is For +## Core Model Stack -- Developers who want DeepSeek to work on real codebases without living in a terminal. -- Teams that need to see what the agent did, which files changed, and which operations required approval. -- Users who maintain multiple projects or long-running conversations and want reusable Skill/MCP setup. -- Anyone who wants a local desktop workbench connected to the official DeepSeek API or a compatible endpoint. +Kun optimizes for **complete capability + extreme cost efficiency**. A requirement-first workflow is longer than ordinary chat and depends on repeated model calls; first-run setup and provider settings are organized around three Chinese model providers so users can cover more agent scenarios with lower model cost. ---- +| Provider | Role in Kun | +| --- | --- | +| **DeepSeek** | Default text and reasoning provider with `deepseek-v4-pro` / `deepseek-v4-flash`, powering coding, planning, review, long-context sessions, and auto model routing | +| **Xiaomi MiMo** | Cost-efficient multimodal and speech entry point, covering long-context text models, vision input, ASR transcription, TTS generation, and Token Plan | +| **MiniMax** | Full media generation complement, covering Anthropic Messages text models, image generation, speech generation, music generation, video generation, and Token Plan | -## Workbench And Entry Points +This stack lets Kun route different jobs to the right capability: fast models for lightweight clarification, stronger models for complex coding and reasoning, speech for writing and IM flows, and image/music/video generation for design and creative work. You can still add OpenAI-compatible, self-hosted, or other custom providers, but Kun's default experience is built around these three cost-efficient model services. -DeepSeek GUI is centered on two main workbenches, **Code** and **Write**, -with additional entry points for **Connect phone**, **Scheduled tasks**, -and **Plugins / Skills / MCP**. They share the same Kun runtime and -settings, but keep sessions, workspaces, and layouts separate so you -can switch by task. +## Why Kun -### Code Mode +| You want | Kun provides | +| --- | --- | +| A next-generation coding workflow | Requirement clarification, requirement documents, design drafts, implementation plans, agent coding, and verification in one line | +| Complete agent capability at extreme cost efficiency | DeepSeek, Xiaomi MiMo, and MiniMax as the core stack for text, reasoning, vision, speech, image, music, and video | +| AI that works on real projects | Bind a local workspace, read and edit files, search code, run commands, and inspect tool calls and results | +| Requirements that become executable plans | New requirements, `/plan`, todos, `/goal`, side conversations, thread compaction, forking, and archiving | +| Controlled changes | Tool approvals, filesystem permission modes, inline diffs, a change-review panel, and `/review` | +| Writing in the same app | Markdown file tree, Live / Source / Split / Preview, export formats, and selection-based inline agent actions | +| Remote or background triggers | Feishu / Lark / WeChat connection, local webhook / relay, and one-time or recurring scheduled tasks | +| More than one model vendor | Custom Base URLs, protocols, model lists, and capability extensions beyond the three core providers | + +## Core Features + +- **Requirement-first coding**: draft requirements, clarify and structure them with AI, generate design drafts or prototypes, then move into implementation plans, todos, agent coding, and verification. +- **Code workbench**: bind a local project folder, chat around real codebases, read and edit files, run commands, and inspect tool calls and file changes. +- **Planning and review**: new requirements, `/plan`, todos, `/goal`, `/review`, side conversations, thread compaction, forking, and archiving. +- **Controlled changes**: inline diffs, a change-review panel, tool approvals, and filesystem permission modes. +- **Write mode**: dedicated Markdown workspaces with a file tree, Live / Source / Split / Preview modes, completion, selection-based inline agent actions, and `HTML / PDF / DOC / DOCX` export. +- **Connect phone**: Feishu / Lark / WeChat IM agents, local webhook / relay support, and one-time, daily, interval, or manual scheduled tasks. +- **Model-stack-first**: first-run setup, provider presets, and capability auto-wiring are designed around DeepSeek, Xiaomi MiMo, and MiniMax as a cost-efficient full agent stack. +- **Multimodal and media capabilities**: image attachments, vision input, speech transcription, image generation, speech generation, music generation, and video generation, enabled by provider configuration. +- **MCP and Skills**: Model Context Protocol servers and project/global Skills give Kun specialized tools and workflows for different tasks. +- **Local runtime**: `kun serve` provides the HTTP/SSE boundary with a cache-first agent loop, append-only event logs, usage tracking, and context compaction. -The development workbench for real codebases: bind a local project directory, read and edit files, run commands, and review changes. +## More Demos

- DeepSeek GUI Code mode + + PDF research demo +

- -- Organize multiple agent sessions by workspace, with streamed reasoning, tool calls, and file changes in one view. -- Inline diffs, a change-review panel, and permission modes from read-only to full access. -- New requirement drafts, `/plan`, the right-side Plan panel, thread todos, and `/goal` help complex work move from clarification to planning to execution. -- `/review`, `/btw`, thread compaction, thread forking, archive, and restore support longer-lived project conversations. -- Quick-start cards for common tasks such as project mapping, bug fixing, implementation planning, and UI polish. - -### Write Mode - -A dedicated Markdown writing workbench that keeps writing files, save state, and AI assistance separate from Code sessions. +

PDF research and source organization demo

- DeepSeek GUI Write mode + + Requirement clarification, requirement documents, and planning demo +

- -- Manage `~/.deepseekgui/write_workspace` plus custom writing spaces from the left file tree. -- Switch between **Live / Source / Split / Preview**; Live keeps Markdown source on the active line and renders the rest. -- Export the current Markdown document from the toolbar as `HTML / PDF / DOC / DOCX`, with best-effort preservation for headings, lists, code blocks, tables, and local images. -- DeepSeek FIM short and inspiration completion, plus selection-based inline agent actions and a right-side writing assistant for summaries, outlines, and polish. - -### Connect Phone - -Background automation and IM integration, so Kun can keep handling phone messages and scheduled jobs outside normal desktop chat. +

Requirement clarification, requirement documents, and planning demo

- DeepSeek GUI Connect phone + + iKun UI plugin demo +

+

iKun UI plugin demo

-- Configure dedicated agents for Feishu / Lark / WeChat and other channels, each with its own profile, default model, and workspace. -- Every IM agent gets its own thread, so you can debug replies and tool calls directly in the GUI. -- Local webhook / relay support for team workflows and personal automation. -- Scheduled tasks can run once, daily, on an interval, or manually. Each task creates a dedicated Kun thread and sends its configured prompt. +## Quick Start ---- +### Path A: Download a Release -## Install +Download the latest build from [GitHub Releases](https://github.com/KunAgent/Kun/releases). -### Download a Pre-built Package +| Platform | Package | Architecture | +| --- | --- | --- | +| macOS | `.dmg` or `.zip` | Intel / Apple Silicon | +| Windows | `.exe`, NSIS installer | x64 | +| Linux | `.AppImage` | x64 | -Download the latest build from [GitHub Releases](https://github.com/XingYu-Zhong/DeepSeek-GUI/releases): +On first launch: -| Platform | Package | -| --- | --- | -| macOS | `.dmg` or `.zip`, Intel and Apple Silicon | -| Windows | `.exe`, NSIS installer, x64 | -| Linux | `.AppImage`, x64 | +1. Choose a UI language. +2. Choose a model provider and enter an API key or Token Plan key. +3. For compatible providers, edit the Base URL, protocol, and model list in Settings. +4. Open Code and bind a local project, or open Write and create a writing workspace. -On first launch, enter your [DeepSeek API key](https://platform.deepseek.com/api_keys). If you use a DeepSeek/OpenAI-compatible endpoint, you can set a custom Base URL in Settings. +### Path B: Run From Source -### Run from Source +Requirements: -For contributors and local development: +| Dependency | Version | +| --- | --- | +| Node.js | 20+ | +| npm | Ships with Node.js | +| Model credentials | At least one of DeepSeek / Xiaomi MiMo / MiniMax / custom provider | ```bash -git clone https://github.com/XingYu-Zhong/DeepSeek-GUI.git -cd DeepSeek-GUI +git clone https://github.com/KunAgent/Kun.git +cd Kun npm install npm run dev ``` -Requirements: - -- Node.js 20+ -- A DeepSeek API key -- Internet access during the first dependency install - For slower network access in mainland China, use an npm mirror: ```bash npm install --registry=https://registry.npmmirror.com ``` ---- - -## First Run - -1. Open DeepSeek GUI. -2. Choose your interface language in the onboarding guide. -3. Enter your DeepSeek API key; set a custom Base URL if needed. -4. Choose a default workspace, or use the default directory created by the app. -5. Start a new session and describe the task you want the agent to handle. +## Common Commands -Typical flow (**Code mode**): - -- Pick or switch a workspace from the sidebar. -- Describe the task in the composer. -- Watch reasoning, tool calls, command execution, and file changes as they happen. -- Allow or deny actions that require approval. -- Inspect changes in the review panel before deciding what to do next. - -See [Workbench And Entry Points](#workbench-and-entry-points) above for Connect phone and Write details. Quick start: - -- **Connect phone**: enable background automation in Settings → add a Feishu / Lark / WeChat connection → configure agent profile, model, and workspace → optionally enable webhook / relay or scheduled tasks. -- **Write**: switch to Write mode → use the default writing space or add a new one → write in the Live editor with completion, selection inline agent, and the right-side writing assistant. - -## Usage and Settings - -Settings manages: - -- DeepSeek API key, Base URL, runtime port, and runtime token. -- Auto-start for the local runtime, plus optional custom `deepseek` path. -- Tool approval policy and filesystem access mode. -- Default workspace, language, theme, font size, and completion notifications. -- GUI updates and local error logs. -- Skill creation, Skill folders, and MCP config editing. -- Connect phone automation, Feishu / Lark / WeChat connections, webhook / relay settings, and scheduled tasks. - -Keyboard shortcuts: - -| Key | Action | +| Command | Description | | --- | --- | -| `Enter` | Send message | -| `Shift+Enter` | Newline in composer | -| `Ctrl+Enter` | Send message | -| `Esc` | Close a panel or dismiss the current overlay | - -## Write Mode Design Notes - -Write mode extends DeepSeek GUI from a code/chat workbench into a long-form writing workspace. Its implementation borrows several ideas from the local `openhanako` reference project: - -- Markdown live editing: openhanako inspired the CodeMirror decorations approach where the active line stays editable as Markdown source while inactive lines render headings, tasks, images, dividers, and tables through widgets. -- Selection inline agent: openhanako inspired the selection-capture and floating-input interaction, so selected text can be sent with file path, line numbers, and bounded original text as structured context. -- AI session isolation: Write uses Kun threads, but the GUI keeps a local write thread registry per writing space so write conversations do not pollute Code / Connect phone sidebars. -- Text completion: writing completion bypasses the local Kun serve runtime (**Kun** is the bundled local HTTP/SSE agent runtime, the single boundary between the GUI and the agent loop — see the [Runtime: Kun](#runtime-kun) section above for details) and calls the DeepSeek FIM Completion API directly for low-latency ghost text. Short completion uses a short debounce, small token budget, and strict local filtering; inspiration completion uses a longer pause, larger token budget, and only runs at line ends or paragraph boundaries. Before completion, the app builds a short-TTL lightweight index over Markdown / text files in the writing space, retrieves cross-document snippets with BM25 + keyword matching, and injects them as a hidden Markdown comment so terminology, facts, and style stay consistent. - ---- - -## Uninstall - -### Windows - -- Open Settings -> Apps -> Installed apps, find `DeepSeek GUI`, and uninstall it. -- Or uninstall from Control Panel -> Programs and Features. -- Or run the uninstaller from the installation directory. - -The Windows installer creates Start Menu and desktop shortcuts by default. It does not force a taskbar pin; pin it manually from the Start Menu if you want one. - -### macOS +| `npm run dev` | Build the Kun runtime and start the Electron dev app | +| `npm run build` | Production build | +| `npm run typecheck` | TypeScript type checking | +| `npm run lint` | ESLint checks | +| `npm run test` | Vitest tests | +| `npm run dist:mac` | Build macOS `.dmg` and `.zip` | +| `npm run dist:win` | Build the Windows NSIS installer | +| `npm run dist:linux` | Build the Linux AppImage | -- Move `DeepSeek GUI.app` from Applications to Trash. -- If macOS blocks the app on first open, right-click it in Finder and choose Open. -- For local unsigned builds, you can remove the quarantine attribute first: +## Configuration and Data -```bash -npm run mac:unquarantine -- '/Applications/DeepSeek GUI.app' -``` - -### Linux +- Preferences, sessions, logs, runtime config, and local runtime data stay on your machine by default. +- Model calls use the provider credentials you configure; provider presets are editable starting points. +- Code / Write / Connect Phone share the same `kun` runtime boundary for sessions, approvals, tools, and usage tracking. +- File writes, command execution, MCP tools, and media generation are governed by permissions and configuration. -- If you built a Linux package from source, delete the related `.AppImage` or installed files. -- If you manually created a desktop entry or shortcut, delete that too. +## Documentation Map -### Remove Local Data - -By default, uninstalling removes the app but keeps local settings, sessions, and runtime config so reinstalling is smoother. For a full cleanup, remove these paths if needed: - -| Platform | App data path | +| Doc | Contents | | --- | --- | -| macOS | `~/Library/Application Support/DeepSeek GUI` | -| Windows | `%APPDATA%\DeepSeek GUI` | -| Linux | `~/.config/DeepSeek GUI` | - -Kun data lives under `~/.deepseekgui/kun` or the configured Kun data dir. Check it before deleting, because it may contain sessions, MCP, or Skill settings you still need. - ---- - -## Updates - -- For regular users: check GUI updates in Settings or download the latest installer from [GitHub Releases](https://github.com/XingYu-Zhong/DeepSeek-GUI/releases). +| [kun/README.md](kun/README.md) | Kun runtime, CLI, environment variables, HTTP API | +| [docs/kun-architecture.en.md](docs/kun-architecture.en.md) | Runtime architecture and GUI integration | +| [docs/kun-cache-optimization.en.md](docs/kun-cache-optimization.en.md) | Cache optimization and token economy | +| [docs/model-provider-presets.md](docs/model-provider-presets.md) | Model provider presets | +| [docs/CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | Contribution guide | +| [docs/DEVELOPMENT.en.md](docs/DEVELOPMENT.en.md) | Local development workflow | +| [SECURITY.md](SECURITY.md) | Security disclosure policy | ## Contributing -Contributions are welcome for bug fixes, UI/UX improvements, documentation, localization, build/release workflows, and runtime integration. +Bug fixes, UI/UX improvements, documentation, localization, build/release work, and runtime integration contributions are welcome. Project conventions: -- Day-to-day collaboration and integration happens on `develop`; stable releases land on `master`. -- Start features and fixes from the latest `develop`, preferably on a short-lived feature branch. -- Open pull requests into `develop` by default; maintainers merge reviewed changes into `master` for release. -- Align on scope first for larger or riskier changes. -- Run `npm run typecheck`, `npm run build`, and `npm run test` before opening a PR. -- Include a video or GIF when the UI changes. -- Include unit tests when project logic changes. -- Update both `README.md` and `README.en.md` when usage changes. - -See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) and [DEVELOPMENT.md](./docs/DEVELOPMENT.md) for details. - -## Local Build - -```bash -npm run build # production build -npm run dist:mac # macOS packages -npm run dist:win # Windows installer (run on Windows) -npm run dist:linux # Linux AppImage -npm run release:mac # manual fallback for macOS release assets -npm run release:win # manual fallback for Windows release assets -``` - -For the full development workflow, see [DEVELOPMENT.md](./docs/DEVELOPMENT.md). - -## Documentation - -| Doc | Contents | -| --- | --- | -| [docs/kun-architecture.en.md](docs/kun-architecture.en.md) | Single-Kun runtime plan, GUI removal scope, HTTP/SSE contract, and legacy agent retirement notes | -| [docs/kun-cache-optimization.en.md](docs/kun-cache-optimization.en.md) | Kun cache optimization, token economy, MCP search, tool-output compaction, and usage savings | -| [docs/kun-contributing.en.md](docs/kun-contributing.en.md) | Kun contribution guide: hexagonal architecture, design patterns (Ports & Adapters / Functional Core Imperative Shell / event sourcing / explicit DI / composition root), four typical PR scenarios | -| [kun/README.md](kun/README.md) | Kun package: CLI, env, data dir, HTTP API | -| [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | Contribution guide | -| [DEVELOPMENT.en.md](docs/DEVELOPMENT.en.md) | Local development workflow | -| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Community code of conduct | -| [SECURITY.md](SECURITY.md) | Security disclosure policy | - ---- +- Day-to-day integration happens on `develop`; stable releases land on `master`. +- Open pull requests into `develop` by default. +- Before opening a PR, run `npm run typecheck`, `npm run build`, and `npm run test` when possible. +- External contributions require acceptance of the [Contributor License Agreement](./CLA.md). ## Thanks -Kun stands on the shoulders of prior projects: - -- **Reasonix** — the cache-first agent loop. `ImmutablePrefix` (with sha256 fingerprint) and its explicit mutation API, `AppendOnlySessionLog` (in-memory window + JSONL on disk), `LruCache` / `TtlLruCache`, `InflightTracker` with `finally`-block cleanup, `SteeringQueue` for mid-turn user guidance, `ContextCompactor` that preserves pinned constraints, and `UsageCounter` + `CacheTelemetry` are direct TypeScript ports and refinements of Reasonix's design prototypes. Reasonix's split between reasoning events and assistant text, the `tool_call` / `tool_result` pairing via `callId`, and the usage replay pattern also flow directly into the Kun event contract. - -We are also grateful to: +Thanks to [LobsterAI](https://github.com/netease-youdao/LobsterAI), DeepSeek, Xiaomi MiMo, MiniMax, and everyone who contributes issues, ideas, code, and documentation. -- **[LobsterAI](https://github.com/netease-youdao/LobsterAI)**: its IM management, QR binding, agent binding, and customizable agent-profile flows inspired the Connect phone integration in this project. -- **OpenHanako**: its Markdown live editing, writing-space, and selection inline-agent patterns heavily informed Write mode. -- **[DeepSeek](https://github.com/deepseek-ai)**: for the models and API. -- Everyone who contributes issues, ideas, code, and documentation to DeepSeek GUI. - - - + + -> [!NOTE] -> This project is not affiliated with DeepSeek Inc. - ## License -[MIT](./LICENSE) +This project is provided for learning and reference only and may not be used for any commercial purpose. Commercial use, commercial distribution, SaaS/hosted services, resale, or integration into commercial products requires separate written authorization from the author. + +Educational institutions and public-interest educational organizations may use the project for noncommercial teaching, research, coursework, experiments, and learning/reference purposes. See [PolyForm Noncommercial License 1.0.0](./LICENSE) for the full terms. ## Star History -[![Star History Chart](https://api.star-history.com/chart?repos=XingYu-Zhong/DeepSeek-GUI&type=date&legend=top-left)](https://www.star-history.com/?repos=XingYu-Zhong%2FDeepSeek-GUI&type=date&logscale=&legend=top-left) +[![Star History Chart](https://api.star-history.com/chart?repos=KunAgent/Kun&type=date&legend=top-left)](https://www.star-history.com/?repos=KunAgent%2FKun&type=date&logscale=&legend=top-left) diff --git a/README.md b/README.md index 8a1c97a0..1df60b33 100644 --- a/README.md +++ b/README.md @@ -1,425 +1,227 @@

- DeepSeek GUI 图标 + Kun 图标

-# DeepSeek GUI +

Kun

-[English](./README.en.md) | 简体中文 +

+ 探索需求先行的下一代 coding 范式。
+ 用 DeepSeek、Xiaomi MiMo、MiniMax 的高性价比组合,把需求澄清、设计稿、计划和 Agent 编码串成完整闭环。 +

+ +

+ English +  ·  + 简体中文 +  ·  + 下载 +  ·  + 文档 +  ·  + 源码运行 +

-> 把 Kun 的高 Token ROI 本地智能体能力带进桌面窗口:**Code** 处理项目、**写作**打磨文档、**连接手机**接入 IM 与定时任务——让每一个 token 尽量花在需求、代码、决策和结果上。 +

+ GitHub release + License: PolyForm Noncommercial 1.0.0 + Platform + Electron 34 + React 19 +

-[官网](https://deepseek-gui.com) | [下载](https://deepseek-gui.com) +Kun 是一次面向未来编程方式的产品实验:不再从“给 Agent 一句话,让它直接改代码”开始,而是从需求澄清开始,把需求文档、设计稿、交互原型、实施计划、Todo、Agent 编码和变更审查放到一条连续的 GUI 工作流里。 -[![GitHub release](https://img.shields.io/github/v/release/XingYu-Zhong/DeepSeek-GUI?label=github)](https://github.com/XingYu-Zhong/DeepSeek-GUI/releases) -[![License](https://img.shields.io/github/license/XingYu-Zhong/DeepSeek-GUI)](./LICENSE) +Kun 面向希望把 AI Agent 真正放进日常工作的用户。它不是只聊天的客户端,也不是只给程序员的 CLI 外壳:你可以把本地目录交给它处理代码、需求、计划和变更审查,也可以在独立的 Write 工作区里写作、润色和导出文档。 -DeepSeek GUI 是一个面向开发者和高频 AI 工作者的本地桌面工作台。它以 Kun 为唯一运行时,把终端里的智能体体验整理成更容易上手、更适合长期使用的应用:选择工作目录,发起任务,实时查看推理、工具调用和文件改动,并在需要时审批或回退。 +这也是 Kun 为什么把 DeepSeek、Xiaomi MiMo、MiniMax 作为默认的一线模型组合,而不是把它们当成普通的“可选 Provider”。需求先行的 coding 范式会带来更多轮澄清、调研、结构化、规划、执行和验证,如果模型成本太高,这条流程很难成为日常工作方式。Kun 选择三家来自中国的高性价比模型供应商,正是为了让完整流程跑得起、用得久、试得多。 -这个项目的目标不是再造一个聊天壳,而是让 DeepSeek 变成一个可以稳定参与真实项目工作的桌面伙伴。Kun 的核心优势是高 Token ROI:同样的上下文预算,少浪费在重复前缀、庞大工具目录和失控输出上,多投入到真正推动任务完成的信息里。 +Kun 内置同名本地运行时,通过 `kun serve` 连接桌面端。会话、日志、偏好设置和运行时配置默认保存在本机;模型请求使用你自己的模型服务凭据。对会读写文件和执行命令的流程,Kun 提供工具审批、权限模式、内联 diff 和变更审查面板。 ---

- DeepSeek GUI Code 模式演示 + Kun Code 模式演示 - DeepSeek GUI 写作模式演示 - -

- -## 更多演示 - -

- - 飞书 / Lark / 微信连接演示 - -

-

飞书 / Lark / 微信连接演示。

- -

- - 新建需求与计划演示 + Kun Write 模式演示

-

新建需求与计划演示。

-

- - Web 工具演示 - -

-

Web 工具演示。

+## 需求先行的 coding 范式 -## Kun 为什么 Token ROI 高 +Kun 想探索的是“需求 -> 设计 -> 计划 -> 编码 -> 验证”的下一代编程工作流,而不是把一个聊天框简单贴到 IDE 上。 -Kun 把“省 token”做成 agent loop 的默认行为,而不是事后补救。它不只是压缩文本,更是在每一轮调用前判断哪些信息值得进入上下文。 - -| Kun 优势 | Token ROI 来源 | +| 阶段 | Kun 的尝试 | | --- | --- | -| **Cache-first agent loop** | 稳定 system prompt、工具 schema 和不可变前缀,让 DeepSeek 原生缓存更容易命中,长会话不必反复为同一段背景付费。 | -| **按需工具上下文** | MCP 工具很多时,先用 `mcp_search` 找相关工具,再描述和调用目标工具,避免每轮把完整工具目录塞进 prompt。 | -| **上下文卫生** | 对超长工具结果、长参数、base64 payload、重复工具循环和低价值历史做边界压缩,保留代码、路径、错误、决策和未解决事项。 | -| **可见的用量收益** | 运行时跟踪 cache hit/miss、token 用量和节省估算,GUI 会把 Token economy 的收益显示出来,方便长期观察成本回报。 | - -结果是:Kun 更适合真实项目里的长任务、长会话和多工具协作。它把模型注意力留给高价值上下文,让用户用同样的 API 预算换到更多有效推进。 - -## 我们做了什么 - -- 把 Kun 本地运行时封装进桌面应用,默认可以自动启动和管理。 -- 做了一套完整的聊天工作台,支持多会话、实时流式输出、历史回看、中断和重新发送。 -- 打通本地工作目录,让智能体可以围绕真实项目读取、编辑和创建文件。 -- 做了文件变更审查视图,让每一次修改都能被看见、理解和确认。 -- 做了首次引导、设置页、语言/主题/字体大小、系统通知、错误日志和更新入口。 -- 做了 Skill 与 MCP 的图形化管理,让用户不用手写很多配置也能扩展智能体能力。 -- 做了连接手机能力,支持飞书 / Lark / 微信接入、独立 IM Agent、本地 webhook / relay 和定时任务。 -- 做了 Write 写作工作台,提供独立写作空间、Markdown 文件树、live 编辑/预览、文本补全和选中文本 inline agent。 -- 做了新建需求、计划面板、线程 Todo、目标追踪和代码审查,让任务可以从想法走到执行再走到复盘。 -- 提供 macOS、Windows、Linux 预构建安装包;也可以从源码自行构建。 - -## 功能亮点 - -- **桌面聊天工作台**:多会话、流式回复、推理过程、工具调用、审批请求和文件改动都在同一个界面中展示。 -- **项目级工作区**:为每个任务选择本地目录,按工作区管理会话,并支持文件预览、编辑器打开和 Git 分支选择。 -- **新建需求**:先写需求草稿(背景、目标、验收标准),让需求 AI 帮忙澄清问题和补齐调研,再一键生成实施计划。 -- **计划与 Todo**:`/plan` 或新建需求都会生成可编辑的计划文件,右侧计划面板会同步线程 Todo,方便把长任务拆成可跟踪步骤。 -- **目标模式**:`/goal` 可以给当前会话设置长期目标,支持暂停、继续、清除和完成状态,让 agent 持续围绕同一个结果推进。 -- **代码审查**:`/review` 可审查当前未提交改动,也可以指定 base branch、commit 或自定义审查范围,结果以 findings 卡片呈现。 -- **旁支对话与会话管理**:`/btw` 可开启继承当前上下文的旁支对话;会话还支持压缩、分叉、归档和恢复。 -- **变更审查**:内联 diff 和侧边审查面板会记录智能体产生的文件改动,便于在应用内完成 review。 -- **权限可控**:支持只读、工作区可写、完全访问等模式,并可配置工具调用前是否需要审批。 -- **运行时托管**:默认使用内置 Kun;也可以在设置中指定自己的 `kun` 可执行文件。 -- **Skill 与 MCP**:在图形界面中创建 Skill、保存 MCP 配置、添加常用工具,并打开对应目录继续管理。 -- **可开关的 agent 扩展能力**:Kun 通过配置开关逐步启用 MCP、Web fetch/search、Skills、独立 CLI、图片附件、跨会话 Memory 和子 agent 委派;设置页会显示运行时实际上报的能力与诊断状态。 -- **连接手机**:可开启独立于普通聊天的后台 Agent,当前支持飞书 / Lark / 微信接入、IM webhook / relay,以及按计划自动执行任务。 -- **定时任务**:创建一次性、每日、间隔或手动任务,指定工作区、模型和推理强度,让 Kun 在电脑唤醒时自动执行。 -- **Write 写作模式**:独立管理 `~/.deepseekgui/write_workspace` 和自定义写作空间,读取 Markdown 文件树,支持 live Markdown 编辑、相对图片预览、DeepSeek FIM 短补全 / 灵感长补全(可用跨文本 BM25 + 关键词检索增强)、当前文档导出为 `HTML / PDF / DOC / DOCX`,以及选中文本后直接唤起 inline 写作助手。 -- **高 Token ROI**:Kun 会稳定 prompt 前缀、跟踪 DeepSeek 原生缓存命中、按需压缩上下文和工具输出,并用 MCP search 渐进发现工具,把 token 留给需求、代码、决策和结果。 -- **首次配置友好**:首次启动会引导你选择语言、填写 DeepSeek API Key,并按需配置兼容服务地址。 -- **本地优先**:设置、会话状态、日志和运行时配置保存在本机;模型调用使用你自己的 DeepSeek API Key。 -- **中英文界面**:应用和 README 均提供中文、英文版本,界面语言可随时切换。 -- **跨平台使用**:提供 macOS `.dmg/.zip`、Windows `.exe`、Linux `.AppImage`;也可以从源码构建。 - -## 运行时:Kun - -DeepSeek-GUI 当前唯一活跃的本地 Agent 运行时是仓库自带的 -**Kun**(位于 `kun/` 目录)。Kun 取意于《庄子·逍遥游》中的 -“北冥有鱼,其名为鲲”:它不是一个临时聊天壳,而是希望把模型能力沉到 -更深的本地运行时里,让它能承载更长的上下文、更复杂的工具调用和更持续的 -项目协作。技术上,Kun 是一个独立的 TypeScript 包,启动本地 HTTP/SSE -服务作为 GUI 与 agent loop 之间的唯一边界。 - -Kun 的核心理念是提高每一个 token 的 ROI。对用户来说,同样的上下文预算 -应该尽量花在需求、代码、决策和结果上,而不是重复的工具 schema、失控的 -工具输出、无效历史或已经可以被缓存复用的前缀上。它适合的不是一次性问答, -而是反复读写项目、持续调用工具、需要长期上下文的真实工作流。 - -Kun 集成了已被验证的设计: - -- **借鉴自 Reasonix 的 cache-first agent loop**:immutable prompt prefix(带 sha256 指纹)、append-only session log、bounded TTL/LRU cache、inflight tracking with guaranteed cleanup、mid-turn steering queue、context compaction(保留 pinned constraints)、cache / usage telemetry。 -- **Token economy 与工具上下文优化**:稳定系统前缀与工具 schema,按 DeepSeek 原生字段统计 cache hit/miss;对超长工具结果、长参数、base64 payload 和重复工具循环做边界压缩或抑制;当 MCP 工具很多时,可用 `mcp_search` / `mcp_describe` / `mcp_call` 渐进发现和调用工具,避免一次性把庞大的 MCP 工具目录全部塞进 prompt。 - -> 致谢:感谢 Reasonix 团队提供的可运行参考。Kun -> 的几乎全部性能特征——cache hit 率、token replay、断线重连、 -> 审批中断——都可以追溯到该项目。具体设计取舍与借鉴映射 -> 详见 [`docs/kun-architecture.md`](docs/kun-architecture.md)。 - -如果你想专门了解 Kun 如何做缓存优化,包括稳定前缀、工具 schema -规范化、DeepSeek 原生 hit/miss 统计、tool pair healing 和验证方法, -可以直接阅读 -[`docs/kun-cache-optimization.md`](docs/kun-cache-optimization.md)。 - -Kun 的大块 agent 能力采用 feature flag 管理:`capabilities.mcp` -接入第三方 MCP server,`capabilities.web` 暴露 `web_fetch` / -`web_search`,`capabilities.skills` 发现 `skill.json` 与 legacy -`SKILL.md`,`capabilities.attachments` 支持图片附件和文本模型 fallback,`capabilities.memory` -启用跨会话记忆,`capabilities.subagents` 允许有预算上限的子 agent -委派。`kun run` / `kun chat` / `kun exec` 可脱离 GUI 运行;GUI 的设置页 -会读取 `/v1/runtime/info` 与 `/v1/runtime/tools` 展示实际可用状态。 -这些能力默认按配置关闭或受模型能力限制,完整配置示例和排障说明见 -[`kun/README.md`](kun/README.md)。 - -技术架构(简化版): - -```text -Renderer (React) - → KunRuntimeProvider - → preload: dsGui.runtimeRequest / startSse - → main: LocalHttpRuntimeAdapter - → kun serve (HTTP + SSE) - → cache-first AgentLoop -``` - -设置项在 **设置 → Agent 运行时** 里维护:binary path、port、 -auto-start、API key、base URL、runtime token、data dir、model、 -approval policy、sandbox mode、insecure 开关。如果之前保存过旧 -provider,settings 会在读取时迁移到 `agents.kun`,再次保存后 -只保留 Kun 配置。 +| **澄清需求** | 在 GUI 中新建需求草稿,让需求 AI 帮你补问题、做实现前调研、整理边界 | +| **沉淀文档** | 把草稿保存为 `.kunsdd/draft/.../requirement.md`,支持结构化需求块、验收标准和需求历史 | +| **生成设计** | 从需求片段生成 UI 设计稿、信息图或交互式 HTML 原型,让需求不只停留在文字里 | +| **形成计划** | 通过 `/plan` 和 `create_plan` 生成 GUI 管理的 `.kunsdd/plan/...` 实施计划,并把计划步骤和需求关联 | +| **Agent 编码** | 计划进入 Todo、文件编辑、命令执行和变更审查;需求变更后可以提示重规划,避免计划和需求脱节 | +| **回到验收** | 结合需求块、验收标准、计划状态和 `/review`,把“做完了吗”落回最初的需求 | -完整的端点、CLI flag、环境变量、data dir 布局、SSE 事件 schema -见 [`kun/README.md`](kun/README.md)。 +这条线是 Kun 最重要的产品方向:让 AI coding 从“即时问答”走向“需求驱动的软件生产流程”。模型、写作、计划、审查和自动化都围绕这条线服务。 -## 适合谁 +## 核心模型组合 -- 想用 DeepSeek 处理真实代码库,但不想一直留在终端里的开发者。 -- 希望清楚看到智能体做了什么、改了哪些文件、哪些操作需要批准的团队。 -- 需要长期维护多个项目、多个会话,并希望把 Skill/MCP 配置沉淀下来的用户。 -- 想用本地工作台连接 DeepSeek 官方 API 或 OpenAI 兼容服务的人。 +Kun 追求的是“完整能力 + 极致性价比”。需求先行的流程比普通聊天更长,也更依赖反复调用模型;首启和设置页围绕三家中国模型供应商组织,让用户可以用更低的模型成本覆盖更多 Agent 场景。 ---- +| 供应商 | 在 Kun 中的角色 | +| --- | --- | +| **DeepSeek** | 默认文本与推理主模型,提供 `deepseek-v4-pro` / `deepseek-v4-flash`,支撑代码、计划、审查、长上下文会话和自动模型路由 | +| **Xiaomi MiMo** | 高性价比多模态与语音入口,覆盖长上下文文本模型、视觉输入、ASR 语音转写、TTS 语音生成和 Token Plan | +| **MiniMax** | 补齐完整媒体生成能力,覆盖 Anthropic Messages 文本模型、图片生成、语音生成、音乐生成、视频生成和 Token Plan | -## 工作台与入口 +这套组合让 Kun 可以把不同任务分配给更合适的能力:轻量澄清走高速模型,复杂代码和推理走更强模型,需求文档和 IM 场景接入语音,设计与创作场景接入图片、音乐和视频。你仍然可以添加 OpenAI 兼容、自托管或其他自定义 Provider,但 Kun 的默认体验会优先围绕这三家高性价比模型服务展开。 -DeepSeek GUI 现在以 **Code** 和 **写作** 两个主工作台为核心,并提供 -**连接手机**、**定时任务**、**插件 / Skill / MCP** 等入口。它们共享同一套 -Kun 运行时与设置,但会话、工作区和界面布局彼此独立,可按任务随时切换。 +## 为什么选择 Kun -### Code 模式 +| 你想要 | Kun 提供 | +| --- | --- | +| 探索下一代 coding 范式 | 从需求澄清、需求文档、设计稿、实施计划一路走到 Agent 编码和验收 | +| 极致性价比的完整 Agent 能力 | 以 DeepSeek、Xiaomi MiMo、MiniMax 为核心组合,覆盖文本、推理、视觉、语音、图片、音乐和视频 | +| 让 AI 面向真实项目工作 | 绑定本地工作区,读写文件、搜索代码、执行命令、查看工具调用和结果 | +| 把需求推进到可执行计划 | 支持新建需求、`/plan`、Todo、`/goal`、旁支对话、会话压缩、分叉和归档 | +| 让改动保持可控 | 工具审批、文件系统权限模式、内联 diff、变更审查面板和 `/review` | +| 在同一个应用里写作 | Markdown 文件树、Live / Source / Split / Preview、多种导出格式、选区 inline agent | +| 离开电脑也能触发任务 | 飞书 / Lark / 微信连接、本地 webhook / relay、一次性或周期性定时任务 | +| 不被单一模型绑定 | 三家核心供应商之外,也支持自定义 Base URL、协议、模型列表和扩展能力 | + +## 核心能力 + +- **需求先行 coding**:新建需求草稿,AI 澄清和结构化需求,生成设计稿或交互原型,再进入实施计划、Todo、Agent 编码和验收。 +- **Code 工作台**:围绕真实代码库对话,读取项目上下文,执行 shell 命令,修改文件,并在提交前审查每一次变更。 +- **需求、计划与审查**:从需求草稿进入计划,再到 Todo、执行、复盘和代码审查;长会话可以压缩、恢复、分叉或归档。 +- **Write 写作模式**:独立 Markdown 工作区,支持文件树、预览模式切换、补全、选区改写、图片附件,以及 `HTML / PDF / DOC / DOCX` 导出。 +- **自动化与远程入口**:把桌面会话接到飞书 / Lark / 微信等 IM,支持本地 webhook、relay 和定时任务,让后台任务也能回到同一套 Agent loop。 +- **模型组合优先**:围绕 DeepSeek、Xiaomi MiMo、MiniMax 设计首启、Provider 预设和能力自动接线,用高性价比模型组合承担完整桌面 Agent 工作流。 +- **多模态与媒体能力**:支持图片附件、视觉输入、语音转写、图片生成、语音生成、音乐生成和视频生成;相关能力随 Provider 配置启用。 +- **MCP 与 Skills**:接入 Model Context Protocol 服务器,加载项目或全局 Skills,让 Kun 按任务获得更专门的工具和工作方式。 +- **本地运行时**:`kun serve` 提供 HTTP/SSE 边界,采用 cache-first agent loop、追加式事件日志、用量统计和上下文压缩策略。 -面向真实代码库的开发工作台:绑定本地项目目录,围绕仓库读写文件、执行命令、审查改动。 +## 更多演示

- DeepSeek GUI Code 模式 + + PDF 研究演示 +

- -- 按工作区管理多个 Agent 会话,实时查看推理、工具调用与文件变更。 -- 支持内联 diff、变更审查面板,以及只读 / 工作区可写 / 完全访问等权限策略。 -- 支持新建需求、`/plan` 计划、右侧计划面板、线程 Todo 和 `/goal` 长期目标,让复杂任务可以先澄清、再计划、再执行。 -- 支持 `/review` 代码审查、`/btw` 旁支对话、会话压缩、会话分叉和归档恢复,适合长时间维护同一个项目上下文。 -- 提供快捷任务卡片,可一键发起结构梳理、排错、实现方案或 UI 优化等对话。 - -### Write 模式 - -独立的 Markdown 写作工作台,把写作文件、保存状态与 AI 助手从 Code 会话里拆出来单独管理。 +

PDF 研究与资料整理演示

- DeepSeek GUI Write 模式 + + 需求澄清、需求文档与计划演示 +

- -- 管理 `~/.deepseekgui/write_workspace` 与多个自定义写作空间,左侧文件树支持新建、重命名与删除。 -- 编辑器支持 **Live / Source / Split / Preview**,Live 模式在当前行保留 Markdown 源码,其余行实时渲染。 -- 工具栏支持把当前 Markdown 文档导出为 `HTML / PDF / DOC / DOCX`,导出时会尽量保留标题、列表、代码块、表格和本地图片。 -- 内置 DeepSeek FIM 短补全与灵感长补全;选中文本可唤起 inline agent,右侧写作助手支持摘要、大纲与润色等快捷操作。 - -### 连接手机 - -把 Kun 连接到手机和 IM 的后台自动化入口,让 Agent 在普通桌面聊天之外持续处理消息与定时任务。 +

需求澄清、需求文档与计划演示

- DeepSeek GUI 连接手机 + + iKun UI 插件演示 +

+

iKun UI 插件演示

-- 为飞书 / Lark / 微信等渠道配置独立 Agent,分别设定人设、默认模型与工作目录。 -- 每个 IM Agent 拥有独立会话线程,可在 GUI 内直接调试回复与工具调用。 -- 支持本地 webhook / relay,适合把 DeepSeek 接到团队协作或个人自动化流程中。 -- 定时任务可设置一次性、每日、间隔或手动运行,任务会创建独立 Kun thread,并按配置发送 prompt。 +## 快速开始 ---- +### 路径 A:下载发布版 -## 下载安装 +前往 [GitHub Releases](https://github.com/KunAgent/Kun/releases) 下载最新版本。 -### 下载预构建安装包 +| 平台 | 安装包 | 架构 | +| --- | --- | --- | +| macOS | `.dmg` 或 `.zip` | Intel / Apple Silicon | +| Windows | `.exe`,NSIS 安装器 | x64 | +| Linux | `.AppImage` | x64 | -前往 [GitHub Releases](https://github.com/XingYu-Zhong/DeepSeek-GUI/releases) 下载最新版本: +首次启动时: -| 平台 | 安装包 | -| --- | --- | -| macOS | `.dmg` 或 `.zip`,支持 Intel 与 Apple Silicon | -| Windows | `.exe`,NSIS 安装器,x64 | -| Linux | `.AppImage`,x64 | +1. 选择界面语言。 +2. 选择模型服务并填写 API Key 或 Token Plan Key。 +3. 如需兼容服务,在设置里编辑 Base URL、协议和模型列表。 +4. 进入 Code 绑定本地项目,或进入 Write 创建写作工作区。 -首次启动时需要填写 [DeepSeek API Key](https://platform.deepseek.com/api_keys)。如果你使用兼容 DeepSeek / OpenAI 的服务,也可以在设置里修改 Base URL。 +### 路径 B:从源码运行 -### 从源码运行 +环境要求: -适合贡献者或需要本地开发的人: +| 依赖 | 版本 | +| --- | --- | +| Node.js | 20+ | +| npm | 随 Node.js 安装 | +| 模型服务凭据 | DeepSeek / Xiaomi MiMo / MiniMax / 自定义 Provider 至少一个 | ```bash -git clone https://github.com/XingYu-Zhong/DeepSeek-GUI.git -cd DeepSeek-GUI +git clone https://github.com/KunAgent/Kun.git +cd Kun npm install npm run dev ``` -环境要求: - -- Node.js 20+ -- 可用的 DeepSeek API Key -- 首次安装依赖时需要联网 - 中国大陆访问较慢时,可以使用 npm 镜像: ```bash npm install --registry=https://registry.npmmirror.com ``` ---- - -## 首次使用 +## 常用命令 -1. 打开 DeepSeek GUI。 -2. 在首次引导中选择界面语言。 -3. 填入 DeepSeek API Key;如果需要,设置自定义 Base URL。 -4. 选择默认工作目录,或使用应用自动创建的默认目录。 -5. 新建会话,输入任务,让智能体开始工作。 - -常用流程(**Code 模式**): - -- 在左侧选择或切换工作区。 -- 在聊天框描述你要完成的任务。 -- 观察回复中的推理、工具调用、命令执行和文件改动。 -- 对需要审批的操作选择允许或拒绝。 -- 在变更审查面板里检查改动,再决定下一步。 - -**连接手机** 与 **写作** 的详细说明见上文 [工作台与入口](#工作台与入口)。简要步骤: - -- **连接手机**:在设置页启用后台自动化 → 添加飞书 / Lark / 微信连接 → 配置 Agent 人设、模型与工作目录 → 按需开启 webhook / relay 或定时任务。 -- **Write**:切换到 Write 模式 → 使用默认写作空间或添加新空间 → 在 Live 编辑器中写作,配合补全、选区 inline agent 与右侧写作助手。 - -## 设置与使用 - -设置页集中管理这些内容: - -- DeepSeek API Key、Base URL、运行时端口和运行时 Token。 -- 是否自动启动本地运行时,以及是否使用自定义 `deepseek` 路径。 -- 工具审批策略和文件系统权限范围。 -- 默认工作目录、语言、主题、字体大小和完成通知。 -- GUI 更新和本地错误日志。 -- Skill 创建与目录管理、MCP 配置编辑。 -- 连接手机后台自动化、飞书 / Lark / 微信连接、Webhook / Relay 和定时任务。 - -快捷键: - -| 按键 | 功能 | +| 命令 | 说明 | | --- | --- | -| `Enter` | 发送消息 | -| `Shift+Enter` | 在输入框中换行 | -| `Ctrl+Enter` | 发送消息 | -| `Esc` | 关闭面板或退出当前浮层 | - -## Write 模式设计参考 - -Write 模式的目标是把 DeepSeek GUI 从“代码/聊天工作台”扩展成真正可长期写作的桌面工作区。实现时参考了本地 `openhanako` 项目中的几个方案: - -- Markdown live 编辑:借鉴 openhanako 的 CodeMirror decorations 思路,当前行保留 Markdown 源码,非当前行用装饰层渲染标题、任务项、图片、分割线和表格。 -- 选区 inline agent:借鉴 openhanako 的选区捕获与浮动输入框交互,用户选中文本后可以直接输入“润色/续写/分析”等指令,并把文件路径、行号和原文作为结构化引用交给写作助手。 -- AI 会话隔离:Write 使用 Kun thread,但在 GUI 本地按写作空间维护 write thread registry,避免写作会话污染 Code / 连接手机侧栏。 -- 文本补全:写作补全不走本地 Kun serve(**Kun** 是仓库自带的本地 HTTP/SSE Agent 运行时,唯一负责 GUI 与 agent loop 之间的通信,详见上一节「运行时:Kun」),而是直接调用 DeepSeek FIM Completion API,方便在纯写作场景里获得低延迟 ghost text。短补全使用较短 debounce、较小 token 预算和严格本地过滤;灵感长补全使用更长停顿触发、更大 token 预算,并只在行尾 / 段落边界工作。补全前会对写作空间内的 Markdown / 文本文件建立短 TTL 轻量索引,使用 BM25 + 关键词匹配召回跨文本片段,并以隐藏 Markdown comment 的形式注入 prompt,帮助模型保持术语、事实和风格连续性。 - ---- - -## 卸载 - -### Windows - -- 打开“设置 -> 应用 -> 已安装的应用”,找到 `DeepSeek GUI` 并卸载。 -- 或在“控制面板 -> 程序和功能”中卸载。 -- 也可以运行安装目录中的卸载程序。 - -Windows 安装器默认会创建开始菜单和桌面快捷方式。安装包不会强制固定到任务栏;如需固定,可在开始菜单中右键 `DeepSeek GUI` 并选择固定。 - -### macOS - -- 将 `DeepSeek GUI.app` 从“应用程序”移到废纸篓。 -- 如果首次打开被系统拦截,可在 Finder 中右键应用并选择“打开”。 -- 本地未公证构建可先运行: - -```bash -npm run mac:unquarantine -- '/Applications/DeepSeek GUI.app' -``` - -### Linux +| `npm run dev` | 构建 Kun 运行时并启动 Electron 开发环境 | +| `npm run build` | 生产构建 | +| `npm run typecheck` | TypeScript 类型检查 | +| `npm run lint` | ESLint 检查 | +| `npm run test` | 运行 Vitest 测试 | +| `npm run dist:mac` | 构建 macOS `.dmg` 和 `.zip` | +| `npm run dist:win` | 构建 Windows NSIS 安装器 | +| `npm run dist:linux` | 构建 Linux AppImage | -- 如果你是从源码构建的 Linux 包,删除对应的 `.AppImage` 或安装文件即可。 -- 如果你手动创建了桌面入口或快捷方式,也一并删除。 +## 配置与数据 -### 清理本地数据 +- 偏好设置、会话、日志、运行时配置和本地运行时数据默认保存在本机。 +- 模型调用通过你配置的 Provider 凭据发起;Provider 预设可以作为起点,字段仍可编辑。 +- Code / Write / 连接手机共用同一个 `kun` 运行时边界,便于复用会话、审批、工具和用量统计。 +- 文件读写、命令执行、MCP 工具、媒体生成等高权限能力会经过权限与配置控制。 -默认卸载只移除应用文件,会保留本地设置、会话和运行时配置,便于后续重装恢复。若要彻底清理,可按需删除: +## 文档地图 -| 平台 | 应用数据位置 | +| 文档 | 内容 | | --- | --- | -| macOS | `~/Library/Application Support/DeepSeek GUI` | -| Windows | `%APPDATA%\DeepSeek GUI` | -| Linux | `~/.config/DeepSeek GUI` | - -Kun 数据默认位于 `~/.deepseekgui/kun` 或应用数据目录下的 Kun data dir。删除前请确认其中没有你还需要的会话、MCP 或 Skill 配置。 - ---- - -## 更新 - -- 普通用户:可在设置页检查 GUI 更新,或前往 [GitHub Releases](https://github.com/XingYu-Zhong/DeepSeek-GUI/releases) 下载最新安装包。 +| [kun/README.zh-CN.md](kun/README.zh-CN.md) | Kun 运行时、CLI、环境变量、HTTP API | +| [docs/kun-architecture.md](docs/kun-architecture.md) | 单运行时架构与 GUI 集成 | +| [docs/kun-cache-optimization.md](docs/kun-cache-optimization.md) | 缓存优化、token economy 与可观测性 | +| [docs/model-provider-presets.md](docs/model-provider-presets.md) | 模型 Provider 预设与扩展能力 | +| [docs/DEVELOPMENT.zh-CN.md](docs/DEVELOPMENT.zh-CN.md) | 本地开发流程、分支策略和发布说明 | +| [docs/CONTRIBUTING.zh-CN.md](docs/CONTRIBUTING.zh-CN.md) | 贡献说明 | +| [SECURITY.zh-CN.md](SECURITY.zh-CN.md) | 安全漏洞披露方式 | -## 贡献指南 +## 贡献 欢迎提交 bug 修复、UI/UX 优化、文档改进、本地化内容、构建发布流程和运行时集成相关改动。 协作约定: - 日常协作与集成分支为 `develop`,稳定发布分支为 `master`。 -- 新功能和修复建议从最新 `develop` 拉出短期功能分支开始。 -- PR 默认提交到 `develop`,由维护者审核后再由维护者合入 `master` 发布。 -- 对高风险改动请先沟通范围,再进入实现。 -- 发起 PR 前运行 `npm run typecheck`、`npm run build`,以及 `npm run test`。 -- 如果改动影响界面,请附上视频或 GIF。 -- 如果改动影响项目逻辑,请附上对应单元测试。 -- 如果改动影响使用方式,请同步更新 `README.md` 和 `README.en.md`。 - -详见 [CONTRIBUTING.zh-CN.md](./docs/CONTRIBUTING.zh-CN.md) 和 [DEVELOPMENT.zh-CN.md](./docs/DEVELOPMENT.zh-CN.md)。 - -## 本地构建 - -```bash -npm run build # 生产构建 -npm run dist:mac # macOS 安装包 -npm run dist:win # Windows 安装包(在 Windows 上运行) -npm run dist:linux # Linux AppImage -npm run release:mac # 手动兜底:构建并上传 macOS release 资源 -npm run release:win # 手动兜底:构建并上传 Windows release 资源 -``` - -更多开发流程请看 [DEVELOPMENT.zh-CN.md](./docs/DEVELOPMENT.zh-CN.md)。 - -## 文档 - -| 文档 | 内容 | -| --- | --- | -| [docs/kun-architecture.md](docs/kun-architecture.md) | Kun 单运行时方案、GUI 拆改范围、HTTP/SSE 合约、旧 agent 拆除说明 | -| [docs/kun-cache-optimization.md](docs/kun-cache-optimization.md) | Kun 缓存优化、token economy、MCP search、工具输出压缩与用量收益统计 | -| [docs/kun-contributing.md](docs/kun-contributing.md) | Kun 贡献指南:六边形架构、设计模式(Ports & Adapters / Functional Core Imperative Shell / 事件溯源 / 显式 DI / Composition Root)、4 个典型 PR 场景 | -| [kun/README.md](kun/README.md) | Kun 包:CLI、env、data dir、HTTP API | -| [CONTRIBUTING.zh-CN.md](docs/CONTRIBUTING.zh-CN.md) | 贡献说明 | -| [DEVELOPMENT.zh-CN.md](docs/DEVELOPMENT.zh-CN.md) | 本地开发与协作流程 | -| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | 社区行为准则 | -| [SECURITY.md](SECURITY.md) | 安全漏洞披露方式 | - ---- +- PR 默认提交到 `develop`。 +- 发起 PR 前建议运行 `npm run typecheck`、`npm run build` 和 `npm run test`。 +- 外部贡献需接受 [Contributor License Agreement](./CLA.md)。 ## 致谢 -Kun 的设计站在先行项目的肩膀上: +感谢 [LobsterAI](https://github.com/netease-youdao/LobsterAI)、DeepSeek、Xiaomi MiMo、MiniMax,以及所有提交 issue、建议、代码和文档的贡献者。 -- **Reasonix** —— cache-first agent loop。`ImmutablePrefix`(带 sha256 指纹)+ 显式 mutation API、`AppendOnlySessionLog`(in-memory 窗口 + JSONL 磁盘重放)、`LruCache` / `TtlLruCache`、带 `finally` 清理的 `InflightTracker`、`SteeringQueue`(mid-turn 用户引导)、`ContextCompactor`(保留 pinned constraints)、`UsageCounter` + `CacheTelemetry` —— 这些都是 Reasonix 设计原型的 TypeScript 复刻与改进。Reasonix 的 reasoning events 拆分流、tool call / result 配对、usage replay 等设计也直接延续到 Kun 的事件合约。 - -也感谢以下项目和个人: - -- **[LobsterAI](https://github.com/netease-youdao/LobsterAI)**:IM 管理、扫码绑定、Agent 绑定与自定义人设流程给了本项目连接手机能力很多启发。 -- **OpenHanako**:Markdown live 编辑、写作空间、选中文本 inline agent 等 Write 模式交互和实现方案给了本项目重要参考。 -- **[DeepSeek](https://github.com/deepseek-ai)**:提供模型与 API。 -- 所有为 DeepSeek GUI 提交 issue、建议、代码和文档的贡献者。 - - - + + Kun contributors -> [!NOTE] -> 本项目与 DeepSeek Inc. 无隶属关系。 - ## 许可证 -[MIT](./LICENSE) +本项目仅供学习和参考,不可用于任何商业用途。商业使用、商业分发、SaaS/托管服务、二次销售或集成到商业产品中,均需要获得作者的单独书面授权。 + +教育机构与公益教育机构可用于非商业教学、研究、课程实验和学习参考。完整条款见 [PolyForm Noncommercial License 1.0.0](./LICENSE)。 ## Star 历史 -[![Star History Chart](https://api.star-history.com/chart?repos=XingYu-Zhong/DeepSeek-GUI&type=date&legend=top-left)](https://www.star-history.com/?repos=XingYu-Zhong%2FDeepSeek-GUI&type=date&logscale=&legend=top-left) +[![Star History Chart](https://api.star-history.com/chart?repos=KunAgent/Kun&type=date&legend=top-left)](https://www.star-history.com/?repos=KunAgent%2FKun&type=date&logscale=&legend=top-left) diff --git a/SECURITY.md b/SECURITY.md index 3c67839e..486612ea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -Thank you for helping keep DeepSeek GUI and its users safe. +Thank you for helping keep Kun and its users safe. ## Supported Versions diff --git a/SECURITY.zh-CN.md b/SECURITY.zh-CN.md index 045cd89d..15346376 100644 --- a/SECURITY.zh-CN.md +++ b/SECURITY.zh-CN.md @@ -1,6 +1,6 @@ # 安全策略 -感谢您帮助保护 DeepSeek GUI 及其用户的安全。 +感谢您帮助保护 Kun 及其用户的安全。 ## 支持的版本 diff --git a/build/entitlements.mac.inherit.plist b/build/entitlements.mac.inherit.plist index 9a279dc8..281212cc 100644 --- a/build/entitlements.mac.inherit.plist +++ b/build/entitlements.mac.inherit.plist @@ -8,5 +8,7 @@ com.apple.security.cs.disable-library-validation + com.apple.security.device.audio-input + diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 9a279dc8..281212cc 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -8,5 +8,7 @@ com.apple.security.cs.disable-library-validation + com.apple.security.device.audio-input + diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c512477e..4af56494 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,6 +1,6 @@ # Agent Runtime Notes -DeepSeek GUI has one live agent runtime: **Kun**. +The Kun desktop app has one live agent runtime: the bundled **Kun** runtime. Do not add a second live provider, provider switcher, runtime diagnostics panel, or legacy CodeWhale/Reasonix process path. Code, Write, and Connect phone all diff --git a/docs/AGENTS.zh-CN.md b/docs/AGENTS.zh-CN.md index 487ee998..3c8cc0c8 100644 --- a/docs/AGENTS.zh-CN.md +++ b/docs/AGENTS.zh-CN.md @@ -1,6 +1,6 @@ # 代理运行时说明 -DeepSeek GUI 当前只有一个可运行的本地 Agent 运行时:**Kun**。 +Kun 桌面应用当前只有一个可运行的本地 Agent 运行时:仓库自带的同名 **Kun** 运行时。 不要新增第二套运行时、运行时切换器、运行时诊断面板,或旧的 CodeWhale / Reasonix 进程路径。Code、Write、连接手机三个入口都统一走同一个 Kun HTTP/SSE 边界。连接手机在代码内部仍沿用 `claw` 命名作为兼容标识。 diff --git a/docs/CONTRIBUTING.en.md b/docs/CONTRIBUTING.en.md index f0ad9610..a05729c2 100644 --- a/docs/CONTRIBUTING.en.md +++ b/docs/CONTRIBUTING.en.md @@ -2,7 +2,7 @@ [Simplified Chinese](./CONTRIBUTING.zh-CN.md) -Thank you for contributing to DeepSeek GUI. +Thank you for contributing to Kun. This document explains how contributors should collaborate on the project, what standards to follow, and how changes should be proposed. @@ -10,7 +10,7 @@ This document explains how contributors should collaborate on the project, what Code is easy. Good taste is rare. -For DeepSeek GUI, taste means clear workflows, restrained interfaces, humane copy, and behavior that feels obvious after one use. Strong contributions show judgment, not just implementation. +For Kun, taste means clear workflows, restrained interfaces, humane copy, and behavior that feels obvious after one use. Strong contributions show judgment, not just implementation. ## Contribution Scope @@ -47,7 +47,7 @@ Rules: ## Shape of a Typical PR -A well-structured PR for DeepSeek GUI is focused and self-contained. It typically: +A well-structured PR for Kun is focused and self-contained. It typically: - Touches **1-3 new files** and modifies **2-5 existing files** for wiring - Scopes to a single feature, fix, or documentation update @@ -172,14 +172,14 @@ Examples: - `docs: rewrite README and contribution guides` - `feat: improve runtime connection recovery` -- `fix: handle missing DeepSeek binary path` +- `fix: handle missing Kun binary path` ## Reporting Issues When reporting issues, please include: - Operating system and version -- DeepSeek GUI version (from Settings or the About dialog) +- Kun version (from Settings or the About dialog) - Bundled `kun` version (`kun --version` in the same directory, if available) - Steps to reproduce the issue - Expected vs actual behavior @@ -202,4 +202,11 @@ If requirements are unclear, ask for clarification before making broad architect ## License -By contributing to DeepSeek GUI, you agree that your contributions will be licensed under the [MIT License](../LICENSE). +External contributions are accepted under the [Contributor License Agreement](../CLA.md). +By submitting a contribution, you agree to grant the project owner the rights +described in the CLA, including the right to sublicense and relicense your +contribution as part of Kun under commercial, proprietary, noncommercial, or +other license terms. + +The project itself remains available under the [PolyForm Noncommercial License 1.0.0](../LICENSE) +unless the project owner grants a separate written commercial license. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b6f3a9fb..47214c5c 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -2,7 +2,7 @@ [简体中文](./CONTRIBUTING.zh-CN.md) -Thank you for contributing to DeepSeek GUI. +Thank you for contributing to Kun. This document explains how contributors should collaborate on the project, what standards to follow, and how changes should be proposed. @@ -10,7 +10,7 @@ This document explains how contributors should collaborate on the project, what Code is easy. Good taste is rare. -For DeepSeek GUI, taste means clear workflows, restrained interfaces, humane copy, and behavior that feels obvious after one use. Strong contributions show judgment, not just implementation. +For Kun, taste means clear workflows, restrained interfaces, humane copy, and behavior that feels obvious after one use. Strong contributions show judgment, not just implementation. ## Contribution Scope @@ -47,7 +47,7 @@ Rules: ## Shape of a Typical PR -A well-structured PR for DeepSeek GUI is focused and self-contained. It typically: +A well-structured PR for Kun is focused and self-contained. It typically: - Touches **1-3 new files** and modifies **2-5 existing files** for wiring - Scopes to a single feature, fix, or documentation update @@ -172,14 +172,14 @@ Examples: - `docs: rewrite README and contribution guides` - `feat: improve runtime connection recovery` -- `fix: handle missing DeepSeek binary path` +- `fix: handle missing Kun binary path` ## Reporting Issues When reporting issues, please include: - Operating system and version -- DeepSeek GUI version (from Settings or the About dialog) +- Kun version (from Settings or the About dialog) - Bundled `kun` version (`kun --version` in the same directory, if available) - Steps to reproduce the issue - Expected vs actual behavior @@ -202,4 +202,11 @@ If requirements are unclear, ask for clarification before making broad architect ## License -By contributing to DeepSeek GUI, you agree that your contributions will be licensed under the [MIT License](../LICENSE). +External contributions are accepted under the [Contributor License Agreement](../CLA.md). +By submitting a contribution, you agree to grant the project owner the rights +described in the CLA, including the right to sublicense and relicense your +contribution as part of Kun under commercial, proprietary, noncommercial, or +other license terms. + +The project itself remains available under the [PolyForm Noncommercial License 1.0.0](../LICENSE) +unless the project owner grants a separate written commercial license. diff --git a/docs/CONTRIBUTING.zh-CN.md b/docs/CONTRIBUTING.zh-CN.md index 1f0b92de..696e9f17 100644 --- a/docs/CONTRIBUTING.zh-CN.md +++ b/docs/CONTRIBUTING.zh-CN.md @@ -2,7 +2,7 @@ [English](./CONTRIBUTING.md) -感谢你为 DeepSeek GUI 做贡献。 +感谢你为 Kun 做贡献。 这份文档说明了贡献者应该如何协作、遵循什么标准,以及改动应如何提交。 @@ -10,7 +10,7 @@ 代码不难,难得的是好品味。 -在 DeepSeek GUI 里,品味意味着清晰的流程、克制的界面、自然的文案,以及用一次就能理解的行为。好的贡献不只是把功能做出来,也要体现判断力。 +在 Kun 里,品味意味着清晰的流程、克制的界面、自然的文案,以及用一次就能理解的行为。好的贡献不只是把功能做出来,也要体现判断力。 ## 贡献范围 @@ -47,7 +47,7 @@ ## 典型 PR 的结构 -一个结构良好的 DeepSeek GUI PR 应该聚焦且自包含。通常: +一个结构良好的 Kun PR 应该聚焦且自包含。通常: - 涉及 **1-3 个新文件**,修改 **2-5 个现有文件**进行接入 - 范围限定在单个功能、修复或文档更新 @@ -172,14 +172,14 @@ npm run dev - `docs: rewrite README and contribution guides` - `feat: improve runtime connection recovery` -- `fix: handle missing DeepSeek binary path` +- `fix: handle missing Kun binary path` ## 提交 Issue 提交 Issue 时,请尽可能包含以下信息: - 操作系统及版本 -- DeepSeek GUI 版本(可在设置页或关于对话框查看) +- Kun 版本(可在设置页或关于对话框查看) - 内置的 `kun` 版本(如可用,在同目录下执行 `kun --version`) - 复现步骤 - 预期行为与实际行为 @@ -202,4 +202,6 @@ npm run dev ## 许可证 -向 DeepSeek GUI 贡献代码即表示你同意你的贡献将基于 [MIT 许可证](../LICENSE) 发布。 +外部贡献基于英文 [Contributor License Agreement](../CLA.md) 接收。提交贡献即表示你同意 CLA 中的授权条款,包括项目所有者可将你的贡献作为 Kun 的一部分进行再授权、商业授权或其他形式授权。 + +项目本身默认仍基于 [PolyForm Noncommercial License 1.0.0](../LICENSE) 发布;除非项目所有者另行提供书面商业授权。 diff --git a/docs/KUN_CONFIG.md b/docs/KUN_CONFIG.md index f434d5c0..0ad7fad4 100644 --- a/docs/KUN_CONFIG.md +++ b/docs/KUN_CONFIG.md @@ -1,18 +1,18 @@ # Kun Agent 与模型配置说明 -本文说明 DeepSeek GUI / Kun 的本地配置文件在哪里、哪些字段由 UI 管理、哪些字段适合手工扩展,以及模型上下文压缩阈值应该如何配置。 +本文说明 Kun(桌面应用与运行时)的本地配置文件在哪里、哪些字段由 UI 管理、哪些字段适合手工扩展,以及模型上下文压缩阈值应该如何配置。 ## 配置文件分层 -DeepSeek GUI 有两层配置。 +Kun 有两层配置。 1. GUI settings 这是桌面应用自己的设置文件,保存设置页里的 Agent 运行时选项。 - - macOS: `~/Library/Application Support/DeepSeek GUI/deepseek-gui-settings.json` - - Windows: `%APPDATA%/DeepSeek GUI/deepseek-gui-settings.json` - - Linux: `~/.config/DeepSeek GUI/deepseek-gui-settings.json` + - macOS: `~/Library/Application Support/Kun/kun-settings.json` + - Windows: `%APPDATA%/Kun/kun-settings.json` + - Linux: `~/.config/Kun/kun-settings.json` Agent 运行时设置在 `agents.kun` 下,例如端口、data dir、默认模型、审批策略、sandbox、token economy 等。多数用户通过设置页修改这些字段。 @@ -21,7 +21,7 @@ DeepSeek GUI 有两层配置。 这是 Kun 本地运行时读取的高级配置文件。默认路径是: ```text - ~/.deepseekgui/kun/config.json + ~/.kun/data/config.json ``` 如果 `agents.kun.dataDir` 改成了别的目录,实际路径就是: @@ -36,7 +36,7 @@ DeepSeek GUI 有两层配置。 GUI 启动 Kun 时会按下面的顺序合并配置。 -1. GUI 读取 `deepseek-gui-settings.json`,得到 `agents.kun` 和通用 provider 配置。 +1. GUI 读取 `kun-settings.json`(旧版 `deepseek-gui-settings.json` 会自动迁移),得到 `agents.kun` 和通用 provider 配置。 2. GUI 在启动 Kun 前同步 `/config.json`,写入 UI 管理的 token economy、默认压缩摘要参数、默认模型 profiles、runtime tuning、MCP search 和附件能力。 3. Kun serve 读取 `/config.json` 或 `--config` 指定的文件。 4. CLI 参数和环境变量会覆盖 `serve` 里的基础启动字段,例如 `--model`、`--port`、`KUN_MODEL`、`KUN_PORT`。 @@ -49,7 +49,7 @@ GUI 启动 Kun 时会按下面的顺序合并配置。 "serve": { "host": "127.0.0.1", "port": 8899, - "dataDir": "~/.deepseekgui/kun", + "dataDir": "~/.kun/data", "runtimeToken": "", "apiKey": "", "baseUrl": "https://api.deepseek.com/beta", @@ -200,7 +200,7 @@ Kun 内置 DeepSeek V4 默认模型画像: "binaryPath": "", "port": 8899, "autoStart": true, - "dataDir": "~/.deepseekgui/kun", + "dataDir": "~/.kun/data", "model": "deepseek-v4-pro", "approvalPolicy": "auto", "sandboxMode": "workspace-write", @@ -213,6 +213,32 @@ Kun 内置 DeepSeek V4 默认模型画像: 设置页会保存这些字段。GUI 模式下默认模型以 `agents.kun.model` 为准;`config.json` 里的 `serve.model` 更适合 standalone `kun serve` 使用,因为 GUI 启动时会把设置页里的模型作为启动参数传给 Kun。 +## Hooks 配置写在哪里 + +Hooks 写在 `config.json` 顶层的 `hooks` 数组里,GUI 启动 Kun 时通过 +`--data-dir` 自动加载,无需额外开关: + +```json +{ + "hooks": [ + { + "phase": "PreToolUse", + "matcher": "bash|write_file|mcp__*", + "command": "node ~/.kun-hooks/guard.js", + "timeoutMs": 10000 + }, + { "phase": "UserPromptSubmit", "command": "~/.kun-hooks/prompt-context.sh" } + ] +} +``` + +支持的 `phase`:`PreToolUse`、`PostToolUse`(工具调用前后,可改写参数 / +输出、拒绝或自动放行)、`UserPromptSubmit`(回合开始前,可拒绝或注入 +上下文)、`TurnStart`、`TurnEnd`、`PreCompact`(只读通知)。命令通过 +stdin 收到 JSON invocation,退出码 `0` + stdout JSON 返回结构化结果, +退出码 `2` 阻断动作,其余非零只产生 `hook_warning` 事件。完整参考 +(各阶段载荷、失败语义、示例脚本)见 [kun-hooks.md](kun-hooks.md)。 + ## 用户如何自定义 常见做法: diff --git a/docs/UI_PLUGINS.md b/docs/UI_PLUGINS.md new file mode 100644 index 00000000..bedaccea --- /dev/null +++ b/docs/UI_PLUGINS.md @@ -0,0 +1,106 @@ +# UI 插件开发指南(形象工坊) + +Kun 的「形象工坊」允许任何人制作并安装自己的吉祥物形象包, +换掉工作台里的泳动小鸟、欢迎/睡觉/坐着的状态形象、会话出没彩蛋、完成庆祝,甚至主题色和进行中文案。 + +**iKun 模式就是最好的例子**:它不是硬编码的功能,而是一个随应用分发、 +首次启动自动安装的 UI 插件(id 为 `ikun`,见 `src/main/ui-plugin-bundled.ts`)。 +你在形象工坊里看到的 iKun 卡片,与任何第三方插件完全同级 —— 可以启用、停用,也可以删除。 +(它额外享有一套手工制作的运球/快攻/喝奶茶动画,这部分由应用针对 `ikun` id 特殊点亮, +第三方插件则使用通用的泳动/状态动画框架。) + +**一个 UI 插件就是一个文件夹**:`manifest.json` + 若干图片。没有任何代码 —— +插件是纯声明式的,应用不会执行插件内的任何脚本或样式。 + +``` +my-plugin/ +├── manifest.json +└── img/ + ├── swim.png + ├── greet.png + └── … +``` + +安装方式:`设置 → 形象工坊 → 安装插件文件夹…`,选中插件目录即可。 +应用会校验 manifest 后把 **manifest 和被引用到的图片** 复制进应用数据目录 +(`~/.kun/ui-plugins//`),源目录中的其它文件一律不会被复制。 + +官方示例见 [`examples/ui-plugins/starlight/`](../examples/ui-plugins/starlight/)。 + +## manifest.json 参考 + +```json +{ + "id": "starlight", + "name": "星夜 Kun", + "version": "1.0.0", + "author": "你的名字", + "description": "一句话介绍(可选,≤240 字符)", + "figures": { + "swim": "img/bird.png", + "surf": "img/surf.png", + "greet": "img/greet.png", + "sleep": "img/sleep.png", + "sit": "img/sit.png", + "run": "img/run.png", + "toggleIcon": "img/icon.png" + }, + "labels": { + "zh": { "working": "巡航中…" }, + "en": { "working": "Cruising…" } + }, + "tokens": { + "light": { "--ds-accent": "#7a5fd0" }, + "dark": { "--ds-accent": "#a78ff0" } + }, + "features": { "cameos": true } +} +``` + +### 字段规则 + +| 字段 | 必填 | 规则 | +|---|---|---| +| `id` | ✓ | 2–40 位小写字母/数字/连字符;保留字 `default` / `kun` / `on` / `off` / `none` 不可用(`ikun` 被预装示例占用,重装会覆盖它) | +| `name` | ✓ | ≤60 字符 | +| `version` | ✓ | 语义化版本,如 `1.0.0` | +| `author` / `description` | | ≤80 / ≤240 字符 | +| `figures` | ✓ | 至少一个槽位;路径必须是插件目录内的相对路径,仅 `png/webp/jpg/jpeg/gif` | +| `labels` | | 仅 `zh` / `en` 两种语言;键限 `working` / `workingSprint` / `workingDive` / `workingSurf`;每条 ≤24 字符 | +| `tokens` | | 仅 `light` / `dark` 两个主题;键限 `--ds-*`;值禁止 `url()`、分号、花括号等(只允许颜色/渐变安全字符);总数 ≤60 | +| `features.cameos` | | `true` 时启用主会话两侧的不定时出没彩蛋 | + +### 形象槽位(figures) + +所有图片建议 **主体朝左**、透明背景、最长边 512px 左右。 +缺失的槽位会回退到默认 Kun 美术或按下表的回退链借用你的其它槽位。 + +| 槽位 | 出现在哪里 | 缺失时回退 | +|---|---|---| +| `swim` | 回合进行中的泳动动画主体(推进/冲刺/潜入)、各处的最终兜底 | 默认 Kun 鸟 | +| `surf` | 泳动动画的冲浪姿态、庆祝「胜利巡游」 | `swim` | +| `greet` | 欢迎卡片、侧边栏轮播、出没「探头」、庆祝「跃起欢呼」 | `swim` | +| `sleep` | 运行时唤醒页、侧边栏轮播、出没「打盹」 | `sit` → `swim` | +| `sit` | 选择工作区空状态、侧边栏轮播、出没「歇脚」、庆祝「举杯」 | `greet` → `swim` | +| `run` | 出没「横穿/对穿」、庆祝「胜利巡游」 | `surf` → `swim` | +| `toggleIcon` | 形象工坊里的预览小图 | `swim` → `greet` … | + +### 体积限制 + +- `manifest.json` ≤64KB;单张图片 ≤2MB;全部图片合计 ≤24MB。 + +## 安全模型(为什么这样设计) + +1. **无代码执行**:插件不含 JS/HTML/CSS 文件;即使放了也不会被复制安装。 +2. **白名单安装**:只复制 manifest 与被 `figures` 引用的图片;路径禁止 `..`、绝对路径与反斜杠。 +3. **图片经主进程读取后以 data URL 注入**,渲染层不直接访问插件目录。 +4. **主题 token 白名单**:键名必须是 `--ds-*`,值经过字符集校验,样式文本由应用生成, + 并锚定在 `html[data-ui-plugin='']` 下,停用即移除。 + +## 调试技巧 + +- 安装失败时,设置页会列出 manifest 的具体校验错误。 +- 修改插件后重新执行一次「安装插件文件夹…」即可覆盖更新(同 id 覆盖安装)。 +- 可用的 `--ds-*` token 清单见 `src/renderer/src/styles/base-shell.css` 顶部的 + `:root` 与 `[data-theme='dark']` 两个变量块;最常用的是 + `--ds-accent` / `--ds-accent-soft` / `--ds-selection`。 diff --git a/docs/kun-architecture.en.md b/docs/kun-architecture.en.md index 0f4c580d..a9ab1a85 100644 --- a/docs/kun-architecture.en.md +++ b/docs/kun-architecture.en.md @@ -1,11 +1,11 @@ # Kun GUI single-runtime architecture -This document describes how DeepSeek GUI should now be organized around one dedicated runtime, +This document describes how the Kun desktop app should now be organized around one dedicated runtime, `Kun`, that serves the GUI through a single HTTP/SSE boundary. The conclusion is clear up front: the GUI keeps one agent with the only ID `kun`; Code, Write, and Connect phone all flow through the same `kun serve` HTTP/SSE boundary. -CodeWhale, Reasonix, painting/design entry points, runtime diagnostics panel, +Historical runtimes, painting/design entry points, runtime diagnostics panel, and agent switching are no longer shown as primary product surfaces. ## Target boundary @@ -14,8 +14,8 @@ and agent switching are no longer shown as primary product surfaces. Renderer (React + Zustand) Code / Write / Connect phone UI | - | window.dsGui.runtimeRequest(path, method, body) - | window.dsGui.startSse(threadId, sinceSeq) + | window.kunGui.runtimeRequest(path, method, body) + | window.kunGui.startSse(threadId, sinceSeq) v Preload IPC bridge | @@ -38,24 +38,23 @@ kun serve (TypeScript package) /v1/workspace/status ``` -This boundary follows the HTTP architecture used by TUI/CodeWhale: GUI never embeds the agent loop, -does not juggle multiple state machines through stdio/RPC, and treats the local -HTTP server as the stable API boundary. -Inside `kun`, the cache-first agent loop is adopted from Reasonix (`immutable` prompt -prefix, append-only log, bounded LRU/TTL cache, inflight cleanup, steering queue, -context compaction, usage/cache telemetry). +This boundary uses a local HTTP service architecture: GUI never embeds the agent loop, +does not juggle multiple state machines through stdio/RPC, and treats `kun serve` +as the stable API boundary. +Inside `kun`, the cache-first agent loop uses immutable prompt prefixes, +append-only logs, bounded LRU/TTL caches, inflight cleanup, steering queues, +context compaction, and usage/cache telemetry. ## Cache-hit optimization -Kun cache-hit metrics should be computed and optimized using DeepSeek native fields first: +Kun cache-hit metrics should be computed and optimized using provider-native usage fields first: - Model client prefers native fields: `prompt_cache_hit_tokens` and `prompt_cache_miss_tokens`. Only when those are missing should it fall back to compatibility fields such as `prompt_tokens_details.cached_tokens` and `cache_read_input_tokens`. - Use hit rate as `hit / (hit + miss)`, not `hit / prompt_tokens`. - DeepSeek native misses are not always equal to `prompt_tokens - hit`; Reasonix also uses - the `hit + miss` denominator. + Provider-native misses are not always equal to `prompt_tokens - hit`. - `kun/src/prompt/kun-system-prompt.ts` is the stable prefix. It may only contain long-lived Kun run contract content and must not include workspace names, timestamps, file snippets, selected text, user dynamic state, @@ -71,7 +70,7 @@ or timestamps. Stable ordering avoids prefix churn caused by schema reordering. - Each turn persists a canonical tool-catalog fingerprint and count. If a scope detects tool-definition drift, `toolCatalogDrift` is recorded to aid cache debugging. -- Before sending historical messages to DeepSeek, repair message history: +- Before sending historical messages to the upstream model, repair message history: no orphaned `tool_result`, no `tool_call` whose result is missing. Multiple tool calls in one response are reorganized into a single legal assistant `tool_calls` message to reduce 400/retry loops. @@ -93,10 +92,10 @@ Observed temporary-thread verification on `2026-06-02`: overall (including warm-up) `95.2%`, latest round `98.1%`. Pre-existing usage events persisted before optimization cannot be rewritten because -DeepSeek native cache fields were not recorded then; they only reflect old behavior and +provider-native cache fields were not recorded then; they only reflect old behavior and should not be treated as evidence that current hit rates are lower. -Reasonix findings still useful as future references: +Cache capabilities still worth pursuing next: - Tool-collection mutation policy: adding tools should be append-only; edit/reorder/remove requires either restart or a new session boundary to avoid sudden cache misses. @@ -132,10 +131,9 @@ kept removed: Main process and preload no longer expose old provider IPC: -- Remove `deepseek:spawn-if-needed`, `deepseek:update-*`, `deepseek:diagnostics`. -- Remove `reasonix:rpc-send`, `reasonix:spawn-if-needed`, and the `reasonix` RPC bridge. -- Remove CodeWhale adapter, Reasonix adapter, Reasonix HTTP bridge, - DeepSeek/CodeWhale updater, legacy binary resolver, and old process manager. +- Remove historical runtime spawn/update/diagnostics IPC. +- Remove historical RPC event bridges. +- Remove historical adapters, HTTP bridges, updaters, binary resolvers, and process managers. - Remove diagnostic/importer modules unrelated to Kun. Main process now only needs: @@ -160,7 +158,7 @@ Saved settings should now be just: "apiKey": "", "baseUrl": "https://api.deepseek.com/beta", "runtimeToken": "", - "dataDir": "~/.deepseekgui/kun", + "dataDir": "~/.kun/data", "model": "deepseek-v4-pro", "approvalPolicy": "auto", "sandboxMode": "workspace-write", @@ -170,17 +168,15 @@ Saved settings should now be just: } ``` -The only reason strings `codewhale` and `reasonix` remain in code is for one-time -migration from old settings: +The only reason historical provider strings remain in code is one-time migration +from old settings: -- `agentProvider: codewhale | reasonix | deepseek-runtime` normalizes to `kun`. -- Old `agents.deepseek` / `agents.codewhale` values for `port`, `autoStart`, `apiKey`, - `baseUrl`, `runtimeToken`, `approvalPolicy`, and `sandboxMode` are migrated into +- Historical `agentProvider` values normalize to `kun`. +- Historical provider values for `port`, `autoStart`, `apiKey`, `baseUrl`, + `runtimeToken`, `approvalPolicy`, `sandboxMode`, and `model` are migrated into `agents.kun`. -- Old `agents.reasonix` values for `apiKey`, `baseUrl`, `model`, and `autoStart` - are also migrated to `agents.kun`. -- Persisted files after migration no longer retain `agents.codewhale` / `agents.reasonix`. -- Legacy Connect phone fields (internally still named Claw) `agentThreadIds.codewhale` and `agentThreadIds.reasonix` are collapsed +- Persisted files after migration no longer retain historical provider blocks. +- Legacy Connect phone fields (internally still named Claw) `agentThreadIds` are collapsed to `agentThreadIds.kun`; per-provider maps are not retained. ## Code / Write / Connect phone flows under Kun @@ -189,16 +185,16 @@ migration from old settings: steer, interrupt, compact, approval, and SSE mapping. Chat UI does not directly know about old providers. - Write: writing assistant and inline completion share the same Kun API key/base URL. - Write thread registry identifies write threads as Kun threads only, with no Reasonix distinction. + Write thread registry identifies write threads as Kun threads only, with no legacy-runtime distinction. - Connect phone: scheduled tasks, Feishu/Lark/WeChat, and IM webhooks create or reuse Kun threads. The codebase still uses the internal `claw` route, settings key, and runtime file names for legacy-name compatibility. `threadId` / `localThreadId` remain only for legacy settings compatibility; canonical mapping is written to `agentThreadIds.kun`. -## Functional parity from CodeWhale in GUI HTTP path +## Functional parity in GUI HTTP path -Replacing CodeWhale is not only preserving chat. -Kun GUI HTTP must expose the same capabilities previously exposed through CodeWhale: +Runtime unification is not only preserving chat. +Kun GUI HTTP must expose the capabilities already consumed by the store/UI: - `GET /v1/threads` supports `limit`, `search`, `include_archived`, `archived_only`. Archived/deleted threads are hidden by default; session search and archive views @@ -207,7 +203,7 @@ Kun GUI HTTP must expose the same capabilities previously exposed through CodeWh and writes historical items back into the new thread's session store. During copy, pending `approval` / `user-input` states are rewritten to history-only states to prevent hanging gates in new sessions. -- `POST /v1/sessions/{id}/resume-thread` follows the previous CodeWhale resume path. +- `POST /v1/sessions/{id}/resume-thread` follows the historical resume path. Kun should first attempt same-name thread restore, then session snapshot/JSONL reconstruction, and return `404` when not found. - Both `POST /v1/user-inputs/{id}` and legacy `POST /v1/user-input/{id}` are accepted, @@ -223,21 +219,10 @@ and return `404` when not found. Legacy runtime paths should not reappear: -- `src/renderer/src/agent/codewhale-runtime.ts` -- `src/renderer/src/agent/reasonix-runtime.ts` -- `src/renderer/src/agent/reasonix-event-mapper.ts` -- `src/main/runtime/codewhale-adapter.ts` -- `src/main/runtime/reasonix-adapter.ts` -- `src/main/runtime/reasonix-http-bridge.ts` -- `src/main/deepseek-process.ts` -- `src/main/resolve-deepseek-binary.ts` -- `src/main/deepseek-updater.ts` -- `src/main/reasonix-process.ts` -- `src/main/reasonix-config.ts` -- `src/main/resolve-reasonix-binary.ts` -- `src/shared/reasonix-protocol.ts` -- `src/shared/deepseek-update.ts` -- Diagnostic/importer modules for old runtime paths. +- Historical runtime adapters / bridges +- Historical runtime process managers / binary resolvers +- Historical runtime update modules +- Diagnostics/importers outside Kun Legacy UI entrypoints should not reappear: @@ -245,7 +230,6 @@ Legacy UI entrypoints should not reappear: - `ConnectionStatusBar` - `RuntimeDiagnosticsDialog` - `RuntimeInsightsPanel` -- `ReasonixInsightsPanel` - Design/Painting starter card ## Design constraints @@ -277,12 +261,12 @@ npm run build Manual smoke checks: -1. Open DeepSeek GUI. +1. Open the Kun desktop app. 2. Code can create a new session, send messages, stream output, and use approval/interruption. 3. Write opens writing space; inline completion and inline selected-text assistant share API key. 4. Connect phone can save settings, run manual tasks, and write thread IDs back to Kun mapping. -5. `Settings -> Agents` shows only Kun, with no provider switch and no runtime diagnostics/ - CodeWhale/Reasonix blocks. +5. `Settings -> Agents` shows only Kun, with no provider switch, runtime diagnostics, + or historical provider blocks. 6. If `GET /v1/usage?group_by=thread` returns history, home and footer no longer show blank “No usage yet”, but show token, turn, cache-hit indicators. 7. Thread search, archive, fork/resume, and request_user_input answer/cancel flows all operate diff --git a/docs/kun-architecture.md b/docs/kun-architecture.md index 5acca2d2..29f644d8 100644 --- a/docs/kun-architecture.md +++ b/docs/kun-architecture.md @@ -1,10 +1,10 @@ # Kun GUI 单运行时方案 -本文记录 DeepSeek GUI 现在应该如何围绕一个专门服务 GUI 的 +本文记录 Kun 桌面应用现在应该如何围绕一个专门服务 GUI 的 Kun 改造。结论先说清楚:GUI 只保留一个 agent,唯一 ID 是 `kun`;Code、Write、连接手机都通过同一条 `kun serve` -HTTP/SSE 边界工作;CodeWhale、Reasonix、绘画/设计类入口、运行时 -诊断面板、agent 切换都不再是产品表面。 +HTTP/SSE 边界工作;历史运行时、绘画/设计类入口、运行时诊断面板、 +agent 切换都不再是产品表面。 ## 目标边界 @@ -12,8 +12,8 @@ HTTP/SSE 边界工作;CodeWhale、Reasonix、绘画/设计类入口、运行 Renderer (React + Zustand) Code / Write / Connect phone UI | - | window.dsGui.runtimeRequest(path, method, body) - | window.dsGui.startSse(threadId, sinceSeq) + | window.kunGui.runtimeRequest(path, method, body) + | window.kunGui.startSse(threadId, sinceSeq) v Preload IPC bridge | @@ -36,23 +36,21 @@ kun serve (TypeScript package) /v1/workspace/status ``` -这个边界借鉴 TUI/CodeWhale 的 serve HTTP 架构:GUI 不直接嵌 agent -loop,不通过 stdio/RPC 混跑多个状态机,只把本地 HTTP 服务当成稳定 -协议。Kun 内部再吸收 Reasonix 的 cache-first loop:immutable -prefix、append-only log、bounded LRU/TTL cache、inflight cleanup、 -steering queue、context compaction、usage/cache telemetry。 +这个边界采用本地 HTTP 服务架构:GUI 不直接嵌 agent loop,不通过 +stdio/RPC 混跑多个状态机,只把 `kun serve` 当成稳定协议。Kun 内部使用 +cache-first loop:immutable prefix、append-only log、bounded LRU/TTL cache、 +inflight cleanup、steering queue、context compaction、usage/cache telemetry。 ## 缓存命中优化 -Kun 的缓存命中率要按 Reasonix 的 DeepSeek 原生口径计算和优化: +Kun 的缓存命中率要按 provider 原生 usage 字段优先计算和优化: -- 模型 client 优先解析 DeepSeek 原生 +- 模型 client 优先解析 provider 原生 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`。只有原生字段缺失 时,才退回 `prompt_tokens_details.cached_tokens`、`cache_read_input_tokens` 等兼容字段。 - cache hit rate 使用 `hit / (hit + miss)`,不使用 - `hit / prompt_tokens`。DeepSeek 原生 miss 不一定等于 `prompt_tokens - hit`, - Reasonix 也是按 hit+miss 作为缓存统计分母。 + `hit / prompt_tokens`。provider 原生 miss 不一定等于 `prompt_tokens - hit`。 - `kun/src/prompt/kun-system-prompt.ts` 是稳定前缀。它只放长期 不变的 Kun 运行契约,不能放 workspace、时间戳、文件片段、选中文本、 用户动态信息或一次性工具结果。 @@ -65,7 +63,7 @@ Kun 的缓存命中率要按 Reasonix 的 DeepSeek 原生口径计算和优化 schema key 顺序变化造成 prefix churn。 - 每个 turn 会持久化 canonical tool catalog fingerprint 和 tool count;同一 scope 下工具定义漂移时会标记 `toolCatalogDrift`,便于排查 cache miss。 -- 历史消息发送给 DeepSeek 前会做共享的 model-history repair:孤儿 +- 历史消息发送给上游模型前会做共享的 model-history repair:孤儿 `tool_result` 不发,缺少对应 result 的 `tool_call` 不发;同一次响应里的 多个 tool call 会重组为一个合法 assistant `tool_calls` 消息,避免 400/retry 造成额外延迟和缓存浪费。 @@ -83,10 +81,10 @@ Kun 的缓存命中率要按 Reasonix 的 DeepSeek 原生口径计算和优化 - 12 轮短消息:去掉冷启动后的热命中 `94.7%`,最新一轮 `93.6%`。 - 同一稳定前缀热身后 24 轮短消息:整体含冷启动 `95.2%`,最新一轮 `98.1%`。 -优化前已经持久化的旧 usage 事件不会被事后改写,因为当时没有保存 DeepSeek -原生缓存字段;这些历史数据只能作为旧实现的证据,不能证明新实现仍然低命中。 +优化前已经持久化的旧 usage 事件不会被事后改写,因为当时没有保存 +provider 原生缓存字段;这些历史数据只能作为旧实现的证据,不能证明新实现仍然低命中。 -Reasonix 资料里仍可作为下一阶段的借鉴项: +下一阶段仍值得推进的缓存能力: - 工具集合 mutation gate:新增工具允许 append,编辑、重排、删除工具时要求 restart 或新会话边界,避免热前缀突然全量 miss。当前 Kun 已排序工具 @@ -121,12 +119,9 @@ Renderer 只应展示 Kun。需要删除或保持删除的 UI 面包括: 主进程和 preload 不再暴露旧 agent IPC: -- 删除 `deepseek:spawn-if-needed`、`deepseek:update-*`、 - `deepseek:diagnostics`。 -- 删除 `reasonix:rpc-send`、`reasonix:spawn-if-needed`、 - `reasonix` RPC event bridge。 -- 删除 CodeWhale adapter、Reasonix adapter、Reasonix HTTP bridge、 - DeepSeek/CodeWhale updater、旧 binary resolver、旧 process manager。 +- 删除历史运行时的 spawn/update/diagnostics IPC。 +- 删除历史 RPC event bridge。 +- 删除历史 adapter、HTTP bridge、updater、binary resolver 和 process manager。 - 删除 Kun 之外的 diagnostics/importer 模块。用户要的是可用的单 agent,不是运行时检测中心。 @@ -152,7 +147,7 @@ Renderer 只应展示 Kun。需要删除或保持删除的 UI 面包括: "apiKey": "", "baseUrl": "https://api.deepseek.com/beta", "runtimeToken": "", - "dataDir": "~/.deepseekgui/kun", + "dataDir": "~/.kun/data", "model": "deepseek-v4-pro", "approvalPolicy": "auto", "sandboxMode": "workspace-write", @@ -162,17 +157,14 @@ Renderer 只应展示 Kun。需要删除或保持删除的 UI 面包括: } ``` -代码里仍允许出现 `codewhale` / `reasonix` 字符串的唯一原因是读取旧 -settings 文件时做一次性迁移: +代码里仍允许出现历史 provider 字符串的唯一原因是读取旧 settings 文件时做 +一次性迁移: -- `agentProvider: codewhale | reasonix | deepseek-runtime` 归一为 - `kun`。 -- 旧 `deepseek`/`agents.codewhale` 的 port、autoStart、API key、 - base URL、runtime token、approval、sandbox 会种到 `agents.kun`。 -- 旧 `agents.reasonix` 的 API key、base URL、model、autoStart 会种到 - `agents.kun`。 -- 迁移后的落盘文件不再保留 `agents.codewhale` 或 `agents.reasonix`。 -- 连接手机(内部旧名 Claw)旧 `agentThreadIds.codewhale/reasonix` 只折叠成 +- 历史 `agentProvider` 值归一为 `kun`。 +- 历史 provider 的 port、autoStart、API key、base URL、runtime token、 + approval、sandbox、model 会种到 `agents.kun`。 +- 迁移后的落盘文件不再保留历史 provider 配置块。 +- 连接手机(内部旧名 Claw)的历史 `agentThreadIds` 只折叠成 `agentThreadIds.kun`,不保留 per-agent map。 ## Code / Write / 连接手机如何走 Kun @@ -182,16 +174,16 @@ settings 文件时做一次性迁移: provider。 - Write:写作助手和 inline completion 读取同一份 Kun API key / base URL 配置。Write thread registry 只把写作线程识别为 Kun - thread,不再区分 Reasonix 会话。 + thread,不再区分旧运行时会话。 - 连接手机:定时任务、飞书/Lark/微信、IM webhook 创建或复用 Kun thread。 代码内部仍沿用 `claw` route / settings key / runtime 文件名,作为旧命名兼容。 `threadId` / `localThreadId` 字段只作为旧 settings 兼容字段存在,真正 当前映射写入 `agentThreadIds.kun`。 -## CodeWhale 功能等价面 +## GUI HTTP 功能等价面 -替换 CodeWhale 不是只保留聊天。Kun 的 GUI HTTP 面必须覆盖旧 -provider 已经暴露给 store/UI 的能力: +运行时归一不是只保留聊天。Kun 的 GUI HTTP 面必须覆盖 store/UI +已经依赖的能力: - `GET /v1/threads` 支持 `limit`、`search`、`include_archived`、 `archived_only`。默认隐藏 archived/deleted,会话搜索和归档视图不依赖 @@ -199,7 +191,7 @@ provider 已经暴露给 store/UI 的能力: - `POST /v1/threads/{id}/fork` 复制 thread 历史、写入 fork lineage, 并把历史 item 写回新 thread 的 session store。复制时会把 pending approval/user-input 规整为不可继续操作的历史状态,避免新会话悬挂旧 gate。 -- `POST /v1/sessions/{id}/resume-thread` 沿用旧 CodeWhale resume 路径。 +- `POST /v1/sessions/{id}/resume-thread` 沿用历史 resume 路径。 Kun 优先从同名 thread 恢复;没有 thread 时从 session snapshot 或 JSONL items 重建 turns;找不到时返回 404,而不是在 GUI 抛 unsupported。 @@ -212,25 +204,14 @@ provider 已经暴露给 store/UI 的能力: Workbench 首页和 composer 底部只消费 Kun usage,不再打开 runtime insights 面板。 -## 已删除/应保持删除的旧路径 +## 已删除/应保持删除的旧入口 旧 agent 运行路径不应再回来: -- `src/renderer/src/agent/codewhale-runtime.ts` -- `src/renderer/src/agent/reasonix-runtime.ts` -- `src/renderer/src/agent/reasonix-event-mapper.ts` -- `src/main/runtime/codewhale-adapter.ts` -- `src/main/runtime/reasonix-adapter.ts` -- `src/main/runtime/reasonix-http-bridge.ts` -- `src/main/deepseek-process.ts` -- `src/main/resolve-deepseek-binary.ts` -- `src/main/deepseek-updater.ts` -- `src/main/reasonix-process.ts` -- `src/main/reasonix-config.ts` -- `src/main/resolve-reasonix-binary.ts` -- `src/shared/reasonix-protocol.ts` -- `src/shared/deepseek-update.ts` -- Runtime diagnostics/importers for old agent paths +- 历史 runtime adapters / bridges +- 历史 runtime process managers / binary resolvers +- 历史 runtime update modules +- Kun 之外的 diagnostics/importers 旧 UI 入口不应再回来: @@ -238,7 +219,6 @@ provider 已经暴露给 store/UI 的能力: - `ConnectionStatusBar` - `RuntimeDiagnosticsDialog` - `RuntimeInsightsPanel` -- `ReasonixInsightsPanel` - 设计/绘画 starter card ## 设计模式约束 @@ -270,12 +250,12 @@ npm run build 手动冒烟: -1. 打开 DeepSeek GUI。 +1. 打开 Kun 桌面应用。 2. Code 新建会话,能创建 thread、发送消息、流式返回、审批/中断可用。 3. Write 打开写作空间,inline completion 和选中文本助手能用同一个 API key。 4. 连接手机能保存设置、运行手动 task、把 thread id 写回 Kun mapping。 5. Settings -> Agents 只看得到 Kun,没有 provider switch、runtime - diagnostics、CodeWhale/Reasonix 配置块。 + diagnostics、历史 provider 配置块。 6. `GET /v1/usage?group_by=thread` 有历史 usage 时,GUI 首页/底部不显示 “暂无用量”,而显示 token、回合、缓存命中等指标。 7. 线程搜索、归档视图、fork、resume session、request_user_input 回答/取消 diff --git a/docs/kun-cache-optimization.en.md b/docs/kun-cache-optimization.en.md index eb1699fa..9ed2cca1 100644 --- a/docs/kun-cache-optimization.en.md +++ b/docs/kun-cache-optimization.en.md @@ -1,6 +1,6 @@ # Kun cache optimization technical documentation -This article records the cache optimization design, implementation location, and implementation of the current Kun runtime of DeepSeek GUI. +This article records the cache optimization design, implementation location, and implementation of the current Kun runtime. Statistical caliber and subsequent evolution direction. The goal is not to simply "get the cache numbers high" but to keep the GUI and the local agent's request prefix long-term stable, verifiable, and observable across Code, Write, and Connect phone. @@ -8,8 +8,8 @@ the local agent's request prefix long-term stable, verifiable, and observable ac Kun's cache optimization serves four goals: -- Make request prefixes sent to DeepSeek as byte stable as possible. -- Make cache hit statistics consistent with DeepSeek native fields instead of relying on guesswork. +- Make request prefixes sent to upstream models as byte stable as possible. +- Make cache hit statistics prefer provider-native fields instead of relying on guesswork. - Let prefix drift and message history pollution be discovered during development. - Let the GUI only bear the responsibility of HTTP/SSE calls, and consolidate the caching discipline inside Kun. @@ -42,7 +42,7 @@ single cache-hit metric: ## General principles -Kun borrowed Reasonix’s cache-first design, but adapted it to the GUI scenario: +Kun's cache-first design is adapted to the GUI scenario: - The GUI does not spell prompt, and does not make cache judgments in renderer or main process. - `kun serve` is the only request exit, and cache-related strategies are placed inside the runtime. @@ -140,7 +140,7 @@ Implementation location: - `kun/src/adapters/model/deepseek-compat-model-client.ts` - `kun/src/loop/agent-loop.ts` -Kun will also do a layer of Reasonix-style history hygiene at model request boundaries: +Kun will also do a shared layer of history hygiene at model request boundaries: - Only the history sent to the model is compressed, and the complete tool results saved in the disk/session are not changed. - Extra large `tool_result` will retain head, @@ -185,14 +185,14 @@ Implementation location: There are several direct benefits to doing this: -- Prevent DeepSeek from returning 400 due to illegal message structure +- Prevent upstream model providers from returning 400 due to illegal message structure - Avoid lowering the cache hot prefix ratio due to retry, historical pollution or large tool results - Avoid repeated tool loops that continue to expand dynamic history and create meaningless cache misses - Avoid inheriting malformed tool history for the first request after fork/resume ## Cache statistics caliber -Kun's cache hit statistics preferentially use DeepSeek's native usage field: +Kun's cache hit statistics preferentially use provider-native usage fields: - `prompt_cache_hit_tokens` - `prompt_cache_miss_tokens` @@ -220,7 +220,7 @@ cacheHitRate = hit / prompt_tokens ``` -The reason is that DeepSeek's native miss caliber is not guaranteed to be equal to `prompt_tokens - hit`. If the denominator is wrong, +The reason is that a provider-native miss field is not guaranteed to be equal to `prompt_tokens - hit`. If the denominator is wrong, Panels that look "high" or "low" could just be statistical distortions. The cumulative statistics use the same formula simultaneously to avoid inconsistency between the caliber of a single round and the cumulative panel: @@ -231,7 +231,7 @@ The cumulative statistics use the same formula simultaneously to avoid inconsist Kun will also use a single round of real `prompt_tokens` as the compaction pressure for the next request. If the number of prompt tokens reported by the provider has reached the current model soft threshold, the next time The model step will trigger compaction first; this is more efficient than relying solely on local estimates of 4 characters/token. -Close to the actual context pressure of DeepSeek, it can also maintain the proportion of hot prefixes before tool continuation. +Close to the actual context pressure of the model, it can also maintain the proportion of hot prefixes before tool continuation. Implementation location: @@ -324,13 +324,13 @@ In this way, the Code / Write / Connect phone entry points can share the same se ## Current referenced and unfinished items -Reasonix ideas that have been implemented: +Kun cache mechanisms that have been implemented: - Stable prefix file - immutable prefix fingerprint - Tool schema canonical sorting - tool catalog fingerprint / drift metadata -- DeepSeek native cache fields are given priority +- Provider-native cache fields are given priority - tool-call / tool-result pairing fix - multi-tool call block reorganization and complete subset salvage - streamed tool-call delta press `index` to continue @@ -347,8 +347,8 @@ Points worth learning from in the next stage: Explicit restart or new session boundaries - LLM fold summarizer: If you use the model for compaction in the future, you should reuse the main prefix to avoid The summarizer turns itself into a cold request -- Big tool result token cap: Currently lightweight token-aware estimation has been added; DeepSeek will be built-in in the future - tokenizer, which can be changed to the precise upper limit of tokens like Reasonix +- Big tool result token cap: lightweight token-aware estimation has been added; a future precise + tokenizer can move this to an exact token cap - Volatile scratch boundary: continue to separate "the thinking displayed to the user" and the "history replayed to the model" ## Related documents diff --git a/docs/kun-cache-optimization.md b/docs/kun-cache-optimization.md index ca6df99a..de7f72c7 100644 --- a/docs/kun-cache-optimization.md +++ b/docs/kun-cache-optimization.md @@ -1,6 +1,6 @@ # Kun 缓存优化技术文档 -本文记录 DeepSeek GUI 当前 Kun 运行时的缓存优化设计、实现位置、 +本文记录 Kun 运行时当前的缓存优化设计、实现位置、 统计口径与后续演进方向。目标不是单纯“让缓存数字变高”,而是让 GUI 与 本地 agent 的请求前缀长期稳定、可验证、可观测,并且在 Code / Write / 连接手机三条主路径下都成立。 @@ -9,8 +9,8 @@ Kun 的缓存优化服务于四个目标: -- 让发送给 DeepSeek 的请求前缀尽可能字节稳定。 -- 让缓存命中统计与 DeepSeek 原生字段一致,而不是依赖猜测。 +- 让发送给上游模型的请求前缀尽可能字节稳定。 +- 让缓存命中统计优先使用 provider 原生字段,而不是依赖猜测。 - 让 prefix 漂移和消息历史污染在开发期就被发现。 - 让 GUI 只承担 HTTP/SSE 调用职责,把缓存纪律收敛在 Kun 内部。 @@ -34,7 +34,7 @@ schema、超长工具输出、MCP 工具目录、无效 retry 或历史噪声消 ## 总体原则 -Kun 借鉴了 Reasonix 的 cache-first 设计,但按 GUI 场景做了收束: +Kun 的 cache-first 设计按 GUI 场景做了收束: - GUI 不拼 prompt,不在 renderer 或 main process 做缓存判断。 - `kun serve` 是唯一请求出口,缓存相关策略都放在运行时内部。 @@ -133,7 +133,7 @@ Kun 当前在模型请求边界对消息做一层共享的 model history repair - `kun/src/adapters/model/deepseek-compat-model-client.ts` - `kun/src/loop/agent-loop.ts` -Kun 也会在模型请求边界做一层 Reasonix 风格的 history hygiene: +Kun 也会在模型请求边界做一层共享的 history hygiene: - 只压缩发给模型的历史,不改磁盘/session 里保存的完整工具结果。 - 超大的 `tool_result` 会按字节、行数和轻量 token 估算上限保留 head、 @@ -179,14 +179,14 @@ Fork / resume 创建新线程时也会修复克隆历史: 这样做有几个直接收益: -- 避免 DeepSeek 因消息结构不合法返回 400 +- 避免上游模型 provider 因消息结构不合法返回 400 - 避免因为 retry、历史污染或大工具结果拉低缓存热前缀占比 - 避免重复工具循环继续扩大动态历史并制造无意义 cache miss - 避免 fork/resume 后第一次请求继承畸形工具历史 ## 缓存统计口径 -Kun 的缓存命中统计优先使用 DeepSeek 原生 usage 字段: +Kun 的缓存命中统计优先使用 provider 原生 usage 字段: - `prompt_cache_hit_tokens` - `prompt_cache_miss_tokens` @@ -212,7 +212,7 @@ cacheHitRate = hit / (hit + miss) cacheHitRate = hit / prompt_tokens ``` -原因是 DeepSeek 原生 miss 口径不保证等于 `prompt_tokens - hit`。如果分母错了, +原因是 provider 原生 miss 口径不保证等于 `prompt_tokens - hit`。如果分母错了, 面板看起来“很高”或“很低”都可能只是统计失真。 累计统计同步使用同一公式,避免单轮与累计面板口径不一致: @@ -223,7 +223,7 @@ cacheHitRate = hit / prompt_tokens Kun 也会把单轮真实 `prompt_tokens` 作为下一次请求的 compaction pressure。 如果 provider 报告的 prompt token 数已经达到当前模型 soft threshold,下一次 model step 会优先触发 compaction;这样比单纯依赖 4 字符/token 的本地估算更 -接近 DeepSeek 实际上下文压力,也能在工具 continuation 前保住热前缀占比。 +接近模型实际上下文压力,也能在工具 continuation 前保住热前缀占比。 实现位置: @@ -318,13 +318,13 @@ GUI 不应该做: ## 当前已借鉴与未完成项 -已经落地的 Reasonix 思路: +已经落地的 Kun 缓存机制: - 稳定前缀文件 - immutable prefix 指纹 - 工具 schema canonical 排序 - tool catalog fingerprint / drift metadata -- DeepSeek 原生缓存字段优先 +- provider 原生缓存字段优先 - tool-call / tool-result 配对修复 - multi-tool call block 重组与可完成子集 salvage - streamed tool-call delta 按 `index` 续接 @@ -341,8 +341,8 @@ GUI 不应该做: 明确 restart 或新会话边界 - LLM fold summarizer:未来若使用模型做 compaction,应复用主前缀,避免 summarizer 自己变成冷请求 -- 大工具结果 token cap:当前已加入轻量 token-aware 估算;如未来内置 DeepSeek - tokenizer,可改为 Reasonix 那种 token 精确上限 +- 大工具结果 token cap:当前已加入轻量 token-aware 估算;如未来内置精确 + tokenizer,可改为 token 精确上限 - volatile scratch 边界:把“展示给用户的思考”与“重放给模型的历史”继续分离 ## 相关文档 diff --git a/docs/kun-hooks.en.md b/docs/kun-hooks.en.md new file mode 100644 index 00000000..76c99949 --- /dev/null +++ b/docs/kun-hooks.en.md @@ -0,0 +1,289 @@ +# Kun Hooks + +This is the full reference for the Kun agent runtime hook system: +design rationale, the six phases with their payloads and powers, the +external command protocol, matching and chaining semantics, +configuration, the embedder API, and security notes. After reading +this you should be able to write a working hook without reading the +source. + +The implementation landed in the 2026-06 rework: before it, the hook +engine only had PreToolUse/PostToolUse and no configuration entry +point at all (effectively dead code). After the rework, hooks are a +first-class extension mechanism alongside MCP, skills, memory, and +subagents. + +## Design goals + +1. **Config-only activation**: users write a top-level `hooks` array + in `config.json`. No rebuild, no GUI changes required. +2. **Familiar protocol**: the command protocol mirrors Claude Code + hooks (JSON on stdin, exit 2 blocks, JSON result on stdout) to keep + migration cheap. +3. **Explicit failure semantics**: security gates fail closed (tool + phases), convenience injection fails open (prompt phase), observers + only warn. +4. **One system everywhere**: the main loop, delegated subagents, and + the CLI (`kun serve` / `kun exec`) share the same hooks. + +## Modules and wiring + +```text +kun/src/hooks/hook-engine.ts # phases, payload/result types, matcher, executors +kun/src/hooks/hook-config.ts # zod schema for config.json + resolveConfiguredHooks +kun/src/hooks/index.ts # package export (kun/hooks subpath) + +config.json (top-level hooks array) + → KunConfigSchema (kun/src/config/kun-config.ts) + → ServeOptions (kun/src/cli/cli-options.ts, serve.ts) + → KunServeRuntimeOptions (kun/src/server/runtime-factory.ts) + → LocalToolHost(hooks) # main tool host: PreToolUse/PostToolUse + → LocalToolHost(hooks) # subagent tool host: same + → AgentLoop(hooks) # lifecycle phases +``` + +The GUI launches Kun with `--data-dir`, and `{dataDir}/config.json` is +loaded automatically, so for GUI users the hook config lives at: + +```text +~/.deepseekgui/kun/config.json +``` + +## The six phases + +### PreToolUse (before every tool call, can intervene) + +Runs before approval and execution. Stdin payload: + +```json +{ + "phase": "PreToolUse", + "call": { + "callId": "c_…", + "toolName": "bash", + "providerId": "builtin", + "toolKind": "command_execution", + "arguments": { "command": "ls" } + }, + "context": { + "threadId": "th_…", + "turnId": "turn_…", + "workspace": "/path/to/workspace", + "threadMode": "agent", + "approvalPolicy": "on-request", + "sandboxMode": "workspace-write" + } +} +``` + +Result fields: + +- `{"decision": "deny", "message": "…"}` — block this call. The model + receives a `hook_denied` error result; the turn continues. +- `{"decision": "allow"}` — auto-approve: skip the approval prompt + (as if the user clicked allow). Later hooks can still rewrite + arguments or deny; any deny overrides an allow. +- `{"arguments": {…}}` — **replaces** the tool arguments wholesale + (no merge). Later hooks and the execution see the rewritten value. + +### PostToolUse (after every tool call, can intervene) + +Runs after execution, before the result reaches the model. Payload is +PreToolUse plus `result`: + +```json +{ "phase": "PostToolUse", "call": …, "context": …, "result": { "output": …, "isError": false } } +``` + +Result fields: + +- `{"output": …}` — replace the tool output (the model sees the + replacement). +- `{"isError": true}` — mark the result as an error (combinable with + `output`). + +### UserPromptSubmit (before the turn's first model step, can intervene) + +Runs after TurnStart, before the first model call. Payload: + +```json +{ "phase": "UserPromptSubmit", "threadId": "…", "turnId": "…", "prompt": "raw user input", "workspace": "/…" } +``` + +Result fields: + +- `{"decision": "deny", "message": "…"}` — reject the whole turn. The + turn finishes `failed` with a `hook_denied` error item; the message + is shown to the user. +- `{"additionalContext": "…"}` — inject context. Contributions from + all hooks merge into one persisted `` user message + (separate from the user's original message), visible to the model in + the same turn. +- On exit 0, **plain-text stdout** counts as additionalContext in this + phase (elsewhere it counts as a message), so the simplest injection + hook is `echo "deploy freeze today"`. + +### TurnStart / TurnEnd / PreCompact (observe-only) + +Observation only. Returned values are ignored except `message` (turned +into a warning event); crashes and timeouts produce `hook_warning` +runtime events and never affect the turn. + +Payloads: + +```json +{ "phase": "TurnStart", "threadId": "…", "turnId": "…", "prompt": "…", "workspace": "/…" } +{ "phase": "TurnEnd", "threadId": "…", "turnId": "…", "status": "completed|failed|aborted", "error": "…(failures only)" } +{ "phase": "PreCompact","threadId": "…", "turnId": "…", "reason": "(human-readable trigger description)", "mode": "normal|aggressive|force" } +``` + +Timing notes: TurnEnd runs **after** the turn status is persisted, so +a slow hook never delays the GUI seeing the turn finish. PreCompact +runs after the compaction plan exists and before it executes. + +## Matchers (tool phases only) + +- `matcher`: a glob over the tool name — `*` matches any run of + characters, `|` separates alternatives, everything else matches + literally (regex specials are escaped). Examples: `bash|write_file`, + `mcp__*`, `mcp__github__*`. +- `toolNames`: exact-name array. +- The hook runs when **either** matches; omit both to run on every + tool. Lifecycle phases ignore matchers. + +## Chaining + +Hooks of the same phase run serially in **declaration order**: + +- PreToolUse: hook N sees `call.arguments` as rewritten by hook N-1; + a deny stops the chain (later hooks do not run). +- PostToolUse: hook N sees the `result` as rewritten by hook N-1. +- UserPromptSubmit: a deny stops the chain; additionalContext + accumulates per hook. + +## Command protocol + +Each configured hook is a shell command (executed with `shell: true`; +`cwd` defaults to the active workspace, override with the `cwd` +field): + +1. The invocation is written to **stdin** as a single JSON document. +2. **Exit 0**: stdout is parsed as a JSON `HookResult`. Unparseable + plain text becomes additionalContext for UserPromptSubmit and a + message elsewhere. Empty stdout means no-op. +3. **Exit 2**: blocks. PreToolUse / UserPromptSubmit → deny, + PostToolUse → `isError: true`; stderr is the reason. +4. **Any other non-zero exit**: a non-blocking `hook_warning` (stderr + attached). Note this means a hook script crashing (exit 1) does + **not** block the action — to block, exit 2 explicitly or print + `{"decision":"deny"}`. +5. **Timeout**: 60 000ms by default (`timeoutMs` overrides). A timeout + kills the spawned process tree; tool phases treat it as fail-closed + (`hook_failed` tool error), while UserPromptSubmit and observe-only + phases degrade to warnings. + +## Configuration example + +```json +{ + "hooks": [ + { + "phase": "PreToolUse", + "matcher": "bash", + "command": "node ~/.kun-hooks/bash-guard.js", + "timeoutMs": 10000 + }, + { + "phase": "PostToolUse", + "toolNames": ["write_file", "edit_file"], + "command": "~/.kun-hooks/format-after-write.sh" + }, + { "phase": "UserPromptSubmit", "command": "cat ~/.kun-hooks/standing-context.txt" }, + { "phase": "TurnEnd", "command": "~/.kun-hooks/notify-done.sh" } + ] +} +``` + +A minimal bash-guard.js that rejects dangerous commands: + +```js +let raw = '' +process.stdin.on('data', (c) => (raw += c)) +process.stdin.on('end', () => { + const { call } = JSON.parse(raw) + const cmd = String(call.arguments.command ?? '') + if (/rm\s+-rf\s+\//.test(cmd)) { + console.error('blocked: rm -rf on root path') + process.exit(2) + } + process.exit(0) +}) +``` + +## Embedder API (function hooks) + +Callers assembling the runtime as a library can skip the command +protocol and pass in-process functions: + +```ts +import { LocalToolHost } from 'kun/adapters' +import type { ResolvedHook } from 'kun/hooks' + +const hooks: ResolvedHook[] = [ + { + phase: 'PreToolUse', + matcher: 'mcp__*', + run: (invocation) => { + if (invocation.phase !== 'PreToolUse') return + return { arguments: { ...invocation.call.arguments, audited: true } } + } + } +] +new LocalToolHost({ tools, hooks }) +new AgentLoop({ …, hooks }) +``` + +`run` receives the full `HookInvocation` discriminated union — narrow +on `invocation.phase` before reading fields. Function and command +hooks can be mixed; chaining order is the same. + +## Security + +Command hooks execute arbitrary shell commands with the Kun runtime's +privileges. Treat `config.json` as trusted input: never write content +from untrusted sources into the `hooks` array. This matches the trust +model of the MCP `servers` config. + +## Events and observability + +- `hook_denied` — PreToolUse/UserPromptSubmit rejection (error item + + event). +- `hook_failed` — a tool-phase hook crashed or timed out (fail + closed). +- `hook_warning` — non-blocking diagnostics (error events with + severity `warning`): observer crashes, non-zero command exits, + prompt-gate crashes. + +## Tests and source map + +- Engine unit tests: `kun/tests/hooks.test.ts` (matchers, chaining, + exit-code protocol, timeouts, auto-approve). +- Loop integration: `kun/tests/hooks-lifecycle.test.ts` + (TurnStart/TurnEnd payloads, deny persistence, `` + injection, PreCompact, observer fault tolerance). +- Tool host integration point: + `kun/src/adapters/tool/local-tool-host.ts` (pre/post sections of + `execute`). +- Loop integration points: `kun/src/loop/agent-loop.ts` + (`runTurnStartLifecycleHooks` / `runTurnEndHooks` / + `compactIfNeeded`). + +## Known limits and future work + +- No GUI settings editor for hooks yet; configuration is + `config.json` only. +- Subagents reuse the same tool hooks but there is no dedicated + `SubagentStop` phase. +- Non-blocking warnings from tool-phase hooks are not surfaced as + runtime events yet (the tool host has no event recorder); only + lifecycle-phase warnings emit `hook_warning` events. diff --git a/docs/kun-hooks.md b/docs/kun-hooks.md new file mode 100644 index 00000000..24f656ef --- /dev/null +++ b/docs/kun-hooks.md @@ -0,0 +1,259 @@ +# Kun Hooks 体系 + +本文是 Kun agent runtime hook 体系的完整参考:设计动机、六个阶段的 +载荷与能力、外部命令协议、匹配与链式语义、配置方法、嵌入方 API 与 +安全注意事项。读完本文应当可以在不读源码的情况下写出一个可用的 hook。 + +对应实现于 2026-06 重构落地:在此之前 hook 引擎只有 +PreToolUse/PostToolUse 两个阶段,且没有任何配置入口(等价于死代码)。 +重构后 hook 成为 Kun 对外开放的第一类扩展机制,与 MCP、skills、 +memory、subagents 并列。 + +## 设计目标 + +1. **配置即生效**:用户在 `config.json` 顶层写 `hooks` 数组即可, + 不需要重新编译,不需要 GUI 改动。 +2. **协议熟悉**:外部命令协议对齐 Claude Code hooks(stdin JSON、 + 退出码 2 阻断、stdout JSON 结构化结果),降低迁移成本。 +3. **失败语义明确**:安全门失败要关闭(工具阶段),便利性注入失败 + 要放行(prompt 阶段),观察者失败只告警。 +4. **全运行时一致**:主循环、子代理(delegation)、CLI + (`kun serve` / `kun exec`)共用同一套 hook。 + +## 模块与装配链 + +```text +kun/src/hooks/hook-engine.ts # 阶段、载荷、结果类型;匹配器;执行器 +kun/src/hooks/hook-config.ts # config.json 的 zod schema + resolveConfiguredHooks +kun/src/hooks/index.ts # 包导出(kun/hooks 子路径) + +config.json (顶层 hooks 数组) + → KunConfigSchema (kun/src/config/kun-config.ts) + → ServeOptions (kun/src/cli/cli-options.ts, serve.ts) + → KunServeRuntimeOptions (kun/src/server/runtime-factory.ts) + → LocalToolHost(hooks) # 主工具宿主:PreToolUse/PostToolUse + → LocalToolHost(hooks) # 子代理工具宿主:同上 + → AgentLoop(hooks) # 生命周期阶段 +``` + +GUI 通过 `--data-dir` 启动 Kun,`{dataDir}/config.json` 自动加载, +所以 GUI 用户的 hook 配置路径默认是: + +```text +~/.deepseekgui/kun/config.json +``` + +## 六个阶段 + +### PreToolUse(工具调用前,可干预) + +每次工具调用前、审批与执行之前运行。stdin 载荷: + +```json +{ + "phase": "PreToolUse", + "call": { + "callId": "c_…", + "toolName": "bash", + "providerId": "builtin", + "toolKind": "command_execution", + "arguments": { "command": "ls" } + }, + "context": { + "threadId": "th_…", + "turnId": "turn_…", + "workspace": "/path/to/workspace", + "threadMode": "agent", + "approvalPolicy": "on-request", + "sandboxMode": "workspace-write" + } +} +``` + +可返回的结果字段: + +- `{"decision": "deny", "message": "…"}` — 拒绝本次调用。模型收到 + `hook_denied` 错误结果,回合继续。 +- `{"decision": "allow"}` — 自动放行:跳过审批弹窗(等价于用户点了 + 允许)。后续 hook 仍可改写参数或拒绝;任何 deny 覆盖 allow。 +- `{"arguments": {…}}` — **整体替换**工具参数(不做合并)。后续 + hook 与最终执行看到的都是替换后的参数。 + +### PostToolUse(工具调用后,可干预) + +工具执行完成后、结果写回模型之前运行。载荷在 PreToolUse 基础上多一个 +`result`: + +```json +{ "phase": "PostToolUse", "call": …, "context": …, "result": { "output": …, "isError": false } } +``` + +可返回: + +- `{"output": …}` — 替换工具输出(模型看到的就是替换后的值)。 +- `{"isError": true}` — 把结果标记为错误(可与 output 同时给)。 + +### UserPromptSubmit(回合开始前,可干预) + +回合的第一次模型调用之前运行(在 TurnStart 之后)。载荷: + +```json +{ "phase": "UserPromptSubmit", "threadId": "…", "turnId": "…", "prompt": "用户输入原文", "workspace": "/…" } +``` + +可返回: + +- `{"decision": "deny", "message": "…"}` — 拒绝整个回合。回合以 + `failed` 结束,错误项 code 为 `hook_denied`,message 展示给用户。 +- `{"additionalContext": "…"}` — 注入上下文。多个 hook 的注入合并为 + 一条持久化的 `` 用户消息(与用户原始消息分开), + 模型在本回合即可看到。 +- 退出码 0 时的**纯文本 stdout** 在本阶段直接当作 additionalContext + (其他阶段当作 message),所以最简单的注入 hook 就是 + `echo "今天是部署冻结日"`。 + +### TurnStart / TurnEnd / PreCompact(只读通知) + +只观察,不干预。任何返回值除 `message`(转为告警事件)外都被忽略, +hook 崩溃/超时只产生 `hook_warning` 运行时事件,绝不影响回合。 + +载荷: + +```json +{ "phase": "TurnStart", "threadId": "…", "turnId": "…", "prompt": "…", "workspace": "/…" } +{ "phase": "TurnEnd", "threadId": "…", "turnId": "…", "status": "completed|failed|aborted", "error": "…(仅失败时)" } +{ "phase": "PreCompact","threadId": "…", "turnId": "…", "reason": "(人类可读的触发描述)", "mode": "normal|aggressive|force" } +``` + +时序注意:TurnEnd 在回合状态落盘**之后**运行,慢 hook 不会拖慢 GUI +看到回合完成;PreCompact 在压缩计划生成之后、执行之前运行。 + +## 匹配器(仅工具阶段) + +- `matcher`:针对工具名的 glob——`*` 匹配任意字符段,`|` 分隔多个 + 备选,其余字符精确匹配(正则特殊字符已转义)。例: + `bash|write_file`、`mcp__*`、`mcp__github__*`。 +- `toolNames`:精确名单数组。 +- 两者**任一命中即运行**;都省略则匹配所有工具。 +- 生命周期阶段忽略匹配器。 + +## 链式语义 + +同一阶段的多个 hook 按**声明顺序**串行执行: + +- PreToolUse:hook N 看到的是 hook N-1 改写后的 `call.arguments`; + deny 立即终止链(后续 hook 不再运行)。 +- PostToolUse:hook N 看到的是 hook N-1 改写后的 `result`。 +- UserPromptSubmit:deny 立即终止链;additionalContext 逐个累积。 + +## 外部命令协议 + +每个配置型 hook 是一条 shell 命令(`shell: true` 执行,`cwd` 默认为 +当前 workspace,可用 `cwd` 字段覆盖): + +1. invocation 以单个 JSON 文档写入 **stdin**。 +2. **退出码 0**:stdout 按 JSON `HookResult` 解析;解析失败的纯文本 + 在 UserPromptSubmit 当 additionalContext,其余阶段当 message。 + stdout 为空表示无操作。 +3. **退出码 2**:阻断。PreToolUse / UserPromptSubmit → deny, + PostToolUse → `isError: true`;stderr 为原因。 +4. **其他非零退出码**:非阻断 `hook_warning`(stderr 附带)。 + 注意:这意味着 hook 脚本自身崩溃(exit 1)**不会**阻断动作—— + 要阻断必须显式 exit 2 或输出 `{"decision":"deny"}`。 +5. **超时**:默认 60 000ms(`timeoutMs` 覆盖)。超时杀整棵进程树; + 工具阶段超时按失败关闭处理(`hook_failed` 工具错误), + UserPromptSubmit 与只读阶段超时降级为告警。 + +## 配置示例 + +```json +{ + "hooks": [ + { + "phase": "PreToolUse", + "matcher": "bash", + "command": "node ~/.kun-hooks/bash-guard.js", + "timeoutMs": 10000 + }, + { + "phase": "PostToolUse", + "toolNames": ["write_file", "edit_file"], + "command": "~/.kun-hooks/format-after-write.sh" + }, + { "phase": "UserPromptSubmit", "command": "cat ~/.kun-hooks/standing-context.txt" }, + { "phase": "TurnEnd", "command": "~/.kun-hooks/notify-done.sh" } + ] +} +``` + +bash-guard.js 拒绝危险命令的最小实现: + +```js +let raw = '' +process.stdin.on('data', (c) => (raw += c)) +process.stdin.on('end', () => { + const { call } = JSON.parse(raw) + const cmd = String(call.arguments.command ?? '') + if (/rm\s+-rf\s+\//.test(cmd)) { + console.error('blocked: rm -rf on root path') + process.exit(2) + } + process.exit(0) +}) +``` + +## 嵌入方 API(函数 hook) + +以库方式组装运行时的调用方可以绕过命令协议,直接传进程内函数: + +```ts +import { LocalToolHost } from 'kun/adapters' +import type { ResolvedHook } from 'kun/hooks' + +const hooks: ResolvedHook[] = [ + { + phase: 'PreToolUse', + matcher: 'mcp__*', + run: (invocation) => { + if (invocation.phase !== 'PreToolUse') return + return { arguments: { ...invocation.call.arguments, audited: true } } + } + } +] +new LocalToolHost({ tools, hooks }) +new AgentLoop({ …, hooks }) +``` + +`run` 收到完整的判别联合 `HookInvocation`,先用 `invocation.phase` +收窄再取字段。函数 hook 与命令 hook 可以混用,链式顺序一致。 + +## 安全 + +命令 hook 以 Kun runtime 的权限执行任意 shell 命令。`config.json` +必须当作可信输入:不要把不可信来源的内容写进 `hooks` 数组。这与 +MCP `servers` 配置的信任模型一致。 + +## 事件与可观测性 + +- `hook_denied` — PreToolUse/UserPromptSubmit 拒绝(错误项 + 事件)。 +- `hook_failed` — 工具阶段 hook 崩溃或超时(fail closed)。 +- `hook_warning` — 非阻断告警(severity `warning` 的 error 事件): + 观察者崩溃、命令非零退出、prompt gate 崩溃等。 + +## 测试与源码 + +- 引擎单测:`kun/tests/hooks.test.ts`(匹配器、链式、退出码协议、 + 超时、auto-approve)。 +- 循环集成:`kun/tests/hooks-lifecycle.test.ts`(TurnStart/TurnEnd + 载荷、deny 落盘、`` 注入、PreCompact、观察者容错)。 +- 工具宿主接入点:`kun/src/adapters/tool/local-tool-host.ts` + (`execute` 的 pre/post 段)。 +- 循环接入点:`kun/src/loop/agent-loop.ts` + (`runTurnStartLifecycleHooks` / `runTurnEndHooks` / `compactIfNeeded`)。 + +## 已知边界与后续方向 + +- GUI 设置页暂无 hooks 编辑界面,配置走 `config.json`。 +- 子代理复用同一套工具 hook,但没有独立的 `SubagentStop` 阶段。 +- 工具阶段 hook 的非阻断告警目前不产生运行时事件(工具宿主没有事件 + 记录器),只有生命周期阶段的告警会以 `hook_warning` 事件浮出。 diff --git a/docs/model-provider-presets.md b/docs/model-provider-presets.md new file mode 100644 index 00000000..c2e7206b --- /dev/null +++ b/docs/model-provider-presets.md @@ -0,0 +1,69 @@ +# Model Provider Presets + +## Context + +DeepChat handles model suppliers in two layers: + +- A default provider catalog stores provider id, display name, base URL, API type, + documentation links, and whether the provider is enabled. +- A runtime registry maps each provider or API type to a request protocol such as + OpenAI-compatible chat completions or Anthropic messages. + +Kun already has the runtime half in a smaller form. Settings store +`provider.providers[]`, the active Kun runtime stores `providerId`, and the +runtime resolves the selected provider into API key, base URL, and endpoint +format. The model endpoint formats already cover OpenAI Chat Completions, +OpenAI Responses, and Anthropic Messages. + +## Design + +Do not add a second runtime or a DeepChat-style provider presenter. Add a small +shared provider preset catalog that produces existing `ModelProviderProfileV1` +objects. + +The Settings > Providers panel should let users: + +- add a blank custom provider as before, +- add a known preset provider, +- select the newly added preset as the active Kun provider, +- keep provider fields editable after creation, +- configure optional image-generation capabilities on a provider. + +Preset providers remain opt-in because this project does not have a separate +enabled/disabled provider flag. Adding every known provider by default would +make all of their models appear in the composer before credentials are set. + +## Built-in Providers + +DeepSeek: + +- id: `deepseek` +- base URL: `https://api.deepseek.com` +- endpoint format: OpenAI Chat Completions compatible +- default models: `deepseek-v4-pro`, `deepseek-v4-flash` +- compatibility aliases: `deepseek-chat`, `deepseek-reasoner` +- role: default text/reasoning provider for first-run setup and existing installs + +Xiaomi: + +- id: `xiaomi` +- base URL: `https://api.xiaomimimo.com/v1` +- endpoint format: OpenAI Chat Completions +- initial models: `mimo-v2-omni`, `mimo-v2.5-pro-ultraspeed`, + `mimo-v2-pro`, `mimo-v2.5`, `mimo-v2-flash`, `mimo-v2.5-pro` + +MiniMax: + +- id: `minimax` +- base URL: `https://api.minimaxi.com/anthropic` +- endpoint format: Anthropic Messages +- initial models: `MiniMax-M2.5`, `MiniMax-M3`, + `MiniMax-M2.5-highspeed`, `MiniMax-M2.7`, `MiniMax-M2`, + `MiniMax-M2.7-highspeed`, `MiniMax-M2.1` +- image protocol: MiniMax `/v1/image_generation` +- image base URL: `https://api.minimaxi.com` +- image models: `image-01` + +The defaults are not locked. Users can edit base URLs, protocols, and model IDs +if provider endpoints change, and they can add custom compatible providers at +any time. diff --git a/docs/tiptap-migration.md b/docs/tiptap-migration.md new file mode 100644 index 00000000..c4471b37 --- /dev/null +++ b/docs/tiptap-migration.md @@ -0,0 +1,85 @@ +# Tiptap 富文本编辑器迁移记录 + +状态:Phase 0–5 已完成。写作工作区、SDD 需求草稿、计划面板默认使用 Tiptap 富文本 +模式(`rich`),CodeMirror 保留为源码模式与保真门禁/大文件兜底。本文档记录技术 +验证结论与架构决策。 + +## Phase 0 结论:GO(带强制保真门禁) + +验证环境:`@tiptap/*` 3.26.0(MIT),`@tiptap/markdown` 官方双向转换包(基于 marked,GFM 开启)。 + +### 往返保真审计(`scripts/tiptap-roundtrip-audit.mjs`) + +对仓库 33 个真实 md 文件做 `parse → serialize → parse → serialize` 审计: + +| 指标 | 结果 | +| --- | --- | +| 稳定(一轮后幂等) | 22/33 | +| 字节级一致 | 10/33 | +| 纯文本无损 | 22/33 | + +已确认的上游问题(3.26.0): + +1. **有序列表内硬换行续行会吃字符**(文本丢失,最严重)。`2. ... a\n new port` 序列化为 1 空格缩进续行,再解析时丢失字符。WYSIWYG 编辑不会产生这种文档形态,仅影响打开外部手写文件。 +2. **原生 HTML 块降级为转义字面文本**,且字面文本中的 URL 会在下一轮解析时被 GFM 自动链接,导致不收敛。禁用 marked 的 `url` tokenizer 无效(falsy 回落默认行为)。 +3. **行内代码相邻文本的下划线被错误转义**(`tool_call` → `tool\_call`)。 +4. 表格列宽重排、列表缩进归一化等纯格式噪音(CJK 表格本身稳定收敛)。 + +简单/LLM 风格 markdown(标题、段落、列表、任务列表、代码块、GFM 表格、图片、链接、粗斜体、引用、CJK)**完美稳定往返**。 + +### Headless spike + +- `getSchema([StarterKit, ...])` 无 DOM 构建 schema ✅ +- ghost text 插件状态机(PluginKey meta 设置建议 → docChanged 清除 → mapping 映射位置)在纯 node 环境可测 ✅ +- `MarkdownManager.parse/serialize` 不依赖 Editor 实例与 DOM ✅ + +### 架构决策 + +1. **双引擎**:Tiptap 为 `rich` 模式;CodeMirror 永久保留为 `source` 模式与兜底。 +2. **逐文件保真门禁**:打开文件时跑 `serialize(parse(md))` 幂等性 + 纯文本无损检查。不通过的文件 rich 模式拒绝编辑(横幅提示 + 引导切 source 模式)。 +3. **只有用户真实编辑过的文档才允许序列化落盘**;纯浏览绝不回写。 +4. **行内 AI 全部自研**(ProseMirror decoration/plugin),不使用 Tiptap 付费 AI 产品;主进程 LLM 服务与 IPC 契约不变。 +5. `@tiptap/*` 版本锁定,升级须重跑审计脚本。 + +## 模块布局(实际落地) + +``` +src/renderer/src/write/tiptap/ + markdown-manager.ts 解析/序列化单例 + auditWriteMarkdownFidelity 保真门禁 + markdown-projection.ts markdown 投影(块级语法前缀 + 纯文本)+ 偏移↔PM 位置互转 + markdown-sync.ts 外部快照的块级 diff 局部同步(保光标/撤销栈/防回写循环) + markdown-insert.ts markdown 字符串 → PM 节点的区间替换工具 + recent-edits-pm.ts PM 事务 → recent-edit 记录(投影坐标) + local-image.ts 本地图片 NodeView(相对路径 → file://,原始 src 保留) + paste-image.ts 剪贴板图片粘贴(复用 saveWorkspaceClipboardImage IPC) + WriteRichEditor.tsx 与 WriteMarkdownEditor 同构 props 的编辑器组件 + extensions/ + inline-completion.ts ghost text 补全(移植 CM 编排:防抖/冷却/Tab/Esc/edit 预览) + term-propagation.ts 术语联动(appendTransaction) + template-shortcuts.ts @date 等模板 Tab 展开 +``` + +## 关键机制 + +- **坐标系**:行内 AI、选区引用、行内编辑共用「markdown 投影」坐标。投影通过 + CodeMirror `EditorState` 喂给现有的 `buildInlineCompletionRequestContext`, + 策略/评分/payload 模块零重复复用;服务端 IPC 契约未变。 +- **保真门禁**:每个从外部进入编辑器的文档(打开/盘上同步)都跑幂等性 + 纯文本 + 无损审计;不通过则渲染 `fallback`(CodeMirror live 编辑器)并显示横幅。 + 自身序列化输出不再复审。 +- **外部回流**:agent 改盘上文件后,按顶层块 diff 做最小替换事务(meta 标记 + `writeRichExternalSyncMeta`),不进 undo 历史、不触发 onChange 回写。 +- **接入点**:写作工作区(模式菜单新增「富文本」,render-safety 大文件自动回落 + 源码模式)、SDD 草稿、计划面板。`readStoredPreviewMode` 默认 `'rich'`, + 用户显式选择的模式仍然尊重 localStorage。 +- **行内编辑**:rich 模式下 `submitInlineEdit` 用投影文本构建请求,经 + `WriteRichEditorHandle.applyProjectedReplacement` 应用(带原文校验与 + markdown 解析插入)。 + +## 已知限制 / 后续 + +- 投影不含行内标记(`**`、`` ` ``、链接语法),补全上下文中当前行少量标记缺失; + 选区引用行号为投影行号,与文件行号在复杂文档上可能略有偏差。 +- 代码块暂为纯样式 `
`(无 shiki 高亮 NodeView)。
+- 中文 IME 与 ghost text 的交互需在真机专项手测。
+- `@tiptap/*` 固定 3.26.0;升级前必须重跑 `scripts/tiptap-roundtrip-audit.mjs`。
diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs
index ce7be480..d7648a38 100644
--- a/electron-builder.config.cjs
+++ b/electron-builder.config.cjs
@@ -1,9 +1,17 @@
 const { existsSync, readFileSync } = require('node:fs')
 const { join } = require('node:path')
 
+// 品牌升级后构建环境变量改用 KUN_* 前缀;旧的 DEEPSEEK_GUI_* 仍然
+// 兼容读取,避免 CI / 本地发布脚本一刀切失效。
+function envWithLegacyFallback(kunName, legacyName) {
+  const value = process.env[kunName]
+  if (value !== undefined && value !== '') return value
+  return process.env[legacyName]
+}
+
 function loadLocalReleaseEnv() {
   const candidates = [
-    process.env.DEEPSEEK_GUI_RELEASE_ENV,
+    envWithLegacyFallback('KUN_RELEASE_ENV', 'DEEPSEEK_GUI_RELEASE_ENV'),
     join(__dirname, 'scripts', 'release.local.env'),
     join(__dirname, 'release.local.env')
   ].filter(Boolean)
@@ -43,32 +51,45 @@ const hasNotaryToolCredentials = Boolean(
     (process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_BASE64)
 )
 
-const r2PublicBaseUrl = (process.env.R2_PUBLIC_BASE_URL || 'https://deepseek-gui.com/api/r2')
+// R2 release prefix 维持旧值不动:线上老版本轮询的就是
+// `…/deepseek-gui/channels//latest/`,prefix 一改老客户端就再也
+// 收不到更新。默认公开域名优先使用 kun-agent,运行时仍会兜底旧域名。
+const r2PublicBaseUrl = (process.env.R2_PUBLIC_BASE_URL || 'https://www.kun-agent.com/api/r2')
   .trim()
   .replace(/\/+$/, '')
 const r2ReleasePrefix = (process.env.R2_RELEASE_PREFIX || 'deepseek-gui')
   .trim()
   .replace(/^\/+|\/+$/g, '')
-const updateChannel = normalizeUpdateChannel(process.env.DEEPSEEK_GUI_UPDATE_CHANNEL || 'stable')
+const updateChannel = normalizeUpdateChannel(
+  envWithLegacyFallback('KUN_UPDATE_CHANNEL', 'DEEPSEEK_GUI_UPDATE_CHANNEL') || 'stable'
+)
 const genericUpdateUrl = `${r2PublicBaseUrl}/${r2ReleasePrefix}/channels/${updateChannel}/latest/`
-const releaseAppVersion = (process.env.DEEPSEEK_GUI_APP_VERSION || '').trim()
+const releaseAppVersion = (
+  envWithLegacyFallback('KUN_APP_VERSION', 'DEEPSEEK_GUI_APP_VERSION') || ''
+).trim()
 const artifactVersion = releaseAppVersion || '${version}'
 
 function normalizeUpdateChannel(raw) {
   const value = String(raw || '').trim()
   if (value === 'stable' || value === 'frontier') return value
-  throw new Error(`DEEPSEEK_GUI_UPDATE_CHANNEL must be "stable" or "frontier", got: ${raw}`)
+  throw new Error(`KUN_UPDATE_CHANNEL (or legacy DEEPSEEK_GUI_UPDATE_CHANNEL) must be "stable" or "frontier", got: ${raw}`)
 }
 
 if (releaseAppVersion && !/^\d+\.\d+\.\d+$/.test(releaseAppVersion)) {
   throw new Error(
-    `DEEPSEEK_GUI_APP_VERSION must be a valid x.y.z semver for electron-updater, got: ${releaseAppVersion}`
+    `KUN_APP_VERSION (or legacy DEEPSEEK_GUI_APP_VERSION) must be a valid x.y.z semver for electron-updater, got: ${releaseAppVersion}`
   )
 }
 
 module.exports = {
+  // appId 永远保持旧值,即使品牌已改名 Kun:
+  //  - macOS 端 Squirrel.Mac 校验更新包签名时锚定 bundle identifier,
+  //    换了 id 老版本会拒绝安装新版本;
+  //  - Windows 端 NSIS 以 appId 派生卸载 GUID,换了 id 升级安装不会
+  //    卸载旧版本,用户会装出两份应用;
+  //  - macOS TCC 权限、通知授权也都挂在这个 id 上。
   appId: 'com.xingyuzhong.deepseekgui',
-  productName: 'DeepSeek GUI',
+  productName: 'Kun',
   asar: true,
   asarUnpack: [
     '**/kun/dist/**/*',
@@ -80,7 +101,7 @@ module.exports = {
   ],
   npmRebuild: true,
   directories: {
-    output: process.env.DEEPSEEK_GUI_DIST_DIR || 'dist'
+    output: envWithLegacyFallback('KUN_DIST_DIR', 'DEEPSEEK_GUI_DIST_DIR') || 'dist'
   },
   files: [
     'out/**/*',
@@ -94,10 +115,12 @@ module.exports = {
     '!**/*.ts',
     '!**/tsconfig*.json',
     '!**/README*',
-    '!**/CHANGELOG*',
-    '!**/node_modules/openclaw/**/*'
+    '!**/CHANGELOG*'
+    // node_modules/openclaw (the vendor/openclaw-shim file: dep) must ship:
+    // the WeChat bridge imports @tencent-weixin/openclaw-weixin/dist at
+    // runtime to send media, and that chain resolves openclaw/plugin-sdk/*.
   ],
-  artifactName: `DeepSeek-GUI-${artifactVersion}-\${os}-\${arch}.\${ext}`,
+  artifactName: `Kun-${artifactVersion}-\${os}-\${arch}.\${ext}`,
   publish: [
     {
       provider: 'generic',
@@ -117,7 +140,12 @@ module.exports = {
     gatekeeperAssess: false,
     entitlements: 'build/entitlements.mac.plist',
     entitlementsInherit: 'build/entitlements.mac.inherit.plist',
-    icon: './src/asset/img/deepseek.png',
+    extendInfo: {
+      // 语音输入:渲染进程通过 getUserMedia 录音做语音转文字。
+      NSMicrophoneUsageDescription: 'Kun uses the microphone for voice-to-text input.'
+    },
+    // macOS 不会自动套圆角遮罩,图标文件本身需要是「圆角方块 + 透明边距」
+    icon: './src/asset/img/kun_mac.png',
     // arm64 (Apple Silicon) + x64 (Intel). On M 系列 Mac 本地打包会各出一组 dmg/zip。
     target: [
       { target: 'dmg', arch: ['arm64', 'x64'] },
@@ -128,7 +156,7 @@ module.exports = {
     sign: hasExplicitMacSigningIdentity
   },
   win: {
-    icon: './src/asset/img/deepseek.png',
+    icon: './src/asset/img/kun.png',
     target: [{ target: 'nsis', arch: ['x64'] }]
   },
   nsis: {
@@ -140,13 +168,13 @@ module.exports = {
     // 明确创建快捷方式;always 在覆盖安装时也会重建(即使用户曾删掉桌面图标)
     createDesktopShortcut: 'always',
     createStartMenuShortcut: true,
-    shortcutName: 'DeepSeek GUI',
-    uninstallDisplayName: 'DeepSeek GUI',
+    shortcutName: 'Kun',
+    uninstallDisplayName: 'Kun',
     deleteAppDataOnUninstall: false
   },
   linux: {
     category: 'Development',
-    icon: './src/asset/img/deepseek.png',
+    icon: './src/asset/img/kun.png',
     target: [{ target: 'AppImage', arch: ['x64'] }]
   },
   extraMetadata: {
diff --git a/examples/ui-plugins/starlight/img/bird.png b/examples/ui-plugins/starlight/img/bird.png
new file mode 100644
index 00000000..e69aeb30
Binary files /dev/null and b/examples/ui-plugins/starlight/img/bird.png differ
diff --git a/examples/ui-plugins/starlight/img/greet.png b/examples/ui-plugins/starlight/img/greet.png
new file mode 100644
index 00000000..0f32a399
Binary files /dev/null and b/examples/ui-plugins/starlight/img/greet.png differ
diff --git a/examples/ui-plugins/starlight/img/sit.png b/examples/ui-plugins/starlight/img/sit.png
new file mode 100644
index 00000000..ae69504c
Binary files /dev/null and b/examples/ui-plugins/starlight/img/sit.png differ
diff --git a/examples/ui-plugins/starlight/img/sleep.png b/examples/ui-plugins/starlight/img/sleep.png
new file mode 100644
index 00000000..4fba01c9
Binary files /dev/null and b/examples/ui-plugins/starlight/img/sleep.png differ
diff --git a/examples/ui-plugins/starlight/img/surf.png b/examples/ui-plugins/starlight/img/surf.png
new file mode 100644
index 00000000..e5ac9dee
Binary files /dev/null and b/examples/ui-plugins/starlight/img/surf.png differ
diff --git a/examples/ui-plugins/starlight/manifest.json b/examples/ui-plugins/starlight/manifest.json
new file mode 100644
index 00000000..963923fa
--- /dev/null
+++ b/examples/ui-plugins/starlight/manifest.json
@@ -0,0 +1,44 @@
+{
+  "id": "starlight",
+  "name": "星夜 Kun",
+  "version": "1.0.0",
+  "author": "Kun Team",
+  "description": "官方示例插件:深紫星夜配色的 Kun 形象,含主题色与进行中文案。",
+  "figures": {
+    "swim": "img/bird.png",
+    "surf": "img/surf.png",
+    "greet": "img/greet.png",
+    "sleep": "img/sleep.png",
+    "sit": "img/sit.png",
+    "toggleIcon": "img/greet.png"
+  },
+  "labels": {
+    "zh": {
+      "working": "巡航中…",
+      "workingSprint": "流星冲刺中…",
+      "workingDive": "潜入星海中…",
+      "workingSurf": "星浪滑行中…"
+    },
+    "en": {
+      "working": "Stargazing…",
+      "workingSprint": "Meteor dash…",
+      "workingDive": "Deep diving…",
+      "workingSurf": "Riding stardust…"
+    }
+  },
+  "tokens": {
+    "light": {
+      "--ds-accent": "#7a5fd0",
+      "--ds-accent-soft": "rgba(122, 95, 208, 0.15)",
+      "--ds-selection": "rgba(122, 95, 208, 0.2)"
+    },
+    "dark": {
+      "--ds-accent": "#a78ff0",
+      "--ds-accent-soft": "rgba(167, 143, 240, 0.2)",
+      "--ds-selection": "rgba(167, 143, 240, 0.26)"
+    }
+  },
+  "features": {
+    "cameos": true
+  }
+}
diff --git a/kun/README.md b/kun/README.md
index a1053ab0..28d4c8a9 100644
--- a/kun/README.md
+++ b/kun/README.md
@@ -1,6 +1,6 @@
 # Kun
 
-Kun is the local HTTP/SSE agent runtime for DeepSeek-GUI. It exposes a
+Kun is the local HTTP/SSE agent runtime for the Kun desktop app. It exposes a
 TypeScript-typed agent loop with a stable, GUI-friendly contract:
 
 - `kun serve` starts a local HTTP server with `/v1/*` routes.
@@ -11,7 +11,7 @@ TypeScript-typed agent loop with a stable, GUI-friendly contract:
 
 The name Kun is inspired by the great fish in Zhuangzi's line,
 "In the northern sea there is a fish; its name is Kun." In
-DeepSeek-GUI, it means a deeper local runtime rather than a thin model
+this project, it means a deeper local runtime rather than a thin model
 UI: one agent loop that can carry project context, call tools
 reliably, resume sessions, and serve desktop chat, writing, phone
 connections, and scheduled tasks.
@@ -272,6 +272,69 @@ Use `GET /v1/runtime/info` for the runtime capability manifest and
 `GET /v1/runtime/tools` for redacted provider diagnostics. The GUI
 Settings page reads both routes.
 
+## Hooks
+
+Hooks let external commands observe and intervene in the agent
+lifecycle without rebuilding Kun. They are configured under the
+top-level `hooks` key in `config.json` (so the GUI's
+`~/.deepseekgui/kun/config.json` works out of the box) and run inside
+the serve runtime — main loop, subagents, and CLI alike.
+
+```json
+{
+  "hooks": [
+    {
+      "phase": "PreToolUse",
+      "matcher": "bash|write_file|mcp__*",
+      "command": "node ~/.kun-hooks/guard.js",
+      "timeoutMs": 10000
+    },
+    { "phase": "UserPromptSubmit", "command": "~/.kun-hooks/prompt-context.sh" },
+    { "phase": "TurnEnd", "command": "~/.kun-hooks/notify.sh" }
+  ]
+}
+```
+
+Phases:
+
+- `PreToolUse` — before every tool call. May rewrite `arguments`, deny
+  the call, or auto-approve it (skip the approval prompt).
+- `PostToolUse` — after every tool call. May replace `output` or mark
+  the result as an error.
+- `UserPromptSubmit` — before the first model step of a turn. May deny
+  the turn or inject `additionalContext`, which is persisted as an
+  extra `` user message.
+- `TurnStart`, `TurnEnd`, `PreCompact` — observe-only notifications.
+  Failures surface as `hook_warning` runtime events and never break
+  the turn.
+
+Matching: `matcher` is a glob over the tool name (`*` wildcard, `|`
+alternation); `toolNames` is an exact-name list. Either match runs the
+hook; omit both to run on every tool. Lifecycle phases ignore matchers.
+
+Command protocol: the hook receives the invocation as JSON on stdin
+(`phase` plus phase-specific fields such as `call`, `result`, `prompt`,
+`status`, `reason`). Exit `0` parses stdout as a JSON result
+(`{"decision":"deny"}`, `{"arguments":{...}}`, `{"output":...}`,
+`{"additionalContext":"..."}`); plain-text stdout becomes
+`additionalContext` for `UserPromptSubmit` and a message elsewhere.
+Exit `2` blocks the action with stderr as the reason. Any other exit
+code is a non-blocking `hook_warning`. The default timeout is 60s
+(`timeoutMs` overrides); a timed-out hook fails the tool call closed
+but never blocks observe-only phases.
+
+Hooks chain in declaration order: each hook sees the call or result as
+rewritten by the hooks before it. Embedders that assemble the runtime
+programmatically can also pass in-process function hooks via the
+`hooks` option of `LocalToolHost` and `AgentLoop` (exported from
+`kun/hooks`).
+
+Command hooks execute arbitrary shell commands with the runtime's
+privileges — treat `config.json` as trusted input.
+
+See `../docs/kun-hooks.en.md` for the full reference: per-phase stdin
+payloads, result fields, failure semantics, and example hook scripts.
+
 ## Data directory layout
 
 `--data-dir` is the on-disk root for everything the runtime owns:
@@ -413,7 +476,7 @@ stay local to one thread, leave it as a pinned constraint.
 
 ## GUI integration
 
-After the legacy provider retirement, the DeepSeek-GUI main process
+After the legacy provider retirement, the desktop app main process
 starts Kun through `kun-process.ts` and routes all
 `runtimeRequest` calls to the active base URL with a bearer token.
 The renderer uses the same `AgentProvider` interface as the legacy
diff --git a/kun/README.zh-CN.md b/kun/README.zh-CN.md
index 7440c4d3..d165695a 100644
--- a/kun/README.zh-CN.md
+++ b/kun/README.zh-CN.md
@@ -1,12 +1,12 @@
 # Kun
 
-Kun 是 DeepSeek-GUI 的本地 HTTP/SSE 代理运行时。它为 GUI 提供稳定、类型化且 GUI 友好的代理循环合约:
+Kun 是同名桌面应用的本地 HTTP/SSE 代理运行时。它为 GUI 提供稳定、类型化且 GUI 友好的代理循环合约:
 
 - `kun serve` 会启动一个本地 HTTP 服务器,并暴露 `/v1/*` 路由。
 - 线程、回合(turn)、事件、审批和用量都会以追加写入的 JSONL 日志持久化,并配合原子化索引更新。
 - Agent 循环采用 cache-first 设计:不可变的 prompt 前缀、边界受限的 TTL/LRU 缓存、inflight 跟踪,以及显式上下文压缩。
 
-Kun 取意于《庄子·逍遥游》中的“北冥有鱼,其名为鲲”。在 DeepSeek-GUI
+Kun 取意于《庄子·逍遥游》中的“北冥有鱼,其名为鲲”。在本项目
 里,它代表一个更深的本地运行时:不是把模型回复包一层 UI,而是让模型可以
 长期携带项目上下文、稳定调用工具、恢复会话,并在桌面、写作、手机连接和
 定时任务之间复用同一套 agent loop。
@@ -248,6 +248,61 @@ Kun 默认使用混合存储:`threads/{threadId}/messages.jsonl` 与 `events.j
 在渲染端使用 `GET /v1/runtime/info` 获取运行时能力清单,使用
 `GET /v1/runtime/tools` 查看 provider 诊断。GUI 设置页会读取这两条接口。
 
+## Hooks(钩子)
+
+Hooks 允许外部命令观察并干预 agent 生命周期,无需重新编译 Kun。在
+`config.json` 顶层 `hooks` 键下配置(GUI 默认的
+`~/.deepseekgui/kun/config.json` 直接生效),主循环、子代理和 CLI
+共用同一套 hook。
+
+```json
+{
+  "hooks": [
+    {
+      "phase": "PreToolUse",
+      "matcher": "bash|write_file|mcp__*",
+      "command": "node ~/.kun-hooks/guard.js",
+      "timeoutMs": 10000
+    },
+    { "phase": "UserPromptSubmit", "command": "~/.kun-hooks/prompt-context.sh" },
+    { "phase": "TurnEnd", "command": "~/.kun-hooks/notify.sh" }
+  ]
+}
+```
+
+阶段:
+
+- `PreToolUse` — 每次工具调用前。可改写 `arguments`、拒绝调用,或
+  自动放行(跳过审批弹窗)。
+- `PostToolUse` — 每次工具调用后。可替换 `output` 或把结果标记为错误。
+- `UserPromptSubmit` — 回合首次模型调用前。可拒绝整个回合,或注入
+  `additionalContext`(持久化为一条 `` 用户消息)。
+- `TurnStart`、`TurnEnd`、`PreCompact` — 只读通知。失败只产生
+  `hook_warning` 运行时事件,绝不影响回合。
+
+匹配:`matcher` 是针对工具名的 glob(`*` 通配,`|` 多选);`toolNames`
+是精确名单。两者任一命中即运行;都省略则匹配所有工具。生命周期阶段
+忽略匹配器。
+
+命令协议:invocation 以 JSON 写入 stdin(`phase` 加各阶段字段,如
+`call`、`result`、`prompt`、`status`、`reason`)。退出码 `0` 时 stdout
+按 JSON 结果解析(`{"decision":"deny"}`、`{"arguments":{...}}`、
+`{"output":...}`、`{"additionalContext":"..."}`);纯文本 stdout 在
+`UserPromptSubmit` 中作为 `additionalContext`,其余阶段作为 message。
+退出码 `2` 阻断动作,stderr 为原因。其他非零退出码只产生非阻断的
+`hook_warning`。默认超时 60 秒(`timeoutMs` 可覆盖);工具阶段超时按
+失败关闭处理,只读阶段超时不会阻断。
+
+Hooks 按声明顺序链式执行:每个 hook 看到的是前面 hook 改写后的调用或
+结果。以库方式嵌入运行时的调用方还可以通过 `LocalToolHost` 与
+`AgentLoop` 的 `hooks` 选项传入进程内函数 hook(从 `kun/hooks` 导出)。
+
+命令 hook 以运行时权限执行任意 shell 命令——请把 `config.json` 当作
+可信输入对待。
+
+完整参考见 `../docs/kun-hooks.md`:各阶段 stdin 载荷、结果字段、
+失败语义与示例 hook 脚本。
+
 ## 数据目录布局
 
 `--data-dir` 即运行时所管理的一切磁盘根目录:
diff --git a/kun/config.example.json b/kun/config.example.json
index a4c32632..58e8c515 100644
--- a/kun/config.example.json
+++ b/kun/config.example.json
@@ -112,5 +112,6 @@
       "scopes": ["user", "workspace", "project"],
       "maxInjectedRecords": 8
     }
-  }
+  },
+  "hooks": []
 }
diff --git a/kun/package-lock.json b/kun/package-lock.json
index 022a8d8b..ca6f4212 100644
--- a/kun/package-lock.json
+++ b/kun/package-lock.json
@@ -7,6 +7,7 @@
     "": {
       "name": "kun",
       "version": "0.1.0",
+      "license": "PolyForm-Noncommercial-1.0.0",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.29.0",
         "better-sqlite3": "^12.10.0",
diff --git a/kun/package.json b/kun/package.json
index bd62c551..24415dba 100644
--- a/kun/package.json
+++ b/kun/package.json
@@ -1,7 +1,8 @@
 {
   "name": "kun",
   "version": "0.1.0",
-  "description": "Kun local HTTP/SSE agent runtime for DeepSeek-GUI",
+  "description": "Kun local HTTP/SSE agent runtime",
+  "license": "PolyForm-Noncommercial-1.0.0",
   "type": "module",
   "private": true,
   "main": "./dist/index.js",
@@ -45,6 +46,10 @@
     "./services": {
       "types": "./dist/services/index.d.ts",
       "import": "./dist/services/index.js"
+    },
+    "./hooks": {
+      "types": "./dist/hooks/index.d.ts",
+      "import": "./dist/hooks/index.js"
     }
   },
   "bin": {
diff --git a/kun/src/adapters/file/file-session-store.ts b/kun/src/adapters/file/file-session-store.ts
index 3420d0a3..81ea862e 100644
--- a/kun/src/adapters/file/file-session-store.ts
+++ b/kun/src/adapters/file/file-session-store.ts
@@ -11,6 +11,13 @@ const DEFAULT_USAGE_EVENT_COMPACTION_MAX_BYTES = 5 * 1024 * 1024
 const DEFAULT_USAGE_EVENT_RETENTION_DAYS = 365
 const MS_PER_DAY = 86_400_000
 
+/**
+ * The agent loop reloads the full item history on every model step, so
+ * keep the deduped array for recently touched threads in memory instead
+ * of re-reading and re-parsing messages.jsonl each time.
+ */
+const ITEMS_CACHE_MAX_THREADS = 4
+
 /**
  * File-backed session store. Appends events and items to per-thread
  * JSONL files and keeps the canonical session snapshot in a small
@@ -23,6 +30,8 @@ export class FileSessionStore implements SessionStore {
     retentionDays: number
     nowIso: () => string
   }
+  private readonly itemsCache = new Map()
+  private readonly itemsCacheVersion = new Map()
 
   constructor(options: {
     dataDir: string
@@ -61,12 +70,16 @@ export class FileSessionStore implements SessionStore {
     await this.ensureDir(this.threadDir(threadId))
     const path = this.messagesPath(threadId)
     await appendFile(path, `${JSON.stringify(item)}\n`, 'utf-8')
+    this.bumpItemsVersion(threadId)
+    this.applyItemToCache(threadId, item)
   }
 
   async rewriteItems(threadId: string, items: TurnItem[]): Promise {
     await this.ensureDir(this.threadDir(threadId))
     const contents = items.map((item) => JSON.stringify(item)).join('\n')
     await this.atomicWrite(this.messagesPath(threadId), contents ? `${contents}\n` : '')
+    this.bumpItemsVersion(threadId)
+    this.cacheItems(threadId, [...items])
   }
 
   async updateItem(threadId: string, itemId: string, patch: Partial): Promise {
@@ -76,6 +89,8 @@ export class FileSessionStore implements SessionStore {
     const updated = { ...current, ...patch } as TurnItem
     await this.ensureDir(this.threadDir(threadId))
     await appendFile(this.messagesPath(threadId), `${JSON.stringify(updated)}\n`, 'utf-8')
+    this.bumpItemsVersion(threadId)
+    this.applyItemToCache(threadId, updated)
     return updated
   }
 
@@ -87,6 +102,12 @@ export class FileSessionStore implements SessionStore {
   }
 
   async loadItems(threadId: string): Promise {
+    const cached = this.itemsCache.get(threadId)
+    if (cached) {
+      this.cacheItems(threadId, cached)
+      return [...cached]
+    }
+    const version = this.itemsVersionOf(threadId)
     const raw = await readJsonl(this.messagesPath(threadId))
     const latestById = new Map()
     for (const item of raw) {
@@ -100,6 +121,11 @@ export class FileSessionStore implements SessionStore {
       seen.add(item.id)
       ordered.unshift(latestById.get(item.id)!)
     }
+    // A write that landed while we were reading invalidates this snapshot.
+    if (this.itemsVersionOf(threadId) === version) {
+      this.cacheItems(threadId, ordered)
+      return [...ordered]
+    }
     return ordered
   }
 
@@ -123,7 +149,34 @@ export class FileSessionStore implements SessionStore {
   }
 
   async resetMemory(): Promise {
-    // File-backed store has no in-memory state to reset.
+    this.itemsCache.clear()
+    this.itemsCacheVersion.clear()
+  }
+
+  private itemsVersionOf(threadId: string): number {
+    return this.itemsCacheVersion.get(threadId) ?? 0
+  }
+
+  private bumpItemsVersion(threadId: string): void {
+    this.itemsCacheVersion.set(threadId, this.itemsVersionOf(threadId) + 1)
+  }
+
+  private cacheItems(threadId: string, items: TurnItem[]): void {
+    this.itemsCache.delete(threadId)
+    this.itemsCache.set(threadId, items)
+    while (this.itemsCache.size > ITEMS_CACHE_MAX_THREADS) {
+      const oldest = this.itemsCache.keys().next().value
+      if (oldest === undefined) break
+      this.itemsCache.delete(oldest)
+    }
+  }
+
+  private applyItemToCache(threadId: string, item: TurnItem): void {
+    const cached = this.itemsCache.get(threadId)
+    if (!cached) return
+    const index = cached.findIndex((existing) => existing.id === item.id)
+    if (index >= 0) cached[index] = item
+    else cached.push(item)
   }
 
   private threadDir(threadId: string): string {
diff --git a/kun/src/adapters/hybrid/hybrid-session-store.ts b/kun/src/adapters/hybrid/hybrid-session-store.ts
index 5eb8f72a..8fd9b4dc 100644
--- a/kun/src/adapters/hybrid/hybrid-session-store.ts
+++ b/kun/src/adapters/hybrid/hybrid-session-store.ts
@@ -1,7 +1,11 @@
 import type { RuntimeEvent } from '../../contracts/events.js'
 import type { TurnItem } from '../../contracts/items.js'
 import type { AgentSession } from '../../domain/session.js'
-import type { SessionStore } from '../../ports/session-store.js'
+import type {
+  SessionLatestUsageSnapshot,
+  SessionStore,
+  SessionUsageRecord
+} from '../../ports/session-store.js'
 import { FileSessionStore } from '../file/file-session-store.js'
 import type { HybridThreadStore } from './hybrid-thread-store.js'
 
@@ -28,7 +32,7 @@ export class HybridSessionStore implements SessionStore {
 
   async appendEvent(threadId: string, event: RuntimeEvent): Promise {
     await this.delegate.appendEvent(threadId, event)
-    await this.index.noteEventSeq(threadId, event.seq)
+    await this.index.noteEvent(event)
   }
 
   async appendItem(threadId: string, item: TurnItem): Promise {
@@ -60,9 +64,19 @@ export class HybridSessionStore implements SessionStore {
   }
 
   async highestSeq(threadId: string): Promise {
+    const indexed = await this.index.getEventSeqHighWater(threadId)
+    if (indexed !== null) return indexed
     return this.delegate.highestSeq(threadId)
   }
 
+  async loadUsageRecords(options?: { threadId?: string }): Promise {
+    return this.index.loadUsageRecords(options)
+  }
+
+  async loadLatestUsageSnapshots(options?: { threadIds?: string[] }): Promise {
+    return this.index.loadLatestUsageSnapshots(options)
+  }
+
   async resetMemory(): Promise {
     await this.delegate.resetMemory()
   }
diff --git a/kun/src/adapters/hybrid/hybrid-thread-store.ts b/kun/src/adapters/hybrid/hybrid-thread-store.ts
index df310998..613481b4 100644
--- a/kun/src/adapters/hybrid/hybrid-thread-store.ts
+++ b/kun/src/adapters/hybrid/hybrid-thread-store.ts
@@ -1,6 +1,6 @@
-import { mkdir, open, readFile, readdir, rm, stat } from 'node:fs/promises'
+import { mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
 import { dirname, join, resolve } from 'node:path'
-import type { Database as BetterSqliteDatabase } from 'better-sqlite3'
+import type { Database as BetterSqliteDatabase, Statement } from 'better-sqlite3'
 import type {
   ThreadGoal,
   ThreadMode,
@@ -16,8 +16,14 @@ import type { TurnItem } from '../../contracts/items.js'
 import type { Turn } from '../../contracts/turns.js'
 import type { ApprovalPolicy, SandboxMode } from '../../contracts/policy.js'
 import type { ThreadStore, ThreadStoreListOptions } from '../../ports/thread-store.js'
+import type { SessionLatestUsageSnapshot, SessionUsageRecord } from '../../ports/session-store.js'
 import { toThreadSummary } from '../../domain/thread.js'
 import { readJsonl } from '../file/file-thread-store.js'
+import {
+  emptyUsageSnapshot,
+  UsageSnapshotSchema,
+  type UsageSnapshot
+} from '../../contracts/usage.js'
 
 type ThreadMetadataLine = {
   kind: 'thread_metadata'
@@ -66,6 +72,17 @@ type ThreadIndexRecord = {
   preview: string
 }
 
+type UsageRuntimeEvent = Extract
+
+type UsageRow = {
+  thread_id: string
+  seq: number
+  timestamp: string
+  turn_id: string | null
+  model: string | null
+  usage_json: string
+}
+
 /**
  * Hybrid store inspired by Codex: JSONL files are canonical and SQLite
  * is a rebuildable index. SQLite writes always happen after metadata
@@ -77,7 +94,20 @@ export class HybridThreadStore implements ThreadStore {
   private readonly nowIso: () => string
   private readonly readyPromise: Promise
   private readonly metadataQueues = new Map>()
+  private backfillPromise: Promise | null = null
   private db: BetterSqliteDatabase | null = null
+  // Prepared-statement cache for the per-event hot paths; better-sqlite3
+  // re-compiles the SQL on every prepare() call otherwise.
+  private readonly statementCache = new Map()
+  // Reconstructed thread records keyed by the file signatures they were built
+  // from. Thread detail requests re-read multi-megabyte JSONL files otherwise.
+  private readonly threadRecordCache = new Map<
+    string,
+    { metadataSig: string; itemsSig: string; record: ThreadRecord }
+  >()
+  // Per-thread floor that keeps metadata compaction from re-running on every
+  // append when a single snapshot is already larger than the threshold.
+  private readonly metadataCompactFloor = new Map()
 
   constructor(options: { dataDir: string; sqlitePath?: string; nowIso?: () => string }) {
     this.dataDir = resolve(options.dataDir, 'threads')
@@ -98,6 +128,11 @@ export class HybridThreadStore implements ThreadStore {
     }
   }
 
+  async waitForBackfill(): Promise {
+    await this.ready()
+    await this.backfillPromise
+  }
+
   async list(options: ThreadStoreListOptions = {}): Promise {
     await this.ready()
     if (this.db) {
@@ -130,7 +165,7 @@ export class HybridThreadStore implements ThreadStore {
 
     const thread = await this.readThreadFromDisk(threadId)
     if (thread && this.db) {
-      this.upsertIndexBestEffort(await this.indexRecordForThread(thread))
+      this.upsertIndexBestEffort(this.indexRecordForThread(thread))
     }
     return thread
   }
@@ -139,7 +174,7 @@ export class HybridThreadStore implements ThreadStore {
     await this.ready()
     await this.appendMetadata(thread)
     if (this.db) {
-      this.upsertIndexBestEffort(await this.indexRecordForThread(thread))
+      this.upsertIndexBestEffort(this.indexRecordForThread(thread))
     }
     return thread
   }
@@ -154,25 +189,117 @@ export class HybridThreadStore implements ThreadStore {
     }
     await rm(dir, { recursive: true, force: true })
     this.deleteIndexRow(threadId)
+    this.threadRecordCache.delete(threadId)
+    this.metadataCompactFloor.delete(threadId)
     return true
   }
 
   async noteEventSeq(threadId: string, seq: number): Promise {
+    await this.noteEventHighWater(threadId, seq)
+  }
+
+  async noteEvent(event: RuntimeEvent): Promise {
     await this.ready()
     if (!this.db) return
+    this.noteEventHighWaterSync(event.threadId, event.seq)
+    if (event.kind !== 'usage') return
     try {
-      this.db
+      this.cachedStatement(`
+        INSERT INTO usage_events (
+          thread_id, seq, timestamp, turn_id, model, usage_json
+        )
+        VALUES (
+          @thread_id, @seq, @timestamp, @turn_id, @model, @usage_json
+        )
+        ON CONFLICT(thread_id, seq) DO UPDATE SET
+          timestamp = excluded.timestamp,
+          turn_id = excluded.turn_id,
+          model = excluded.model,
+          usage_json = excluded.usage_json
+      `).run(usageRowFromEvent(event))
+    } catch (error) {
+      warnSqlite('record usage event', error)
+    }
+  }
+
+  async getEventSeqHighWater(threadId: string): Promise {
+    await this.ready()
+    if (!this.db) return null
+    try {
+      const row = this.db
+        .prepare('SELECT event_seq_high_water FROM threads WHERE id = ?')
+        .get(threadId) as { event_seq_high_water?: number } | undefined
+      return typeof row?.event_seq_high_water === 'number' ? row.event_seq_high_water : null
+    } catch (error) {
+      warnSqlite('read event high water', error)
+      return null
+    }
+  }
+
+  async loadUsageRecords(options: { threadId?: string } = {}): Promise {
+    await this.ready()
+    if (!this.db) throw new Error('hybrid sqlite unavailable')
+    try {
+      const threadId = options.threadId?.trim()
+      const rows = threadId
+        ? this.db
+            .prepare(`
+              SELECT * FROM usage_events
+              WHERE thread_id = @thread_id
+              ORDER BY thread_id ASC, seq ASC
+            `)
+            .all({ thread_id: threadId }) as UsageRow[]
+        : this.db
+            .prepare('SELECT * FROM usage_events ORDER BY thread_id ASC, seq ASC')
+            .all() as UsageRow[]
+      return usageRecordsFromRows(rows)
+    } catch (error) {
+      warnSqlite('load usage records', error)
+      throw error
+    }
+  }
+
+  async loadLatestUsageSnapshots(options: { threadIds?: string[] } = {}): Promise {
+    await this.ready()
+    if (!this.db) throw new Error('hybrid sqlite unavailable')
+    try {
+      const threadIds = [...new Set((options.threadIds ?? []).map((id) => id.trim()).filter(Boolean))]
+      if (threadIds.length > 0) {
+        const placeholders = threadIds.map((_id, index) => `@id${index}`).join(', ')
+        const params = Object.fromEntries(threadIds.map((id, index) => [`id${index}`, id]))
+        const rows = this.db
+          .prepare(`
+            SELECT u.*
+            FROM usage_events u
+            JOIN (
+              SELECT thread_id, MAX(seq) AS seq
+              FROM usage_events
+              WHERE thread_id IN (${placeholders})
+              GROUP BY thread_id
+            ) latest
+              ON latest.thread_id = u.thread_id AND latest.seq = u.seq
+            ORDER BY u.thread_id ASC
+          `)
+          .all(params) as UsageRow[]
+        return latestUsageSnapshotsFromRows(rows)
+      }
+      const rows = this.db
         .prepare(`
-          UPDATE threads
-          SET event_seq_high_water = CASE
-            WHEN event_seq_high_water > @seq THEN event_seq_high_water
-            ELSE @seq
-          END
-          WHERE id = @id
+          SELECT u.*
+          FROM usage_events u
+          JOIN (
+            SELECT thread_id, MAX(seq) AS seq
+            FROM usage_events
+            GROUP BY thread_id
+          ) latest
+            ON latest.thread_id = u.thread_id AND latest.seq = u.seq
+          ORDER BY u.thread_id ASC
         `)
-        .run({ id: threadId, seq })
+        .all() as UsageRow[]
+      return latestUsageSnapshotsFromRows(rows)
     } catch (error) {
-      warnSqlite('note event seq', error)
+      warnSqlite('load latest usage snapshots', error)
+      throw error
     }
   }
 
@@ -184,9 +311,10 @@ export class HybridThreadStore implements ThreadStore {
       const Database = sqlite.default
       this.db = new Database(this.sqlitePath)
       this.db.pragma('journal_mode = WAL')
+      this.db.pragma('busy_timeout = 5000')
       this.db.pragma('foreign_keys = ON')
       this.migrate()
-      await this.backfill()
+      this.startBackfill()
     } catch (error) {
       warnSqlite('initialize', error)
       try {
@@ -241,24 +369,73 @@ export class HybridThreadStore implements ThreadStore {
         ON threads(status, updated_at_ms DESC, id DESC);
       CREATE INDEX IF NOT EXISTS threads_relation_updated_idx
         ON threads(relation, updated_at_ms DESC, id DESC);
+      CREATE TABLE IF NOT EXISTS usage_events (
+        thread_id TEXT NOT NULL,
+        seq INTEGER NOT NULL,
+        timestamp TEXT NOT NULL,
+        turn_id TEXT,
+        model TEXT,
+        usage_json TEXT NOT NULL,
+        PRIMARY KEY(thread_id, seq)
+      );
+      CREATE INDEX IF NOT EXISTS usage_events_thread_seq_idx
+        ON usage_events(thread_id, seq);
+      CREATE INDEX IF NOT EXISTS usage_events_timestamp_idx
+        ON usage_events(timestamp);
     `)
     addColumnIfMissing(this.db, 'threads', 'todos_json TEXT')
+    addColumnIfMissing(this.db, 'threads', 'usage_backfilled INTEGER NOT NULL DEFAULT 0')
+  }
+
+  private cachedStatement(sql: string): Statement {
+    if (!this.db) throw new Error('sqlite unavailable')
+    let statement = this.statementCache.get(sql)
+    if (!statement) {
+      statement = this.db.prepare(sql)
+      this.statementCache.set(sql, statement)
+    }
+    return statement
+  }
+
+  private startBackfill(): void {
+    if (this.backfillPromise) return
+    this.backfillPromise = this.backfill().catch((error) => {
+      warnSqlite('background backfill', error)
+    })
   }
 
   private async backfill(): Promise {
     if (!this.db) return
-    const discovered = new Set()
+    const rows = this.db
+      .prepare('SELECT id, usage_backfilled FROM threads')
+      .all() as Array<{ id: string; usage_backfilled?: number }>
+    const indexed = new Map(rows.map((row) => [row.id, row.usage_backfilled === 1]))
     for (const threadId of await this.threadIdsFromFilesystem()) {
-      const thread = await this.readThreadFromDisk(threadId)
-      if (!thread) continue
-      discovered.add(thread.id)
-      this.upsertIndexBestEffort(await this.indexRecordForThread(thread))
+      const usageBackfilled = indexed.get(threadId)
+      // Threads marked as backfilled never need their events.jsonl re-read;
+      // without the marker every startup re-scanned the full event history
+      // of threads that simply have no usage events.
+      if (usageBackfilled === true) continue
+      if (usageBackfilled === undefined) {
+        const thread = await this.readThreadFromDisk(threadId)
+        if (!thread) continue
+        const scan = await this.scanEventsForBackfill(threadId)
+        this.upsertIndexBestEffort({
+          ...this.indexRecordForThread(thread),
+          eventSeqHighWater: scan.highWater
+        })
+        await this.insertUsageEventsChunked(threadId, scan.usage)
+      } else {
+        const scan = await this.scanEventsForBackfill(threadId)
+        this.noteEventHighWaterSync(threadId, scan.highWater)
+        await this.insertUsageEventsChunked(threadId, scan.usage)
+      }
+      this.markUsageBackfilled(threadId)
+      await yieldToEventLoop()
     }
 
     try {
-      const rows = this.db.prepare('SELECT id FROM threads').all() as Array<{ id: string }>
       for (const row of rows) {
-        if (discovered.has(row.id)) continue
         if (!(await pathExists(this.threadDir(row.id)))) {
           this.deleteIndexRow(row.id)
         }
@@ -268,6 +445,64 @@ export class HybridThreadStore implements ThreadStore {
     }
   }
 
+  /** Single pass over events.jsonl: high-water mark plus usage events. */
+  private async scanEventsForBackfill(
+    threadId: string
+  ): Promise<{ highWater: number; usage: UsageRuntimeEvent[] }> {
+    let highWater = 0
+    const usage: UsageRuntimeEvent[] = []
+    try {
+      for (const event of await readJsonl(this.eventsPath(threadId))) {
+        if (event.seq > highWater) highWater = event.seq
+        if (event.kind === 'usage') usage.push(event)
+      }
+    } catch (error) {
+      warnSqlite(`scan events for ${threadId}`, error)
+    }
+    return { highWater, usage }
+  }
+
+  /**
+   * Inserts usage rows in small transactions, yielding between chunks.
+   * better-sqlite3 is synchronous: unchunked backfill of a large history
+   * starved the event loop long enough that the HTTP server never reported
+   * ready within the GUI's startup timeout.
+   */
+  private async insertUsageEventsChunked(threadId: string, events: UsageRuntimeEvent[]): Promise {
+    if (!this.db || events.length === 0) return
+    const insert = this.cachedStatement(`
+      INSERT OR REPLACE INTO usage_events (
+        thread_id, seq, timestamp, turn_id, model, usage_json
+      )
+      VALUES (
+        @thread_id, @seq, @timestamp, @turn_id, @model, @usage_json
+      )
+    `)
+    const insertChunk = this.db.transaction((chunk: UsageRow[]) => {
+      for (const row of chunk) insert.run(row)
+    })
+    const chunkSize = 200
+    for (let start = 0; start < events.length; start += chunkSize) {
+      const chunk = events.slice(start, start + chunkSize).map(usageRowFromEvent)
+      try {
+        insertChunk(chunk)
+      } catch (error) {
+        warnSqlite(`backfill usage events for ${threadId}`, error)
+        return
+      }
+      await yieldToEventLoop()
+    }
+  }
+
+  private markUsageBackfilled(threadId: string): void {
+    if (!this.db) return
+    try {
+      this.db.prepare('UPDATE threads SET usage_backfilled = 1 WHERE id = ?').run(threadId)
+    } catch (error) {
+      warnSqlite('mark usage backfilled', error)
+    }
+  }
+
   private queryThreadRows(options: ThreadStoreListOptions): ThreadRow[] {
     if (!this.db) return []
     const where: string[] = []
@@ -380,6 +615,7 @@ export class HybridThreadStore implements ThreadStore {
     if (!this.db) return
     try {
       this.db.prepare('DELETE FROM threads WHERE id = ?').run(threadId)
+      this.db.prepare('DELETE FROM usage_events WHERE thread_id = ?').run(threadId)
     } catch (error) {
       warnSqlite('delete index row', error)
     }
@@ -396,6 +632,7 @@ export class HybridThreadStore implements ThreadStore {
         thread: stripThreadItemBodies(thread)
       }
       await appendJsonlLine(this.metadataPath(thread.id), line)
+      await this.maybeCompactMetadata(thread.id)
     })
     const guard = run.then(() => undefined, () => undefined)
     this.metadataQueues.set(thread.id, guard)
@@ -408,27 +645,91 @@ export class HybridThreadStore implements ThreadStore {
     }
   }
 
-  private async indexRecordForThread(thread: ThreadRecord): Promise {
-    const items = await this.loadItems(thread.id)
-    const itemSource = items.length > 0 ? items : thread.turns.flatMap((turn) => turn.items)
-    const eventSeqHighWater = await this.highestSeq(thread.id)
+  /**
+   * Every upsert appends a full thread snapshot, so metadata.jsonl grows
+   * quadratically with turn activity (observed: 4.2MB for an 8-turn thread
+   * whose latest snapshot is 6KB). Once the file passes the threshold it is
+   * rewritten as a single normalized snapshot. Runs inside the per-thread
+   * metadata queue, so no append can interleave with the rewrite.
+   */
+  private async maybeCompactMetadata(threadId: string): Promise {
+    const path = this.metadataPath(threadId)
+    const tmpPath = `${path}.compact.tmp`
+    try {
+      const stats = await stat(path)
+      const floor = this.metadataCompactFloor.get(threadId) ?? METADATA_COMPACT_MIN_BYTES
+      if (stats.size < floor) return
+      const record = await this.readLatestMetadata(threadId)
+      if (!record) return
+      const line: ThreadMetadataLine = {
+        kind: 'thread_metadata',
+        version: 1,
+        timestamp: this.nowIso(),
+        thread: stripThreadItemBodies(record)
+      }
+      const handle = await open(tmpPath, 'w')
+      try {
+        await handle.writeFile(`${JSON.stringify(line)}\n`, 'utf-8')
+        await handle.sync()
+      } finally {
+        await handle.close()
+      }
+      await rename(tmpPath, path)
+      const compacted = await stat(path)
+      this.metadataCompactFloor.set(
+        threadId,
+        Math.max(METADATA_COMPACT_MIN_BYTES, compacted.size * 4)
+      )
+    } catch (error) {
+      // On Windows the atomic rename can fail with EPERM while another
+      // handle has the file open; the next append over the threshold simply
+      // retries. Drop the temp file so failures do not accumulate litter.
+      await rm(tmpPath, { force: true }).catch(() => undefined)
+      console.warn(
+        `[kun] metadata compaction skipped for ${threadId}: ${error instanceof Error ? error.message : String(error)}`
+      )
+    }
+  }
+
+  private indexRecordForThread(thread: ThreadRecord): ThreadIndexRecord {
+    const itemSource = thread.turns.flatMap((turn) => turn.items)
     return {
       thread,
       messageCount: itemSource.length,
-      eventSeqHighWater,
+      eventSeqHighWater: 0,
       preview: previewFromItems(itemSource)
     }
   }
 
   private async readThreadFromDisk(threadId: string): Promise {
+    const [metadataSig, itemsSig] = await Promise.all([
+      fileSignature(this.metadataPath(threadId)),
+      fileSignature(this.messagesPath(threadId))
+    ])
+    const cached = this.threadRecordCache.get(threadId)
+    if (cached && cached.metadataSig === metadataSig && cached.itemsSig === itemsSig) {
+      // Refresh LRU position.
+      this.threadRecordCache.delete(threadId)
+      this.threadRecordCache.set(threadId, cached)
+      return cached.record
+    }
     const metadata = await this.readLatestMetadata(threadId)
     const legacy = metadata ? null : await this.readLegacyThread(threadId)
     const source = metadata ?? legacy
     if (!source) return null
     const items = await this.loadItems(threadId)
-    return hydrateThreadItems(source, items, {
+    // Records are treated as immutable by all callers (updates flow through
+    // upsert with fresh objects), so caching the reference is safe.
+    const record = hydrateThreadItems(source, items, {
       preserveExistingItemsWhenNoFileItems: Boolean(legacy)
     })
+    this.threadRecordCache.set(threadId, { metadataSig, itemsSig, record })
+    while (this.threadRecordCache.size > THREAD_RECORD_CACHE_LIMIT) {
+      const oldest = this.threadRecordCache.keys().next().value
+      if (!oldest) break
+      this.threadRecordCache.delete(oldest)
+    }
+    return record
   }
 
   private async readLatestMetadata(threadId: string): Promise {
@@ -471,9 +772,25 @@ export class HybridThreadStore implements ThreadStore {
     return ordered
   }
 
-  private async highestSeq(threadId: string): Promise {
-    const events = await readJsonl(this.eventsPath(threadId))
-    return events.reduce((max, event) => Math.max(max, event.seq), 0)
+  private async noteEventHighWater(threadId: string, seq: number): Promise {
+    await this.ready()
+    this.noteEventHighWaterSync(threadId, seq)
+  }
+
+  private noteEventHighWaterSync(threadId: string, seq: number): void {
+    if (!this.db) return
+    try {
+      this.cachedStatement(`
+        UPDATE threads
+        SET event_seq_high_water = CASE
+          WHEN event_seq_high_water > @seq THEN event_seq_high_water
+          ELSE @seq
+        END
+        WHERE id = @id
+      `).run({ id: threadId, seq })
+    } catch (error) {
+      warnSqlite('note event seq', error)
+    }
   }
 
   private async listFromFilesystem(): Promise {
@@ -739,6 +1056,8 @@ function summaryFromRow(row: ThreadRow): ThreadSummary {
     model: row.model,
     mode: row.mode,
     status: row.status,
+    approvalPolicy: row.approval_policy,
+    sandboxMode: row.sandbox_mode,
     ...(row.cost_budget_usd !== null ? { costBudgetUsd: row.cost_budget_usd } : {}),
     ...(row.cost_budget_warning_sent !== null ? { costBudgetWarningSent: Boolean(row.cost_budget_warning_sent) } : {}),
     relation: row.relation,
@@ -832,6 +1151,143 @@ function previewFromItems(items: TurnItem[]): string {
   return ''
 }
 
+function usageRowFromEvent(event: RuntimeEvent & { kind: 'usage' }): UsageRow {
+  return {
+    thread_id: event.threadId,
+    seq: event.seq,
+    timestamp: event.timestamp,
+    turn_id: event.turnId ?? null,
+    model: event.model ?? null,
+    usage_json: JSON.stringify(event.usage)
+  }
+}
+
+function usageRecordsFromRows(rows: UsageRow[]): SessionUsageRecord[] {
+  const previousByThread = new Map()
+  const records: SessionUsageRecord[] = []
+  for (const row of rows) {
+    const usage = parseUsageSnapshot(row.usage_json)
+    if (!usage) continue
+    const previous = previousByThread.get(row.thread_id) ?? emptyUsageSnapshot()
+    const delta = diffUsage(usage, previous)
+    previousByThread.set(row.thread_id, usage)
+    if (!hasUsage(delta)) continue
+    records.push({
+      threadId: row.thread_id,
+      ...(row.turn_id ? { turnId: row.turn_id } : {}),
+      ...(row.model ? { model: row.model } : {}),
+      completedAt: row.timestamp,
+      usage: delta
+    })
+  }
+  return records
+}
+
+function latestUsageSnapshotsFromRows(rows: UsageRow[]): SessionLatestUsageSnapshot[] {
+  return rows.flatMap((row) => {
+    const usage = parseUsageSnapshot(row.usage_json)
+    if (!usage) return []
+    return [{
+      threadId: row.thread_id,
+      seq: row.seq,
+      usage
+    }]
+  })
+}
+
+function parseUsageSnapshot(raw: string): UsageSnapshot | null {
+  try {
+    const parsed = UsageSnapshotSchema.safeParse(JSON.parse(raw))
+    return parsed.success ? parsed.data : null
+  } catch {
+    return null
+  }
+}
+
+function diffUsage(current: UsageSnapshot, previous: UsageSnapshot): UsageSnapshot {
+  const promptTokens = diffNumber(current.promptTokens, previous.promptTokens)
+  const completionTokens = diffNumber(current.completionTokens, previous.completionTokens)
+  const reportedTotal = diffNumber(current.totalTokens, previous.totalTokens)
+  const totalTokens = reportedTotal || promptTokens + completionTokens
+  const cachedTokens = diffOptionalNumber(current.cachedTokens, previous.cachedTokens)
+  const cacheHitTokens = diffOptionalNumber(current.cacheHitTokens, previous.cacheHitTokens)
+  const cacheMissTokens = diffOptionalNumber(current.cacheMissTokens, previous.cacheMissTokens)
+  const cacheTotal = (cacheHitTokens ?? 0) + (cacheMissTokens ?? 0)
+  return {
+    promptTokens,
+    completionTokens,
+    totalTokens,
+    ...(cachedTokens !== undefined ? { cachedTokens } : {}),
+    ...(cacheHitTokens !== undefined ? { cacheHitTokens } : {}),
+    ...(cacheMissTokens !== undefined ? { cacheMissTokens } : {}),
+    cacheHitRate: cacheHitTokens !== undefined && cacheTotal > 0 ? cacheHitTokens / cacheTotal : null,
+    turns: diffNumber(current.turns, previous.turns),
+    ...(current.costUsd !== undefined || previous.costUsd !== undefined
+      ? { costUsd: diffNumber(current.costUsd ?? 0, previous.costUsd ?? 0) }
+      : {}),
+    ...(current.costCny !== undefined || previous.costCny !== undefined
+      ? { costCny: diffNumber(current.costCny ?? 0, previous.costCny ?? 0) }
+      : {}),
+    ...(current.cacheSavingsUsd !== undefined || previous.cacheSavingsUsd !== undefined
+      ? { cacheSavingsUsd: diffNumber(current.cacheSavingsUsd ?? 0, previous.cacheSavingsUsd ?? 0) }
+      : {}),
+    ...(current.cacheSavingsCny !== undefined || previous.cacheSavingsCny !== undefined
+      ? { cacheSavingsCny: diffNumber(current.cacheSavingsCny ?? 0, previous.cacheSavingsCny ?? 0) }
+      : {}),
+    ...(current.tokenEconomySavingsTokens !== undefined || previous.tokenEconomySavingsTokens !== undefined
+      ? {
+          tokenEconomySavingsTokens: diffNumber(
+            current.tokenEconomySavingsTokens ?? 0,
+            previous.tokenEconomySavingsTokens ?? 0
+          )
+        }
+      : {}),
+    ...(current.tokenEconomySavingsUsd !== undefined || previous.tokenEconomySavingsUsd !== undefined
+      ? {
+          tokenEconomySavingsUsd: diffNumber(
+            current.tokenEconomySavingsUsd ?? 0,
+            previous.tokenEconomySavingsUsd ?? 0
+          )
+        }
+      : {}),
+    ...(current.tokenEconomySavingsCny !== undefined || previous.tokenEconomySavingsCny !== undefined
+      ? {
+          tokenEconomySavingsCny: diffNumber(
+            current.tokenEconomySavingsCny ?? 0,
+            previous.tokenEconomySavingsCny ?? 0
+          )
+        }
+      : {}),
+    ...(current.hasError ? { hasError: true } : {})
+  }
+}
+
+function diffNumber(current: number, previous: number): number {
+  return Math.max(0, current - previous)
+}
+
+function diffOptionalNumber(current?: number, previous?: number): number | undefined {
+  if (current === undefined && previous === undefined) return undefined
+  return Math.max(0, (current ?? 0) - (previous ?? 0))
+}
+
+function hasUsage(usage: UsageSnapshot): boolean {
+  return usage.promptTokens > 0
+    || usage.completionTokens > 0
+    || usage.totalTokens > 0
+    || (usage.cachedTokens ?? 0) > 0
+    || (usage.cacheHitTokens ?? 0) > 0
+    || (usage.cacheMissTokens ?? 0) > 0
+    || usage.turns > 0
+    || (usage.costUsd ?? 0) > 0
+    || (usage.costCny ?? 0) > 0
+    || (usage.cacheSavingsUsd ?? 0) > 0
+    || (usage.cacheSavingsCny ?? 0) > 0
+    || (usage.tokenEconomySavingsTokens ?? 0) > 0
+    || (usage.tokenEconomySavingsUsd ?? 0) > 0
+    || (usage.tokenEconomySavingsCny ?? 0) > 0
+}
+
 function isoToMillis(value: string): number {
   const millis = Date.parse(value)
   return Number.isFinite(millis) ? millis : 0
@@ -853,6 +1309,18 @@ function addColumnIfMissing(db: BetterSqliteDatabase, table: string, columnSql:
   }
 }
 
+const THREAD_RECORD_CACHE_LIMIT = 8
+const METADATA_COMPACT_MIN_BYTES = 1_000_000
+
+async function fileSignature(path: string): Promise {
+  try {
+    const stats = await stat(path)
+    return `${stats.size}:${stats.mtimeMs}`
+  } catch {
+    return 'missing'
+  }
+}
+
 async function appendJsonlLine(path: string, value: unknown): Promise {
   await mkdir(dirname(path), { recursive: true })
   const handle = await open(path, 'a')
@@ -873,6 +1341,10 @@ async function pathExists(path: string): Promise {
   }
 }
 
+async function yieldToEventLoop(): Promise {
+  await new Promise((resolve) => setTimeout(resolve, 0))
+}
+
 function warnSqlite(action: string, error: unknown): void {
   const message = error instanceof Error ? error.message : String(error)
   console.warn(`[kun] hybrid sqlite ${action} failed; using JSONL fallback: ${message}`)
diff --git a/kun/src/adapters/in-memory-event-bus.ts b/kun/src/adapters/in-memory-event-bus.ts
index b20e938c..32b098c1 100644
--- a/kun/src/adapters/in-memory-event-bus.ts
+++ b/kun/src/adapters/in-memory-event-bus.ts
@@ -1,20 +1,35 @@
 import type { EventBus } from '../ports/event-bus.js'
 import type { RuntimeEvent } from '../contracts/events.js'
 
+/**
+ * Retained events per thread for `snapshotSince`. SSE replay reads the
+ * persisted session store, not the bus, so the bus only needs a recent
+ * tail — retaining every event leaked the full delta stream of every
+ * long-running thread into memory.
+ */
+const MAX_RETAINED_EVENTS_PER_THREAD = 256
+
 /**
  * In-memory implementation of the event bus used by tests and the
  * default runtime. Subscribers receive only events for their thread.
- * The bus is a single source of truth for the SSE replay path.
+ * Live fan-out is the bus's job; durable replay belongs to the
+ * session store.
  */
 export class InMemoryEventBus implements EventBus {
   private readonly events = new Map()
   private readonly subscribers = new Map void>>()
   private nextSeq = new Map()
+  private highestSeqByThread = new Map()
 
   publish(event: RuntimeEvent): void {
     const list = this.events.get(event.threadId) ?? []
     list.push(event)
+    if (list.length > MAX_RETAINED_EVENTS_PER_THREAD) {
+      list.splice(0, list.length - MAX_RETAINED_EVENTS_PER_THREAD)
+    }
     this.events.set(event.threadId, list)
+    const highest = this.highestSeqByThread.get(event.threadId) ?? 0
+    if (event.seq > highest) this.highestSeqByThread.set(event.threadId, event.seq)
     const subscribers = this.subscribers.get(event.threadId)
     if (!subscribers) return
     for (const handler of subscribers) {
@@ -32,6 +47,9 @@ export class InMemoryEventBus implements EventBus {
     this.subscribers.set(threadId, set)
     return () => {
       set.delete(handler)
+      if (set.size === 0 && this.subscribers.get(threadId) === set) {
+        this.subscribers.delete(threadId)
+      }
     }
   }
 
@@ -41,8 +59,7 @@ export class InMemoryEventBus implements EventBus {
   }
 
   highestSeq(threadId: string): number {
-    const list = this.events.get(threadId) ?? []
-    return list.reduce((max, event) => Math.max(max, event.seq), 0)
+    return this.highestSeqByThread.get(threadId) ?? 0
   }
 
   /** Returns the next per-thread `seq` value, allocating one if needed. */
@@ -56,5 +73,6 @@ export class InMemoryEventBus implements EventBus {
     this.events.clear()
     this.subscribers.clear()
     this.nextSeq.clear()
+    this.highestSeqByThread.clear()
   }
 }
diff --git a/kun/src/adapters/model/deepseek-compat-model-client.ts b/kun/src/adapters/model/deepseek-compat-model-client.ts
index 68606b6f..59a1e75e 100644
--- a/kun/src/adapters/model/deepseek-compat-model-client.ts
+++ b/kun/src/adapters/model/deepseek-compat-model-client.ts
@@ -1,7 +1,9 @@
 import type { ModelClient, ModelRequest, ModelStreamChunk, ModelToolSpec } from '../../ports/model-client.js'
 import type { TurnItem } from '../../contracts/items.js'
 import { emptyUsageSnapshot, type UsageSnapshot } from '../../contracts/usage.js'
-import { estimateDeepseekCacheSavings, estimateDeepseekCost } from './deepseek-pricing.js'
+import type { ModelCapabilityMetadata } from '../../contracts/capabilities.js'
+import { estimateDeepseekCost } from './deepseek-pricing.js'
+import { estimateMiniMaxCost } from './minimax-pricing.js'
 import { isToolResultBridgeItem, repairModelHistoryItems } from '../../domain/model-history-repair.js'
 import { repairToolArguments } from './tool-argument-repair.js'
 import { isDeepSeekHost, probeDeepSeekReachable } from './model-error-probe.js'
@@ -33,6 +35,8 @@ export type DeepseekCompatConfig = {
   nonStreaming?: boolean
   /** Maximum idle time between streaming chunks before the turn fails. */
   streamIdleTimeoutMs?: number
+  /** Optional model capability resolver used for provider-specific reasoning translation. */
+  modelCapabilities?: (model: string) => ModelCapabilityMetadata
 }
 
 type ChatMessage = {
@@ -52,11 +56,15 @@ type ChatMessageContentPart =
   | { type: 'text'; text: string }
   | { type: 'image_url'; image_url: { url: string } }
 
-type AnthropicContentBlock =
+type AnthropicCacheControl = { type: 'ephemeral' }
+
+type AnthropicContentBlock = (
   | { type: 'text'; text: string }
   | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } | { type: 'url'; url: string } }
+  | { type: 'thinking'; thinking: string }
   | { type: 'tool_use'; id: string; name: string; input: Record }
   | { type: 'tool_result'; tool_use_id: string; content: string }
+) & { cache_control?: AnthropicCacheControl }
 
 type AnthropicImageSource = Extract['source']
 
@@ -162,6 +170,7 @@ export class DeepseekCompatModelClient implements ModelClient {
     const endpointFormat = this.endpointFormat()
     const url = buildModelEndpointUrl(this.config.baseUrl, endpointFormat)
     const stream = request.stream ?? !this.config.nonStreaming
+    const requestModel = request.model?.trim() || this.config.model
     const body = this.buildRequestBody(request, stream)
     const headers = this.buildHeaders(stream, endpointFormat)
     const result = await this.postChatCompletion(url, headers, body, request.abortSignal)
@@ -183,14 +192,14 @@ export class DeepseekCompatModelClient implements ModelClient {
         if (response.ok) {
           if (this.config.nonStreaming || response.headers.get('content-type')?.includes('application/json')) {
             const json = (await response.json()) as ChatCompletionResponse
-            yield* this.materializeNonStreaming(json, endpointFormat)
+            yield* this.materializeNonStreaming(json, endpointFormat, requestModel)
             return
           }
           if (!response.body) {
             yield { kind: 'error', message: 'model response had no body' }
             return
           }
-          yield* this.streamSse(response.body, request.abortSignal, endpointFormat)
+          yield* this.streamSse(response.body, request.abortSignal, endpointFormat, requestModel)
           return
         }
         const retryText = await response.text()
@@ -212,20 +221,24 @@ export class DeepseekCompatModelClient implements ModelClient {
     }
     if (this.config.nonStreaming || response.headers.get('content-type')?.includes('application/json')) {
       const json = (await response.json()) as ChatCompletionResponse
-      yield* this.materializeNonStreaming(json, endpointFormat)
+      yield* this.materializeNonStreaming(json, endpointFormat, requestModel)
       return
     }
     if (!response.body) {
       yield { kind: 'error', message: 'model response had no body' }
       return
     }
-    yield* this.streamSse(response.body, request.abortSignal, endpointFormat)
+    yield* this.streamSse(response.body, request.abortSignal, endpointFormat, requestModel)
   }
 
   private endpointFormat(): ModelEndpointFormat {
     return normalizeModelEndpointFormat(this.config.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT)
   }
 
+  private modelReasoningFor(model: string): ModelCapabilityMetadata['reasoning'] | undefined {
+    return this.config.modelCapabilities?.(model).reasoning
+  }
+
   private async postChatCompletion(
     url: string,
     headers: Record,
@@ -248,9 +261,12 @@ export class DeepseekCompatModelClient implements ModelClient {
 
   private buildHeaders(stream: boolean, endpointFormat: ModelEndpointFormat): Record {
     const headers: Record = {
-      'Content-Type': 'application/json',
-      Accept: stream ? 'text/event-stream' : 'application/json'
+      'Content-Type': 'application/json'
     }
+    // `stream: true` is enough for OpenAI-compatible providers to return SSE.
+    // Some Windows Node/Electron paths time out when routing requests with
+    // `Accept: text/event-stream`, while the same stream works without it.
+    if (!stream) headers.Accept = 'application/json'
     if (this.config.apiKey) {
       if (endpointFormat === 'messages') {
         headers.Authorization = `Bearer ${this.config.apiKey}`
@@ -264,7 +280,7 @@ export class DeepseekCompatModelClient implements ModelClient {
   }
 
   private async classifyHttpError(status: number, text: string): Promise<{ message: string; code: string }> {
-    const body = text.slice(0, 500)
+    const body = text
     if (status === 429) {
       return {
         message: `model request was rate limited (HTTP 429): ${body}`,
@@ -323,7 +339,11 @@ export class DeepseekCompatModelClient implements ModelClient {
       body.stream_options = { include_usage: true }
     }
     const includeThinking = !isAzureOpenAiEndpoint(this.config.baseUrl)
-    applyReasoningEffort(body, request.reasoningEffort, { includeThinking })
+    applyReasoningEffort(body, request.reasoningEffort, {
+      includeThinking,
+      reasoning: this.modelReasoningFor(model),
+      maxReasoningEffort: isDeepSeekHost(this.config.baseUrl) ? 'max' : 'high'
+    })
     if (
       includeThinking &&
       isDeepSeekHost(this.config.baseUrl) &&
@@ -369,7 +389,10 @@ export class DeepseekCompatModelClient implements ModelClient {
     if (request.responseFormat === 'json_object') {
       body.text = { format: { type: 'json_object' } }
     }
-    const reasoning = responsesReasoningForEffort(request.reasoningEffort)
+    const reasoning = responsesReasoningForEffort(
+      request.reasoningEffort,
+      this.modelReasoningFor(model)
+    )
     if (reasoning) body.reasoning = reasoning
     const tools = normalizeToolSpecs(request.tools)
     if (tools.length > 0) {
@@ -389,25 +412,34 @@ export class DeepseekCompatModelClient implements ModelClient {
     messages: ChatMessage[],
     stream: boolean
   ): Record {
-    const converted = messagesToAnthropic(messages)
+    const converted = messagesToAnthropic(
+      messages,
+      this.modelReasoningFor(model)?.requestProtocol === 'anthropic-thinking'
+    )
+    applyAnthropicCacheControl(converted.messages)
     const body: Record = {
       model,
       stream,
       max_tokens: request.maxTokens ?? DEFAULT_MESSAGES_MAX_TOKENS,
       messages: converted.messages
     }
-    if (converted.system) body.system = converted.system
+    const systemText = request.responseFormat === 'json_object'
+      ? [converted.system, 'Return a valid JSON object only.']
+          .filter((item) => item.trim().length > 0)
+          .join('\n\n')
+      : converted.system
+    if (systemText) {
+      body.system = [
+        { type: 'text', text: systemText, cache_control: { type: 'ephemeral' } }
+      ] satisfies AnthropicContentBlock[]
+    }
     if (request.temperature !== undefined) {
       body.temperature = request.temperature
     }
     if (request.topP !== undefined) {
       body.top_p = request.topP
     }
-    if (request.responseFormat === 'json_object') {
-      body.system = [converted.system, 'Return a valid JSON object only.']
-        .filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
-        .join('\n\n')
-    }
+    applyAnthropicReasoningEffort(body, request.reasoningEffort, this.modelReasoningFor(model))
     const tools = normalizeToolSpecs(request.tools)
     if (tools.length > 0) {
       body.tools = tools.map((tool) => ({
@@ -427,18 +459,28 @@ export class DeepseekCompatModelClient implements ModelClient {
     if (request.modeInstruction) {
       out.push({ role: 'system', content: request.modeInstruction })
     }
-    for (const instruction of request.contextInstructions ?? []) {
-      if (instruction.trim()) out.push({ role: 'system', content: instruction })
-    }
     const windowSize = this.config.historyLimit
     const history = windowSize
       ? limitHistoryPreservingCompaction(request.history, windowSize)
       : request.history
-    const thinkingMode = requiresReasoningRoundTrip(request.reasoningEffort, model, this.config.baseUrl)
+    const thinkingMode = requiresReasoningRoundTrip(
+      request.reasoningEffort,
+      model,
+      this.config.baseUrl,
+      this.modelReasoningFor(model)
+    )
     out.push(...this.itemsToMessages(
       repairModelHistoryItems([...request.prefix, ...history]),
       thinkingMode
     ))
+    // Per-turn context (goal budgets, todo state, memories, skill notes,
+    // drift warnings) is volatile — the goal instruction alone embeds a
+    // tokens-used counter that changes every step. It must trail the
+    // stable history: placed before it, every counter tick invalidated
+    // the provider prompt cache for the entire conversation.
+    for (const instruction of request.contextInstructions ?? []) {
+      if (instruction.trim()) out.push({ role: 'system', content: instruction })
+    }
     if (request.attachments?.length) {
       attachImagesToLatestUserMessage(out, request.attachments)
     }
@@ -611,7 +653,8 @@ export class DeepseekCompatModelClient implements ModelClient {
   private async *streamSse(
     body: ReadableStream,
     signal: AbortSignal,
-    endpointFormat: ModelEndpointFormat
+    endpointFormat: ModelEndpointFormat,
+    model: string
   ): AsyncIterable {
     const decoder = new TextDecoder('utf-8')
     const reader = body.getReader()
@@ -673,7 +716,8 @@ export class DeepseekCompatModelClient implements ModelClient {
             completedToolCalls,
             textAccumulator,
             reasoningAccumulator,
-            endpointFormat
+            endpointFormat,
+            model
           )
           textAccumulator = result.text
           reasoningAccumulator = result.reasoning
@@ -717,7 +761,8 @@ export class DeepseekCompatModelClient implements ModelClient {
     completedToolCalls: Set,
     textAccumulator: string,
     reasoningAccumulator: string,
-    endpointFormat: ModelEndpointFormat
+    endpointFormat: ModelEndpointFormat,
+    model: string
   ): {
     chunks: ModelStreamChunk[]
     text: string
@@ -725,6 +770,20 @@ export class DeepseekCompatModelClient implements ModelClient {
     finishReason: string | null
     usage: UsageSnapshot | null
   } {
+    const payloadError = modelPayloadError(payload)
+    if (payloadError) {
+      return {
+        chunks: [{
+          kind: 'error',
+          message: payloadError.message,
+          ...(payloadError.code ? { code: payloadError.code } : {})
+        }],
+        text: textAccumulator,
+        reasoning: reasoningAccumulator,
+        finishReason: 'error',
+        usage: null
+      }
+    }
     if (endpointFormat === 'responses') {
       return this.consumeResponsesStreamPayload(
         payload,
@@ -732,7 +791,8 @@ export class DeepseekCompatModelClient implements ModelClient {
         pendingByIndex,
         completedToolCalls,
         textAccumulator,
-        reasoningAccumulator
+        reasoningAccumulator,
+        model
       )
     }
     if (endpointFormat === 'messages') {
@@ -742,7 +802,8 @@ export class DeepseekCompatModelClient implements ModelClient {
         pendingByIndex,
         completedToolCalls,
         textAccumulator,
-        reasoningAccumulator
+        reasoningAccumulator,
+        model
       )
     }
     const chunks: ModelStreamChunk[] = []
@@ -797,7 +858,7 @@ export class DeepseekCompatModelClient implements ModelClient {
     }
     const usagePayload = payload.usage as Record | undefined
     if (usagePayload) {
-      usage = this.mapUsage(usagePayload)
+      usage = this.mapUsage(usagePayload, model)
     }
     if (finishReason === 'tool_calls' && pendingArguments.size > 0) {
       for (const [callId, value] of pendingArguments) {
@@ -821,7 +882,8 @@ export class DeepseekCompatModelClient implements ModelClient {
     pendingByIndex: Map,
     completedToolCalls: Set,
     textAccumulator: string,
-    reasoningAccumulator: string
+    reasoningAccumulator: string,
+    model: string
   ): {
     chunks: ModelStreamChunk[]
     text: string
@@ -915,7 +977,7 @@ export class DeepseekCompatModelClient implements ModelClient {
         skipText: Boolean(text),
         pendingArguments,
         completedToolCalls
-      })
+      }, model)
       chunks.push(...materialized.chunks)
       if (materialized.usage) usage = materialized.usage
       finishReason = materialized.finishReason
@@ -933,7 +995,8 @@ export class DeepseekCompatModelClient implements ModelClient {
     pendingByIndex: Map,
     completedToolCalls: Set,
     textAccumulator: string,
-    reasoningAccumulator: string
+    reasoningAccumulator: string,
+    model: string
   ): {
     chunks: ModelStreamChunk[]
     text: string
@@ -952,7 +1015,7 @@ export class DeepseekCompatModelClient implements ModelClient {
     if (type === 'message_start') {
       const message = recordValue(payload, 'message')
       const usagePayload = message ? recordValue(message, 'usage') : null
-      if (usagePayload) usage = this.mapUsage(usagePayload)
+      if (usagePayload) usage = this.mapUsage(usagePayload, model)
     } else if (type === 'content_block_start') {
       const block = recordValue(payload, 'content_block')
       if (block && recordString(block, 'type') === 'tool_use') {
@@ -1022,7 +1085,7 @@ export class DeepseekCompatModelClient implements ModelClient {
       const mappedStopReason = anthropicStopReason(stopReason)
       if (mappedStopReason) finishReason = mappedStopReason
       const usagePayload = recordValue(payload, 'usage')
-      if (usagePayload) usage = this.mapUsage(usagePayload)
+      if (usagePayload) usage = this.mapUsage(usagePayload, model)
     } else if (type === 'message_stop') {
       finishReason = finishReason ?? 'stop'
     } else if (type === 'error') {
@@ -1034,14 +1097,24 @@ export class DeepseekCompatModelClient implements ModelClient {
 
   private *materializeNonStreaming(
     payload: ChatCompletionResponse,
-    endpointFormat: ModelEndpointFormat
+    endpointFormat: ModelEndpointFormat,
+    model: string
   ): Generator {
+    const payloadError = modelPayloadError(payload as unknown as Record)
+    if (payloadError) {
+      yield {
+        kind: 'error',
+        message: payloadError.message,
+        ...(payloadError.code ? { code: payloadError.code } : {})
+      }
+      return
+    }
     if (endpointFormat === 'responses') {
-      yield* this.materializeResponsesNonStreaming(payload as unknown as ResponsesApiResponse)
+      yield* this.materializeResponsesNonStreaming(payload as unknown as ResponsesApiResponse, model)
       return
     }
     if (endpointFormat === 'messages') {
-      yield* this.materializeAnthropicMessagesNonStreaming(payload as unknown as AnthropicMessageResponse)
+      yield* this.materializeAnthropicMessagesNonStreaming(payload as unknown as AnthropicMessageResponse, model)
       return
     }
     const choice = payload.choices?.[0]
@@ -1069,7 +1142,7 @@ export class DeepseekCompatModelClient implements ModelClient {
       }
     }
     if (payload.usage) {
-      yield { kind: 'usage', usage: this.mapUsage(payload.usage) }
+      yield { kind: 'usage', usage: this.mapUsage(payload.usage, model) }
     }
     let stopReason: 'stop' | 'tool_calls' | 'length' | 'error' = 'stop'
     if (choice.finish_reason === 'tool_calls') stopReason = 'tool_calls'
@@ -1079,13 +1152,14 @@ export class DeepseekCompatModelClient implements ModelClient {
   }
 
   private *materializeResponsesNonStreaming(
-    payload: ResponsesApiResponse
+    payload: ResponsesApiResponse,
+    model: string
   ): Generator {
     if (payload.error?.message) {
       yield { kind: 'error', message: payload.error.message, code: payload.error.type }
       return
     }
-    const materialized = this.materializeResponsesOutput(payload)
+    const materialized = this.materializeResponsesOutput(payload, {}, model)
     yield* materialized.chunks
     if (materialized.usage) {
       yield { kind: 'usage', usage: materialized.usage }
@@ -1099,7 +1173,8 @@ export class DeepseekCompatModelClient implements ModelClient {
       skipText?: boolean
       pendingArguments?: Map
       completedToolCalls?: Set
-    } = {}
+    } = {},
+    model = this.config.model
   ): {
     chunks: ModelStreamChunk[]
     finishReason: ModelStopReason
@@ -1134,7 +1209,7 @@ export class DeepseekCompatModelClient implements ModelClient {
         arguments: this.parseToolArguments(argsRaw)
       })
     }
-    const usage = payload.usage ? this.mapUsage(payload.usage) : null
+    const usage = payload.usage ? this.mapUsage(payload.usage, model) : null
     let finishReason: ModelStopReason = sawToolCall ? 'tool_calls' : 'stop'
     if (payload.status === 'incomplete') {
       finishReason = payload.incomplete_details?.reason === 'max_output_tokens' ? 'length' : 'error'
@@ -1145,7 +1220,8 @@ export class DeepseekCompatModelClient implements ModelClient {
   }
 
   private *materializeAnthropicMessagesNonStreaming(
-    payload: AnthropicMessageResponse
+    payload: AnthropicMessageResponse,
+    model: string
   ): Generator {
     let sawToolCall = false
     for (const block of payload.content ?? []) {
@@ -1172,15 +1248,13 @@ export class DeepseekCompatModelClient implements ModelClient {
       }
     }
     if (payload.usage) {
-      yield { kind: 'usage', usage: this.mapUsage(payload.usage) }
+      yield { kind: 'usage', usage: this.mapUsage(payload.usage, model) }
     }
     yield { kind: 'completed', stopReason: anthropicStopReason(payload.stop_reason) ?? (sawToolCall ? 'tool_calls' : 'stop') }
   }
 
-  private mapUsage(usage: Record): UsageSnapshot {
-    const promptTokens = Number(usage.prompt_tokens ?? usage.prompt_eval_count ?? usage.input_tokens ?? 0) || 0
+  private mapUsage(usage: Record, model = this.config.model): UsageSnapshot {
     const completionTokens = Number(usage.completion_tokens ?? usage.eval_count ?? usage.output_tokens ?? 0) || 0
-    const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens) || 0
     const promptDetails = usage.prompt_tokens_details as
       | { cached_tokens?: number }
       | undefined
@@ -1190,21 +1264,41 @@ export class DeepseekCompatModelClient implements ModelClient {
     const cachedTokens = Number(promptDetails?.cached_tokens ?? 0) || 0
     const cacheRead = Number(usage.cache_read_input_tokens ?? 0) || 0
     const cacheCreation = Number(usage.cache_creation_input_tokens ?? 0) || 0
+    // Anthropic-protocol usage (MiniMax et al.) reports input_tokens
+    // EXCLUDING cache reads/writes; OpenAI-style prompt_tokens includes
+    // everything and marks the cached subset in prompt_tokens_details.
+    const anthropicUsage = usage.prompt_tokens === undefined &&
+      usage.prompt_eval_count === undefined &&
+      usage.input_tokens !== undefined
+    const reportedPromptTokens = Number(usage.prompt_tokens ?? usage.prompt_eval_count ?? usage.input_tokens ?? 0) || 0
+    const promptTokens = anthropicUsage
+      ? reportedPromptTokens + cacheRead + cacheCreation
+      : reportedPromptTokens
     const cacheHit = hasNativeCache ? nativeHit : (cachedTokens > 0 ? cachedTokens : cacheRead)
     const cacheMiss = hasNativeCache ? nativeMiss : Math.max(promptTokens - cacheHit, 0)
     const cacheTotal = cacheHit + cacheMiss
     const cacheHitRate = cacheTotal === 0 ? null : cacheHit / cacheTotal
+    const totalTokens = anthropicUsage
+      ? promptTokens + completionTokens
+      : Number(usage.total_tokens ?? promptTokens + completionTokens) || 0
+    const pricingCacheRead = cacheRead || cacheHit
+    const pricingCacheWrite = cacheCreation
+    const pricingInputTokens = anthropicUsage
+      ? reportedPromptTokens
+      : Math.max(promptTokens - pricingCacheRead - pricingCacheWrite, 0)
     const estimatedCost = estimateDeepseekCost({
-      model: this.config.model,
+      model,
       providerHost: this.config.baseUrl,
       cacheHitTokens: cacheHit,
       cacheMissTokens: cacheMiss,
       outputTokens: completionTokens
-    })
-    const estimatedSavings = estimateDeepseekCacheSavings({
-      model: this.config.model,
+    }) ?? estimateMiniMaxCost({
+      model,
       providerHost: this.config.baseUrl,
-      cacheHitTokens: cacheHit
+      inputTokens: pricingInputTokens,
+      cacheReadTokens: pricingCacheRead,
+      cacheWriteTokens: pricingCacheWrite,
+      outputTokens: completionTokens
     })
     const reportedCostUsd = Number(usage.cost_usd ?? usage.costUsd)
     const reportedCostCny = Number(usage.cost_cny ?? usage.costCny)
@@ -1219,9 +1313,7 @@ export class DeepseekCompatModelClient implements ModelClient {
       cacheHitRate,
       turns: 1,
       costUsd: Number.isFinite(reportedCostUsd) ? reportedCostUsd : estimatedCost?.costUsd,
-      costCny: Number.isFinite(reportedCostCny) ? reportedCostCny : estimatedCost?.costCny,
-      cacheSavingsUsd: estimatedSavings?.costUsd,
-      cacheSavingsCny: estimatedSavings?.costCny
+      costCny: Number.isFinite(reportedCostCny) ? reportedCostCny : estimatedCost?.costCny
     }
   }
 
@@ -1273,13 +1365,28 @@ function messagesToResponsesInput(messages: ChatMessage[]): Array 0) {
+        appendTrailingInstruction(out, text)
+        continue
+      }
+      system.push(text)
       continue
     }
     if (message.role === 'tool') {
@@ -1300,6 +1407,10 @@ function messagesToAnthropic(messages: ChatMessage[]): { system: string; message
       : content.trim()
         ? [{ type: 'text' as const, text: content }]
         : []
+    if (includeThinkingBlocks && message.role === 'assistant') {
+      const thinking = message.reasoning_content?.trim()
+      if (thinking) blocks.unshift({ type: 'thinking', thinking })
+    }
     for (const call of message.tool_calls ?? []) {
       blocks.push({
         type: 'tool_use',
@@ -1316,6 +1427,46 @@ function messagesToAnthropic(messages: ChatMessage[]): { system: string; message
   return { system: system.join('\n\n'), messages: out }
 }
 
+/**
+ * Folds a trailing system instruction into the conversation as user
+ * content. Appends to the final user message when one exists so the
+ * request keeps strict user/assistant alternation.
+ */
+function appendTrailingInstruction(out: AnthropicMessage[], text: string): void {
+  const block: AnthropicContentBlock = { type: 'text', text }
+  const last = out[out.length - 1]
+  if (last && last.role === 'user') {
+    if (typeof last.content === 'string') {
+      last.content = last.content.trim()
+        ? [{ type: 'text', text: last.content }, block]
+        : [block]
+      return
+    }
+    last.content.push(block)
+    return
+  }
+  out.push({ role: 'user', content: [block] })
+}
+
+/**
+ * Marks the stable prefix for provider-side prompt caching. Anthropic
+ * protocol caching is explicit: providers such as MiniMax only cache
+ * content before `cache_control` breakpoints (up to 4 per request).
+ * One breakpoint goes on the system block (which also covers the tool
+ * definitions that precede it) and one on the final content block of
+ * each of the last two messages, so consecutive agent steps re-hit the
+ * prefix cached by the previous request.
+ */
+function applyAnthropicCacheControl(messages: AnthropicMessage[]): void {
+  let breakpoints = 0
+  for (let i = messages.length - 1; i >= 0 && breakpoints < 2; i -= 1) {
+    const content = messages[i].content
+    if (typeof content === 'string' || content.length === 0) continue
+    content[content.length - 1].cache_control = { type: 'ephemeral' }
+    breakpoints += 1
+  }
+}
+
 function chatContentToResponsesContent(
   content: ChatMessage['content']
 ): string | Array> | undefined {
@@ -1377,19 +1528,26 @@ function chatContentToPlainText(content: ChatMessage['content']): string {
   }).join('\n')
 }
 
-function responsesReasoningForEffort(effort: string | undefined): Record | null {
-  const normalized = effort?.trim().toLowerCase()
+type ModelReasoningCapability = NonNullable
+type NormalizedReasoningEffort = ModelReasoningCapability['defaultEffort']
+
+function responsesReasoningForEffort(
+  effort: string | undefined,
+  reasoning?: ModelReasoningCapability
+): Record | null {
+  if (reasoning && reasoning.requestProtocol !== 'openai-responses') return null
+  const resolved = reasoning
+    ? resolveReasoningEffort(effort, reasoning)
+    : normalizeReasoningEffortValue(effort)
+  if (resolved === 'auto' || resolved === 'off' || !resolved) return null
+  const normalized = resolved
   switch (normalized) {
     case 'low':
-    case 'minimal':
       return { effort: 'low' }
     case 'medium':
-    case 'mid':
       return { effort: 'medium' }
     case 'high':
     case 'max':
-    case 'maximum':
-    case 'xhigh':
       return { effort: 'high' }
     default:
       return null
@@ -1484,6 +1642,66 @@ function responseErrorMessage(payload: Record): string {
   return message || recordString(payload, 'message') || 'model stream reported an error'
 }
 
+function modelPayloadError(payload: Record): { message: string; code?: string } | null {
+  const rawError = payload.error
+  if (typeof rawError === 'string' && rawError.trim()) {
+    return { message: rawError.trim() }
+  }
+  const directError = modelErrorObject(recordValue(payload, 'error'))
+  if (directError) return directError
+  const responseError = modelErrorObject(recordValue(recordValue(payload, 'response'), 'error'))
+  if (responseError) return responseError
+  const baseResp = recordValue(payload, 'base_resp') ?? recordValue(payload, 'baseResp')
+  if (baseResp) {
+    const code = errorCodeString(
+      baseResp.status_code ?? baseResp.status ?? baseResp.code ?? baseResp.err_code
+    )
+    if (code && !successErrorCode(code)) {
+      return {
+        message:
+          recordString(baseResp, 'status_msg') ||
+          recordString(baseResp, 'message') ||
+          recordString(baseResp, 'msg') ||
+          `model provider error (${code})`,
+        code
+      }
+    }
+  }
+  const topLevelCode = errorCodeString(payload.code ?? payload.type ?? payload.status_code ?? payload.err_code)
+  const topLevelMessage =
+    recordString(payload, 'message') ||
+    recordString(payload, 'error_msg') ||
+    recordString(payload, 'status_msg')
+  if (topLevelCode && topLevelMessage && !successErrorCode(topLevelCode)) {
+    return { message: topLevelMessage, code: topLevelCode }
+  }
+  return null
+}
+
+function modelErrorObject(error: Record | null): { message: string; code?: string } | null {
+  if (!error) return null
+  const message =
+    recordString(error, 'message') ||
+    recordString(error, 'msg') ||
+    recordString(error, 'status_msg') ||
+    recordString(error, 'error_msg')
+  const code = errorCodeString(error.code ?? error.type ?? error.status ?? error.status_code ?? error.err_code)
+  if (message) return { message, ...(code ? { code } : {}) }
+  if (code && !successErrorCode(code)) return { message: `model provider error (${code})`, code }
+  return null
+}
+
+function errorCodeString(value: unknown): string {
+  if (typeof value === 'string') return value.trim()
+  if (typeof value === 'number' && Number.isFinite(value)) return String(value)
+  return ''
+}
+
+function successErrorCode(code: string): boolean {
+  const normalized = code.trim().toLowerCase()
+  return normalized === '0' || normalized === 'ok' || normalized === 'success'
+}
+
 function anthropicStopReason(value: unknown): ModelStopReason | undefined {
   if (typeof value !== 'string') return undefined
   switch (value) {
@@ -1535,44 +1753,154 @@ function mergeUsageSnapshots(current: UsageSnapshot | null, next: UsageSnapshot)
     cacheMissTokens: Math.max(current.cacheMissTokens ?? 0, next.cacheMissTokens ?? 0),
     cacheHitRate: next.cacheHitRate ?? current.cacheHitRate,
     costUsd: next.costUsd ?? current.costUsd,
-    costCny: next.costCny ?? current.costCny,
-    cacheSavingsUsd: next.cacheSavingsUsd ?? current.cacheSavingsUsd,
-    cacheSavingsCny: next.cacheSavingsCny ?? current.cacheSavingsCny
+    costCny: next.costCny ?? current.costCny
   }
 }
 
 function applyReasoningEffort(
   body: Record,
   effort: string | undefined,
-  options: { includeThinking?: boolean } = {}
+  options: {
+    includeThinking?: boolean
+    reasoning?: ModelReasoningCapability
+    maxReasoningEffort?: 'high' | 'max'
+  } = {}
 ): void {
-  const normalized = effort?.trim().toLowerCase()
+  const normalized = options.reasoning
+    ? resolveReasoningEffort(effort, options.reasoning)
+    : normalizeReasoningEffortValue(effort)
   if (!normalized) return
   const includeThinking = options.includeThinking !== false
+  if (options.reasoning) {
+    applyProfileReasoningEffort(body, normalized, options.reasoning, includeThinking)
+    return
+  }
   switch (normalized) {
     case 'off':
-    case 'disabled':
-    case 'none':
-    case 'false':
       if (includeThinking) body.thinking = { type: 'disabled' }
       break
     case 'low':
-    case 'minimal':
     case 'medium':
-    case 'mid':
     case 'high':
       body.reasoning_effort = 'high'
       if (includeThinking) body.thinking = { type: 'enabled' }
       break
     case 'max':
-    case 'maximum':
-    case 'xhigh':
-      body.reasoning_effort = 'max'
+      body.reasoning_effort = options.maxReasoningEffort ?? 'max'
       if (includeThinking) body.thinking = { type: 'enabled' }
       break
   }
 }
 
+function applyProfileReasoningEffort(
+  body: Record,
+  effort: NormalizedReasoningEffort,
+  reasoning: ModelReasoningCapability,
+  includeThinking: boolean
+): void {
+  switch (reasoning.requestProtocol) {
+    case 'none':
+    case 'openai-responses':
+    case 'anthropic-thinking':
+      return
+    case 'deepseek-chat-completions':
+      applyDeepSeekChatReasoningEffort(body, effort, includeThinking)
+      return
+    case 'mimo-chat-completions':
+      applyMimoChatReasoningEffort(body, effort, includeThinking)
+      return
+  }
+}
+
+function applyDeepSeekChatReasoningEffort(
+  body: Record,
+  effort: NormalizedReasoningEffort,
+  includeThinking: boolean
+): void {
+  if (effort === 'off') {
+    if (includeThinking) body.thinking = { type: 'disabled' }
+    return
+  }
+  if (effort === 'max') {
+    body.reasoning_effort = 'max'
+  } else if (effort !== 'auto') {
+    body.reasoning_effort = 'high'
+  }
+  if (includeThinking && effort !== 'auto') body.thinking = { type: 'enabled' }
+}
+
+function applyMimoChatReasoningEffort(
+  body: Record,
+  effort: NormalizedReasoningEffort,
+  includeThinking: boolean
+): void {
+  if (effort === 'off') {
+    if (includeThinking) body.thinking = { type: 'disabled' }
+    return
+  }
+  if (effort === 'low' || effort === 'medium' || effort === 'high') {
+    body.reasoning_effort = effort
+    if (includeThinking) body.thinking = { type: 'enabled' }
+  }
+}
+
+function applyAnthropicReasoningEffort(
+  body: Record,
+  effort: string | undefined,
+  reasoning?: ModelReasoningCapability
+): void {
+  if (reasoning?.requestProtocol !== 'anthropic-thinking') return
+  const resolved = resolveReasoningEffort(effort, reasoning)
+  if (!resolved) return
+  body.thinking = {
+    type: resolved === 'off' ? 'disabled' : 'adaptive'
+  }
+}
+
+function resolveReasoningEffort(
+  effort: string | undefined,
+  reasoning: ModelReasoningCapability
+): NormalizedReasoningEffort | undefined {
+  const normalized = normalizeReasoningEffortValue(effort)
+  if (!normalized) return undefined
+  if (reasoning.supportedEfforts.includes(normalized)) return normalized
+  if (
+    normalized === 'low' &&
+    reasoning.supportedEfforts.includes('off') &&
+    !reasoning.supportedEfforts.includes('low')
+  ) {
+    return 'off'
+  }
+  return reasoning.defaultEffort
+}
+
+function normalizeReasoningEffortValue(effort: string | undefined): NormalizedReasoningEffort | undefined {
+  switch (effort?.trim().toLowerCase()) {
+    case 'auto':
+    case 'adaptive':
+      return 'auto'
+    case 'off':
+    case 'disabled':
+    case 'none':
+    case 'false':
+      return 'off'
+    case 'low':
+    case 'minimal':
+      return 'low'
+    case 'medium':
+    case 'mid':
+      return 'medium'
+    case 'high':
+      return 'high'
+    case 'max':
+    case 'maximum':
+    case 'xhigh':
+      return 'max'
+    default:
+      return undefined
+  }
+}
+
 function shouldRetryWithoutStreamUsage(
   status: number,
   text: string,
@@ -1602,8 +1930,16 @@ function isThinkingMode(effort: string | undefined): boolean {
 function requiresReasoningRoundTrip(
   effort: string | undefined,
   model: string | undefined,
-  baseUrl: string
+  baseUrl: string,
+  reasoning?: ModelReasoningCapability
 ): boolean {
+  if (reasoning) {
+    const resolved = resolveReasoningEffort(effort, reasoning)
+    if (resolved) {
+      return resolved !== 'off' && reasoning.requestProtocol !== 'none'
+    }
+    return isDeepSeekHost(baseUrl) && isThinkingProducerModel(model)
+  }
   // Thinking-mode round trip is a DeepSeek-specific protocol extension.
   // OpenAI-compat providers (OpenRouter, llama.cpp, etc.) may reject
   // or misinterpret the `thinking` field, so we only auto-enable it
@@ -1869,6 +2205,7 @@ function formatAttachmentTextFallback(
   return [
     '[Attached image as base64 text]',
     `Name: ${attachment.name}`,
+    `FilePath: ${attachment.localFilePath ?? 'unknown'}`,
     `MIME: ${attachment.mimeType}`,
     `Dimensions: ${formatAttachmentDimensions(attachment)}`,
     `Bytes: ${attachment.byteSize}`,
diff --git a/kun/src/adapters/model/deepseek-pricing.ts b/kun/src/adapters/model/deepseek-pricing.ts
index 247dfe3e..f3e745d8 100644
--- a/kun/src/adapters/model/deepseek-pricing.ts
+++ b/kun/src/adapters/model/deepseek-pricing.ts
@@ -103,35 +103,6 @@ export function estimateDeepseekCost(input: {
   }
 }
 
-export function estimateDeepseekInputTokenCost(input: {
-  model: string
-  inputTokens: number
-  providerHost?: string
-}): DeepseekCurrencyCosts | null {
-  return estimateDeepseekCost({
-    model: input.model,
-    cacheHitTokens: 0,
-    cacheMissTokens: input.inputTokens,
-    outputTokens: 0,
-    providerHost: input.providerHost
-  })
-}
-
-export function estimateDeepseekCacheSavings(input: {
-  model: string
-  cacheHitTokens: number
-  providerHost?: string
-}): DeepseekCurrencyCosts | null {
-  if (input.providerHost !== undefined && !isDeepSeekHost(input.providerHost)) {
-    return null
-  }
-  const tier = pricingTierForModel(input.model)
-  if (!tier) return null
-  const prices = DEEPSEEK_V4_PRICES[tier]
-  return {
-    costUsd: (input.cacheHitTokens / TOKENS_PER_MILLION) *
-      Math.max(0, prices.usd.inputCacheMiss - prices.usd.inputCacheHit),
-    costCny: (input.cacheHitTokens / TOKENS_PER_MILLION) *
-      Math.max(0, prices.cny.inputCacheMiss - prices.cny.inputCacheHit)
-  }
-}
+// Savings are reported in tokens only. Money estimates for savings were
+// removed: list prices drift and third-party providers make any currency
+// figure unreliable, so the UI now shows saved tokens instead.
diff --git a/kun/src/adapters/model/minimax-pricing.ts b/kun/src/adapters/model/minimax-pricing.ts
new file mode 100644
index 00000000..ac437427
--- /dev/null
+++ b/kun/src/adapters/model/minimax-pricing.ts
@@ -0,0 +1,142 @@
+export type MiniMaxCurrencyCosts = {
+  costUsd?: number
+  costCny: number
+}
+
+type MiniMaxPrice = {
+  input: number
+  output: number
+  cacheRead: number
+  cacheWrite?: number
+}
+
+const TOKENS_PER_MILLION = 1_000_000
+const M3_LONG_CONTEXT_THRESHOLD = 512_000
+
+// Official MiniMax pay-as-you-go language model prices, CNY per 1M tokens.
+// Token Plan credits are deducted at the matching pay-as-you-go list price.
+const MINIMAX_TEXT_PRICES: Record = {
+  'minimax-m2.7': {
+    input: 2.1,
+    output: 8.4,
+    cacheRead: 0.42,
+    cacheWrite: 2.625
+  },
+  'minimax-m2.7-highspeed': {
+    input: 4.2,
+    output: 16.8,
+    cacheRead: 0.42,
+    cacheWrite: 2.625
+  },
+  'minimax-m2.5': {
+    input: 2.1,
+    output: 8.4,
+    cacheRead: 0.21,
+    cacheWrite: 2.625
+  },
+  'minimax-m2.5-highspeed': {
+    input: 4.2,
+    output: 16.8,
+    cacheRead: 0.21,
+    cacheWrite: 2.625
+  },
+  'minimax-m2.1': {
+    input: 2.1,
+    output: 8.4,
+    cacheRead: 0.21,
+    cacheWrite: 2.625
+  },
+  'minimax-m2.1-highspeed': {
+    input: 4.2,
+    output: 16.8,
+    cacheRead: 0.21,
+    cacheWrite: 2.625
+  },
+  'minimax-m2': {
+    input: 2.1,
+    output: 8.4,
+    cacheRead: 0.21,
+    cacheWrite: 2.625
+  }
+}
+
+const MINIMAX_M3_STANDARD_PRICE: MiniMaxPrice = {
+  input: 2.1,
+  output: 8.4,
+  cacheRead: 0.42
+}
+
+const MINIMAX_M3_LONG_CONTEXT_PRICE: MiniMaxPrice = {
+  input: 4.2,
+  output: 16.8,
+  cacheRead: 0.84
+}
+
+function isMiniMaxHost(baseUrl: string): boolean {
+  try {
+    const host = new URL(baseUrl).hostname.toLowerCase()
+    return host === 'api.minimaxi.com' || host === 'api.minimax.io' || host === 'api.minimax.chat'
+  } catch {
+    return false
+  }
+}
+
+function normalizeModel(model: string): string {
+  const normalized = model.trim().toLowerCase()
+  const parts = normalized.split('/').filter(Boolean)
+  return parts.at(-1) ?? normalized
+}
+
+function priceForModel(model: string, billableInputTokens: number): MiniMaxPrice | null {
+  const normalized = normalizeModel(model)
+  if (normalized === 'minimax-m3') {
+    return billableInputTokens > M3_LONG_CONTEXT_THRESHOLD
+      ? MINIMAX_M3_LONG_CONTEXT_PRICE
+      : MINIMAX_M3_STANDARD_PRICE
+  }
+  return MINIMAX_TEXT_PRICES[normalized] ?? null
+}
+
+function costCnyForPrice(input: {
+  price: MiniMaxPrice
+  inputTokens: number
+  cacheReadTokens: number
+  cacheWriteTokens: number
+  outputTokens: number
+}): number {
+  const cacheWritePrice = input.price.cacheWrite ?? input.price.input
+  return (
+    (input.inputTokens / TOKENS_PER_MILLION) * input.price.input +
+    (input.cacheReadTokens / TOKENS_PER_MILLION) * input.price.cacheRead +
+    (input.cacheWriteTokens / TOKENS_PER_MILLION) * cacheWritePrice +
+    (input.outputTokens / TOKENS_PER_MILLION) * input.price.output
+  )
+}
+
+export function estimateMiniMaxCost(input: {
+  model: string
+  providerHost?: string
+  inputTokens: number
+  cacheReadTokens: number
+  cacheWriteTokens: number
+  outputTokens: number
+}): MiniMaxCurrencyCosts | null {
+  if (input.providerHost !== undefined && !isMiniMaxHost(input.providerHost)) {
+    return null
+  }
+  const billableInputTokens = Math.max(
+    0,
+    input.inputTokens + input.cacheReadTokens + input.cacheWriteTokens
+  )
+  const price = priceForModel(input.model, billableInputTokens)
+  if (!price) return null
+  return {
+    costCny: costCnyForPrice({
+      price,
+      inputTokens: Math.max(0, input.inputTokens),
+      cacheReadTokens: Math.max(0, input.cacheReadTokens),
+      cacheWriteTokens: Math.max(0, input.cacheWriteTokens),
+      outputTokens: Math.max(0, input.outputTokens)
+    })
+  }
+}
diff --git a/kun/src/adapters/tool/builtin-file-tools.ts b/kun/src/adapters/tool/builtin-file-tools.ts
index 18ec80dc..07a14585 100644
--- a/kun/src/adapters/tool/builtin-file-tools.ts
+++ b/kun/src/adapters/tool/builtin-file-tools.ts
@@ -14,6 +14,26 @@ import { withFileMutationQueue } from './file-mutation-queue.js'
 import type { EditLocalToolOptions, WriteLocalToolOptions } from './builtin-tool-types.js'
 import { defaultEditLocalToolOperations, defaultWriteLocalToolOperations } from './builtin-tool-operations.js'
 import { parseEditInstructions, resolveWorkspacePath, withToolBoundary } from './builtin-tool-utils.js'
+import { assertCanWritePath } from './sandbox-policy.js'
+
+/**
+ * Arguments that failed JSON parsing arrive as `{ __raw: "" }`
+ * (tool-argument-repair fallback). The dominant cause is the model's output
+ * limit truncating an oversized payload mid-string, so answer with guidance
+ * the model can act on instead of a generic missing-field error.
+ */
+function truncatedArgumentsError(raw: unknown): { output: { error: string }; isError: true } | null {
+  if (typeof raw !== 'string') return null
+  return {
+    output: {
+      error:
+        'tool arguments were not valid JSON — they were likely truncated by your output limit. ' +
+        `Received ${raw.length} characters. Retry with a much smaller payload: ` +
+        'write a short skeleton first, then extend the file with several small edit calls.'
+    },
+    isError: true
+  }
+}
 
 export function createWriteLocalTool(_options: WriteLocalToolOptions = {}): LocalTool {
   const mkdirOp = _options.operations?.mkdir ?? defaultWriteLocalToolOperations.mkdir!
@@ -33,12 +53,15 @@ export function createWriteLocalTool(_options: WriteLocalToolOptions = {}): Loca
     policy: 'on-request',
     toolKind: 'file_change',
     execute: async (args, context) => withToolBoundary(async () => {
+      const truncated = truncatedArgumentsError(args.__raw)
+      if (truncated) return truncated
       const rawPath = typeof args.path === 'string' ? args.path : ''
       const content = typeof args.content === 'string' ? args.content : null
       if (!rawPath.trim() || content == null) {
         return { output: { error: 'path and content are required' }, isError: true }
       }
       const { absolutePath, relativePath } = resolveWorkspacePath(rawPath, context)
+      assertCanWritePath(absolutePath, context)
       return withFileMutationQueue(absolutePath, async () => {
         await mkdirOp(dirname(absolutePath))
         await writeFileOp(absolutePath, content)
@@ -88,12 +111,15 @@ export function createEditLocalTool(_options: EditLocalToolOptions = {}): LocalT
     policy: 'on-request',
     toolKind: 'file_change',
     execute: async (args, context) => withToolBoundary(async () => {
+      const truncated = truncatedArgumentsError(args.__raw)
+      if (truncated) return truncated
       const rawPath = typeof args.path === 'string' ? args.path : ''
       const edits = parseEditInstructions(args)
       if (!rawPath.trim() || edits.length === 0) {
         return { output: { error: 'path and at least one edit are required' }, isError: true }
       }
       const { absolutePath, relativePath } = resolveWorkspacePath(rawPath, context)
+      assertCanWritePath(absolutePath, context)
       return withFileMutationQueue(absolutePath, async () => {
         const rawSource = await readFileOp(absolutePath)
         const { bom, text: source } = stripBom(rawSource)
diff --git a/kun/src/adapters/tool/capability-registry.ts b/kun/src/adapters/tool/capability-registry.ts
index 03dfbfcf..65552bb6 100644
--- a/kun/src/adapters/tool/capability-registry.ts
+++ b/kun/src/adapters/tool/capability-registry.ts
@@ -4,6 +4,7 @@ import type {
   ToolProviderPolicy
 } from '../../ports/tool-host.js'
 import type { LocalTool } from './local-tool-host.js'
+import { isToolAdvertisedInSandbox } from './sandbox-policy.js'
 
 export type CapabilityToolRecord = {
   provider: ToolProviderPolicy
@@ -23,6 +24,16 @@ export type CapabilityToolSpec = {
   providerKind: ToolProviderKind
 }
 
+const PLAN_MODE_ALLOWED_TOOL_NAMES = new Set([
+  'read',
+  'grep',
+  'find',
+  'ls',
+  'create_plan',
+  'user_input',
+  'request_user_input'
+])
+
 export class CapabilityRegistry {
   private readonly providers = new Map()
   private readonly tools = new Map()
@@ -63,6 +74,7 @@ export class CapabilityRegistry {
     for (const record of this.tools.values()) {
       if (!this.canUseProvider(record.provider, context)) continue
       if (!this.canUseTool(record.tool.name, context)) continue
+      if (!isToolAdvertisedInSandbox(record.tool, context)) continue
       if (record.tool.shouldAdvertise) {
         if (!context || !record.tool.shouldAdvertise(context)) continue
       }
@@ -110,11 +122,18 @@ export class CapabilityRegistry {
   }
 
   private canUseTool(toolName: string, context?: ToolHostContext): boolean {
+    if (isPlanModeContext(context) && !PLAN_MODE_ALLOWED_TOOL_NAMES.has(toolName)) {
+      return false
+    }
     const allowed = context?.allowedToolNames
     return !allowed || allowed.includes(toolName)
   }
 }
 
+function isPlanModeContext(context: ToolHostContext | undefined): boolean {
+  return context?.threadMode === 'plan' || Boolean(context?.guiPlan)
+}
+
 function providerPolicy(provider: ToolProviderPolicy): ToolProviderPolicy {
   return {
     id: provider.id,
diff --git a/kun/src/adapters/tool/create-plan-tool.ts b/kun/src/adapters/tool/create-plan-tool.ts
index e474ad46..599d0310 100644
--- a/kun/src/adapters/tool/create-plan-tool.ts
+++ b/kun/src/adapters/tool/create-plan-tool.ts
@@ -15,6 +15,7 @@ import {
   type CreatePlanToolOutput,
   type GuiPlanOperation
 } from '../../shared/gui-plan.js'
+import { canWritePath } from './sandbox-policy.js'
 
 /**
  * Shared tool name. Kept in sync with the renderer contract so the
@@ -290,6 +291,16 @@ export async function executeCreatePlanTool(
     ? normalize(join(resolvedWorkspace, resolved.relativePath))
     : normalize(join(planDirectory(resolvedWorkspace), basename(resolved.relativePath)))
   assertWithinWorkspace(absolutePath, resolvedWorkspace)
+  const writePermission = canWritePath(absolutePath, context)
+  if (!writePermission.ok) {
+    return {
+      output: {
+        code: writePermission.block.code,
+        error: writePermission.block.message
+      },
+      isError: true
+    }
+  }
   if (context.abortSignal.aborted) {
     return { output: { error: 'plan write aborted' }, isError: true }
   }
diff --git a/kun/src/adapters/tool/image-gen-network-error.test.ts b/kun/src/adapters/tool/image-gen-network-error.test.ts
new file mode 100644
index 00000000..39ede02f
--- /dev/null
+++ b/kun/src/adapters/tool/image-gen-network-error.test.ts
@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { describeNetworkError, OpenAiCompatImageClient } from './image-gen-tool-provider.js'
+
+describe('describeNetworkError', () => {
+  it('unwraps the cause behind undici fetch failed errors', () => {
+    const dns = Object.assign(new Error('getaddrinfo ENOTFOUND images.example.test'), {
+      code: 'ENOTFOUND'
+    })
+    const wrapped = new TypeError('fetch failed', { cause: dns })
+    expect(describeNetworkError(wrapped)).toBe(
+      'fetch failed: getaddrinfo ENOTFOUND images.example.test'
+    )
+  })
+
+  it('digs into AggregateError connection failures', () => {
+    const refused = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8080'), {
+      code: 'ECONNREFUSED'
+    })
+    const wrapped = new TypeError('fetch failed', { cause: new AggregateError([refused], '') })
+    expect(describeNetworkError(wrapped)).toBe('fetch failed: connect ECONNREFUSED 127.0.0.1:8080')
+  })
+
+  it('appends error codes missing from the message', () => {
+    const tls = Object.assign(new Error('self-signed certificate'), {
+      code: 'DEPTH_ZERO_SELF_SIGNED_CERT'
+    })
+    expect(describeNetworkError(new TypeError('fetch failed', { cause: tls }))).toBe(
+      'fetch failed: self-signed certificate (DEPTH_ZERO_SELF_SIGNED_CERT)'
+    )
+  })
+
+  it('handles non-error values and empty chains', () => {
+    expect(describeNetworkError('boom')).toBe('boom')
+    expect(describeNetworkError(new Error(''))).toBe('unknown network error')
+  })
+})
+
+describe('OpenAiCompatImageClient network failures', () => {
+  afterEach(() => {
+    vi.unstubAllGlobals()
+  })
+
+  it('surfaces the failing endpoint and root cause instead of bare fetch failed', async () => {
+    const dns = Object.assign(new Error('getaddrinfo ENOTFOUND images.example.test'), {
+      code: 'ENOTFOUND'
+    })
+    vi.stubGlobal('fetch', vi.fn(async () => {
+      throw new TypeError('fetch failed', { cause: dns })
+    }))
+
+    const client = new OpenAiCompatImageClient('https://images.example.test/v1', 'sk-test')
+    await expect(
+      client.generate({
+        prompt: 'a cat',
+        model: 'test-model',
+        timeoutMs: 5_000,
+        signal: new AbortController().signal
+      })
+    ).rejects.toThrow(
+      'image request to https://images.example.test/v1/images/generations failed: ' +
+        'fetch failed: getaddrinfo ENOTFOUND images.example.test'
+    )
+  })
+
+  it('reports timeouts with the configured duration', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => {
+      throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
+    }))
+
+    const client = new OpenAiCompatImageClient('https://images.example.test/v1', 'sk-test')
+    await expect(
+      client.generate({
+        prompt: 'a cat',
+        model: 'test-model',
+        timeoutMs: 5_000,
+        signal: new AbortController().signal
+      })
+    ).rejects.toThrow('image request to https://images.example.test/v1/images/generations timed out after 5000ms')
+  })
+})
diff --git a/kun/src/adapters/tool/image-gen-tool-provider.ts b/kun/src/adapters/tool/image-gen-tool-provider.ts
new file mode 100644
index 00000000..13d4b5e4
--- /dev/null
+++ b/kun/src/adapters/tool/image-gen-tool-provider.ts
@@ -0,0 +1,655 @@
+import { randomBytes } from 'node:crypto'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { isAbsolute, join, relative, resolve } from 'node:path'
+import type { KunCapabilitiesConfig } from '../../contracts/capabilities.js'
+import type { AttachmentStore } from '../../attachments/attachment-store.js'
+import { detectImage } from '../../attachments/attachment-store.js'
+import type { CapabilityToolProvider } from './capability-registry.js'
+import { LocalToolHost } from './local-tool-host.js'
+
+const GENERATED_IMAGE_DIR = '.deepseekgui-images'
+const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024
+const REFERENCE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])
+const ASPECT_RATIOS = new Set(['1:1', '4:3', '3:4', '16:9', '9:16', '3:2', '2:3', '21:9'])
+const SIZE_TIERS: Record = { '1K': 1024, '2K': 2048 }
+const SIZE_STEP = 64
+const MIN_EDGE = 256
+
+export type GeneratedImage = { data: Buffer; mimeType: string }
+
+export type ImageGenRequest = {
+  prompt: string
+  model: string
+  size?: string
+  timeoutMs: number
+  signal: AbortSignal
+}
+
+export type ImageGenEditRequest = ImageGenRequest & {
+  images: { name: string; mimeType: string; data: Buffer }[]
+}
+
+export class ImageGenHttpError extends Error {
+  constructor(
+    readonly status: number,
+    readonly body: string
+  ) {
+    super(`HTTP ${status}: ${body}`)
+  }
+}
+
+/**
+ * Node's fetch reports every network failure as a bare `TypeError: fetch
+ * failed`, hiding the actionable detail (DNS, refused connection, TLS, …)
+ * in the `cause` chain. Flatten that chain into one readable message.
+ */
+export function describeNetworkError(error: unknown): string {
+  const parts: string[] = []
+  let current: unknown = error
+  for (let depth = 0; depth < 5 && current != null; depth += 1) {
+    if (current instanceof AggregateError && current.errors.length > 0) {
+      current = current.errors[0]
+      continue
+    }
+    if (!(current instanceof Error)) {
+      parts.push(String(current))
+      break
+    }
+    const code = (current as { code?: unknown }).code
+    const codeText = typeof code === 'string' ? code : ''
+    const message = current.message.trim()
+    if (message) {
+      parts.push(codeText && !message.includes(codeText) ? `${message} (${codeText})` : message)
+    } else if (codeText) {
+      parts.push(codeText)
+    }
+    current = current.cause
+  }
+  const unique = parts.filter((part, index) => parts.indexOf(part) === index)
+  return unique.join(': ') || 'unknown network error'
+}
+
+function imageFetchFailure(
+  url: string,
+  error: unknown,
+  request: { timeoutMs: number }
+): Error {
+  const target = url.split('?')[0]
+  if (error instanceof DOMException && error.name === 'TimeoutError') {
+    return new Error(`image request to ${target} timed out after ${request.timeoutMs}ms`, { cause: error })
+  }
+  if (error instanceof DOMException && error.name === 'AbortError') {
+    return new Error(`image request to ${target} was canceled`, { cause: error })
+  }
+  return new Error(`image request to ${target} failed: ${describeNetworkError(error)}`, { cause: error })
+}
+
+export interface ImageGenClient {
+  id: string
+  generate(request: ImageGenRequest): Promise
+  edit(request: ImageGenEditRequest): Promise
+}
+
+export type ImageGenDiagnostic = {
+  id: 'imageGen'
+  enabled: boolean
+  available: boolean
+  model?: string
+  reason?: string
+}
+
+export type ImageGenToolProviderOptions = {
+  client?: ImageGenClient
+  attachmentStore?: AttachmentStore
+  nowIso?: () => string
+}
+
+export type ImageGenToolProviderBuildResult = {
+  providers: CapabilityToolProvider[]
+  diagnostics: ImageGenDiagnostic[]
+  available: boolean
+}
+
+/**
+ * Map UI-friendly aspect ratio + size tier to an OpenAI-compatible "WxH"
+ * size string. Long edge anchors to the tier (1K→1024, 2K→2048), short edge
+ * follows the ratio snapped to multiples of 64 with a 256px floor. Both args
+ * absent → fall back to the configured default (may be undefined or 'auto').
+ */
+export function mapImageSize(
+  aspectRatio: string | undefined,
+  imageSize: string | undefined,
+  defaultSize: string | undefined
+): string | undefined {
+  if (!aspectRatio && !imageSize) return defaultSize
+  const tier = SIZE_TIERS[imageSize ?? ''] ?? SIZE_TIERS['1K']
+  const parsed = parseRatio(aspectRatio)
+  if (!parsed) return `${tier}x${tier}`
+  const { w, h } = parsed
+  if (w === h) return `${tier}x${tier}`
+  const short = Math.max(MIN_EDGE, Math.round((tier * Math.min(w, h)) / Math.max(w, h) / SIZE_STEP) * SIZE_STEP)
+  return w > h ? `${tier}x${short}` : `${short}x${tier}`
+}
+
+function parseRatio(aspectRatio: string | undefined): { w: number; h: number } | null {
+  if (!aspectRatio || !ASPECT_RATIOS.has(aspectRatio)) return null
+  const [w, h] = aspectRatio.split(':').map(Number)
+  if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null
+  return { w, h }
+}
+
+export function buildImageGenToolProviders(
+  config: KunCapabilitiesConfig['imageGen'] | undefined,
+  options: ImageGenToolProviderOptions = {}
+): ImageGenToolProviderBuildResult {
+  if (!config?.enabled) {
+    return { providers: [], diagnostics: [], available: false }
+  }
+
+  const missing = [
+    !config.baseUrl ? 'baseUrl' : undefined,
+    !config.apiKey ? 'apiKey' : undefined,
+    !config.model ? 'model' : undefined
+  ].filter((field): field is string => Boolean(field))
+
+  if (missing.length > 0) {
+    const reason = `image generation provider is not configured (missing ${missing.join(', ')})`
+    return {
+      providers: [{ id: 'imageGen', kind: 'image', enabled: true, available: false, reason, tools: [] }],
+      diagnostics: [{ id: 'imageGen', enabled: true, available: false, model: config.model, reason }],
+      available: false
+    }
+  }
+
+  const client = options.client ?? createImageGenClient(config)
+  const model = config.model!
+
+  const tool = LocalToolHost.defineTool({
+    name: 'generate_image',
+    description: [
+      'Generate an image from a text prompt using the configured image provider.',
+      'Optionally pass reference_image_paths (image files inside the workspace) to guide the result (image-to-image).',
+      `The generated image is saved under ${GENERATED_IMAGE_DIR}/ in the workspace and returned as an inline attachment preview.`,
+      'Generates exactly one image per call; call again for variations.'
+    ].join(' '),
+    inputSchema: {
+      type: 'object',
+      properties: {
+        prompt: { type: 'string', description: 'Detailed description of the image to generate' },
+        aspect_ratio: { type: 'string', enum: [...ASPECT_RATIOS] },
+        image_size: { type: 'string', enum: Object.keys(SIZE_TIERS), description: 'Resolution tier, defaults to 1K' },
+        reference_image_paths: {
+          type: 'array',
+          items: { type: 'string' },
+          maxItems: config.maxReferenceImages,
+          description: 'Workspace-relative paths of reference images for image-to-image guidance'
+        }
+      },
+      required: ['prompt'],
+      additionalProperties: false
+    },
+    policy: 'untrusted',
+    execute: async (args, context) => {
+      const startedAt = Date.now()
+      const prompt = pickString(args.prompt)
+      if (!prompt) return toolError('invalid_prompt', 'prompt is required')
+
+      const aspectRatio = pickString(args.aspect_ratio)
+      const imageSize = pickString(args.image_size)
+      const size = mapImageSize(aspectRatio, imageSize, config.defaultSize)
+
+      const references = await collectReferenceImages(
+        args.reference_image_paths,
+        context.workspace,
+        config.maxReferenceImages
+      )
+      if ('error' in references) return references.error
+
+      const endpoint = references.images.length > 0 ? 'edits' : 'generations'
+      let image: GeneratedImage
+      try {
+        const request = {
+          prompt,
+          model,
+          ...(size && size !== 'auto' ? { size } : {}),
+          timeoutMs: config.timeoutMs,
+          signal: context.abortSignal
+        }
+        image = endpoint === 'edits'
+          ? await client.edit({ ...request, images: references.images })
+          : await client.generate(request)
+      } catch (error) {
+        if (error instanceof ImageGenHttpError) {
+          if (endpoint === 'edits' && (error.status === 404 || error.status === 405 || error.status === 501)) {
+            return toolError(
+              'edits_unsupported',
+              'the configured image provider does not support reference images (/images/edits); retry generate_image without reference_image_paths'
+            )
+          }
+          return toolError('provider_error', error.message, telemetry(startedAt, client.id))
+        }
+        return toolError('generation_failed', errorMessage(error), telemetry(startedAt, client.id))
+      }
+
+      const detected = detectImage(image.data)
+      const mimeType = detected?.mimeType ?? image.mimeType ?? 'image/png'
+      const ext = mimeType === 'image/jpeg' ? 'jpg' : mimeType === 'image/webp' ? 'webp' : 'png'
+      const stamp = (options.nowIso?.() ?? new Date().toISOString()).replace(/\D/g, '').slice(0, 14)
+      const fileName = `img-${stamp}-${randomBytes(2).toString('hex')}.${ext}`
+      // Forward slashes regardless of platform: the path is echoed back to the
+      // model and rendered in chat, where POSIX-style relative paths are expected.
+      const relativePath = `${GENERATED_IMAGE_DIR}/${fileName}`
+      const absolutePath = join(context.workspace, GENERATED_IMAGE_DIR, fileName)
+      await mkdir(join(context.workspace, GENERATED_IMAGE_DIR), { recursive: true })
+      await writeFile(absolutePath, image.data)
+
+      const warnings: string[] = []
+      const attachments: { id: string; name: string; mimeType: string; width?: number; height?: number }[] = []
+      if (options.attachmentStore) {
+        try {
+          const attachment = await options.attachmentStore.create({
+            name: fileName,
+            data: image.data,
+            mimeType,
+            threadId: context.threadId,
+            workspace: context.workspace
+          })
+          attachments.push({
+            id: attachment.id,
+            name: attachment.name,
+            mimeType: attachment.mimeType,
+            ...(attachment.width ? { width: attachment.width } : {}),
+            ...(attachment.height ? { height: attachment.height } : {})
+          })
+        } catch (error) {
+          warnings.push(`inline preview unavailable: ${errorMessage(error)}`)
+        }
+      } else {
+        warnings.push('inline preview unavailable: attachment store is disabled')
+      }
+
+      return {
+        output: {
+          files: [{
+            relativePath,
+            absolutePath,
+            mimeType,
+            byteSize: image.data.byteLength,
+            ...(detected?.width ? { width: detected.width } : {}),
+            ...(detected?.height ? { height: detected.height } : {})
+          }],
+          attachments,
+          model,
+          ...(size ? { size } : {}),
+          endpoint,
+          warnings,
+          telemetry: telemetry(startedAt, client.id)
+        }
+      }
+    }
+  })
+
+  return {
+    providers: [{ id: 'imageGen', kind: 'image', enabled: true, available: true, tools: [tool] }],
+    diagnostics: [{ id: 'imageGen', enabled: true, available: true, model }],
+    available: true
+  }
+}
+
+type ReferenceImages = { images: { name: string; mimeType: string; data: Buffer }[] }
+type ReferenceError = { error: { output: unknown; isError: true } }
+
+async function collectReferenceImages(
+  value: unknown,
+  workspace: string,
+  maxCount: number
+): Promise {
+  if (value === undefined || value === null) return { images: [] }
+  if (!Array.isArray(value)) {
+    return { error: toolError('invalid_reference_path', 'reference_image_paths must be an array of strings') }
+  }
+  const paths = value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
+  if (paths.length > maxCount) {
+    return { error: toolError('invalid_reference_path', `at most ${maxCount} reference images are allowed`) }
+  }
+  const images: ReferenceImages['images'] = []
+  for (const rawPath of paths) {
+    const resolved = resolve(workspace, rawPath)
+    const rel = relative(workspace, resolved)
+    if (rel.startsWith('..') || isAbsolute(rel)) {
+      return { error: toolError('invalid_reference_path', `reference image must be inside the workspace: ${rawPath}`) }
+    }
+    let data: Buffer
+    try {
+      data = await readFile(resolved)
+    } catch {
+      return { error: toolError('invalid_reference_path', `reference image not found: ${rawPath}`) }
+    }
+    if (data.byteLength > MAX_REFERENCE_IMAGE_BYTES) {
+      return { error: toolError('invalid_reference_path', `reference image exceeds ${MAX_REFERENCE_IMAGE_BYTES} byte limit: ${rawPath}`) }
+    }
+    const detected = detectImage(data)
+    if (!detected || !REFERENCE_MIME_TYPES.has(detected.mimeType)) {
+      return { error: toolError('invalid_reference_path', `reference image must be png, jpeg, or webp: ${rawPath}`) }
+    }
+    images.push({ name: rawPath.split('/').pop() || 'reference.png', mimeType: detected.mimeType, data })
+  }
+  return { images }
+}
+
+type ImagesApiPayload = { data?: { b64_json?: string; url?: string }[] }
+type MiniMaxImagePayload = {
+  data?: {
+    image_base64?: string[]
+    image_urls?: string[]
+  }
+  base_resp?: {
+    status_code?: number
+    status_msg?: string
+  }
+}
+
+export function createImageGenClient(config: {
+  protocol?: string
+  baseUrl?: string
+  apiKey?: string
+}): ImageGenClient {
+  if (config.protocol === 'minimax-image') {
+    return new MiniMaxImageClient(config.baseUrl!, config.apiKey!)
+  }
+  return new OpenAiCompatImageClient(config.baseUrl!, config.apiKey!)
+}
+
+/**
+ * Endpoint URL for an OpenAI-compatible images API. Mirrors the chat
+ * client's base-url rule so the same provider baseUrl works for both:
+ * a versioned base (`…/v1`) gets the endpoint appended, anything else
+ * gets `/v1` inserted first (e.g. `https://zenmux.ai/api` →
+ * `…/api/v1/images/generations`). A fully-qualified endpoint URL is
+ * kept, including re-routing between generations and edits.
+ */
+export function openAiCompatImageUrl(
+  baseUrl: string,
+  endpoint: 'generations' | 'edits'
+): string {
+  const path = `images/${endpoint}`
+  let normalized = baseUrl.trim().replace(/\/+$/, '')
+  if (!normalized) return `/v1/${path}`
+  const lower = normalized.toLowerCase()
+  if (lower.endsWith(`/${path}`)) return normalized
+  for (const known of ['images/generations', 'images/edits']) {
+    if (lower.endsWith(`/${known}`)) {
+      normalized = normalized.slice(0, -known.length).replace(/\/+$/, '')
+      break
+    }
+  }
+  const lastSegment = normalized.split('/').pop()?.toLowerCase() ?? ''
+  if (/^v\d+$/.test(lastSegment)) return `${normalized}/${path}`
+  return `${normalized}/v1/${path}`
+}
+
+export class OpenAiCompatImageClient implements ImageGenClient {
+  readonly id = 'openai-compat'
+  private readonly baseUrl: string
+
+  constructor(
+    baseUrl: string,
+    private readonly apiKey: string
+  ) {
+    this.baseUrl = baseUrl.replace(/\/+$/, '')
+  }
+
+  async generate(request: ImageGenRequest): Promise {
+    const body = (includeResponseFormat: boolean) =>
+      JSON.stringify({
+        model: request.model,
+        prompt: request.prompt,
+        n: 1,
+        ...(request.size ? { size: request.size } : {}),
+        ...(includeResponseFormat ? { response_format: 'b64_json' } : {})
+      })
+    return this.requestImage(
+      openAiCompatImageUrl(this.baseUrl, 'generations'),
+      (includeResponseFormat) => ({
+        headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
+        body: body(includeResponseFormat)
+      }),
+      request
+    )
+  }
+
+  async edit(request: ImageGenEditRequest): Promise {
+    const buildForm = (includeResponseFormat: boolean) => {
+      const form = new FormData()
+      form.set('model', request.model)
+      form.set('prompt', request.prompt)
+      if (request.size) form.set('size', request.size)
+      if (includeResponseFormat) form.set('response_format', 'b64_json')
+      const field = request.images.length > 1 ? 'image[]' : 'image'
+      for (const image of request.images) {
+        form.append(field, new Blob([new Uint8Array(image.data)], { type: image.mimeType }), image.name)
+      }
+      return form
+    }
+    return this.requestImage(
+      openAiCompatImageUrl(this.baseUrl, 'edits'),
+      (includeResponseFormat) => ({
+        headers: { Authorization: `Bearer ${this.apiKey}` },
+        body: buildForm(includeResponseFormat)
+      }),
+      request
+    )
+  }
+
+  /**
+   * POST with two compat fallbacks: providers that reject `response_format`
+   * (e.g. gpt-image-1) get one retry without it, and providers that return a
+   * URL instead of b64_json (e.g. SiliconFlow default) get a second download.
+   */
+  private async requestImage(
+    url: string,
+    init: (includeResponseFormat: boolean) => { headers: Record; body: string | FormData },
+    request: { timeoutMs: number; signal: AbortSignal }
+  ): Promise {
+    const signal = withTimeout(request.signal, request.timeoutMs)
+    const post = async (includeResponseFormat: boolean): Promise => {
+      try {
+        return await fetch(url, { method: 'POST', ...init(includeResponseFormat), signal })
+      } catch (error) {
+        throw imageFetchFailure(url, error, request)
+      }
+    }
+    let response = await post(true)
+    if (!response.ok && response.status >= 400 && response.status < 500) {
+      const errorBody = await response.text()
+      if (!/response_format/i.test(errorBody)) throw new ImageGenHttpError(response.status, errorBody)
+      response = await post(false)
+    }
+    if (!response.ok) {
+      throw new ImageGenHttpError(response.status, await response.text())
+    }
+    const payload = (await response.json()) as ImagesApiPayload
+    const entry = payload.data?.[0]
+    if (entry?.b64_json) {
+      return { data: Buffer.from(entry.b64_json, 'base64'), mimeType: 'image/png' }
+    }
+    if (entry?.url) {
+      let download: Response
+      try {
+        download = await fetch(entry.url, { signal })
+      } catch (error) {
+        throw imageFetchFailure(entry.url, error, request)
+      }
+      if (!download.ok) throw new ImageGenHttpError(download.status, await download.text())
+      const mimeType = download.headers.get('content-type')?.split(';')[0] || 'image/png'
+      return { data: Buffer.from(await download.arrayBuffer()), mimeType }
+    }
+    throw new Error('image provider returned no image data')
+  }
+}
+
+export class MiniMaxImageClient implements ImageGenClient {
+  readonly id = 'minimax-image'
+  private readonly endpointUrl: string
+
+  constructor(
+    baseUrl: string,
+    private readonly apiKey: string
+  ) {
+    this.endpointUrl = minimaxImageGenerationUrl(baseUrl)
+  }
+
+  async generate(request: ImageGenRequest): Promise {
+    return this.requestImage({
+      model: request.model,
+      prompt: request.prompt,
+      ...minimaxImageDimensionFields(request.model, request.size),
+      prompt_optimizer: true,
+      response_format: 'base64',
+      n: 1
+    }, request)
+  }
+
+  async edit(request: ImageGenEditRequest): Promise {
+    return this.requestImage({
+      model: request.model,
+      prompt: request.prompt,
+      ...minimaxImageDimensionFields(request.model, request.size),
+      subject_reference: request.images.map((image) => ({
+        type: 'character',
+        image_file: `data:${image.mimeType};base64,${image.data.toString('base64')}`
+      })),
+      prompt_optimizer: true,
+      response_format: 'base64',
+      n: 1
+    }, request)
+  }
+
+  private async requestImage(
+    body: Record,
+    request: { timeoutMs: number; signal: AbortSignal }
+  ): Promise {
+    const signal = withTimeout(request.signal, request.timeoutMs)
+    let response: Response
+    try {
+      response = await fetch(this.endpointUrl, {
+        method: 'POST',
+        headers: {
+          Authorization: `Bearer ${this.apiKey}`,
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(body),
+        signal
+      })
+    } catch (error) {
+      throw imageFetchFailure(this.endpointUrl, error, request)
+    }
+    const text = await response.text()
+    if (!response.ok) throw new ImageGenHttpError(response.status, text)
+    let payload: MiniMaxImagePayload
+    try {
+      payload = JSON.parse(text) as MiniMaxImagePayload
+    } catch {
+      throw new Error('MiniMax image provider returned invalid JSON')
+    }
+    const statusCode = payload.base_resp?.status_code
+    if (typeof statusCode === 'number' && statusCode !== 0) {
+      throw new Error(`MiniMax image provider failed (${statusCode}): ${payload.base_resp?.status_msg ?? 'unknown error'}`)
+    }
+    const b64 = payload.data?.image_base64?.[0]
+    if (b64) {
+      return { data: Buffer.from(b64, 'base64'), mimeType: 'image/jpeg' }
+    }
+    const imageUrl = payload.data?.image_urls?.[0]
+    if (imageUrl) {
+      let download: Response
+      try {
+        download = await fetch(imageUrl, { signal })
+      } catch (error) {
+        throw imageFetchFailure(imageUrl, error, request)
+      }
+      if (!download.ok) throw new ImageGenHttpError(download.status, await download.text())
+      const mimeType = download.headers.get('content-type')?.split(';')[0] || 'image/jpeg'
+      return { data: Buffer.from(await download.arrayBuffer()), mimeType }
+    }
+    throw new Error('MiniMax image provider returned no image data')
+  }
+}
+
+function minimaxImageGenerationUrl(baseUrl: string): string {
+  const normalized = baseUrl.trim().replace(/\/+$/, '')
+  const lower = normalized.toLowerCase()
+  if (!normalized) return '/v1/image_generation'
+  if (lower.endsWith('/v1/image_generation') || lower.endsWith('/image_generation')) return normalized
+  if (lower.endsWith('/v1')) return `${normalized}/image_generation`
+  return `${normalized}/v1/image_generation`
+}
+
+// aspect_ratio values both MiniMax image models accept (21:9 is image-01
+// only, and image-01 receives explicit width/height instead).
+const MINIMAX_ASPECT_RATIOS: Array<{ label: string; value: number }> = [
+  { label: '1:1', value: 1 },
+  { label: '16:9', value: 16 / 9 },
+  { label: '4:3', value: 4 / 3 },
+  { label: '3:2', value: 3 / 2 },
+  { label: '2:3', value: 2 / 3 },
+  { label: '3:4', value: 3 / 4 },
+  { label: '9:16', value: 9 / 16 }
+]
+
+/**
+ * MiniMax dimension fields for a `WxH` size. Per the t2i API docs only
+ * image-01 accepts explicit width/height (range [512, 2048], multiples
+ * of 8); image-01-live rejects them with status 2013, so every other model
+ * gets the nearest supported aspect_ratio instead. Nearest (not exact)
+ * because mapImageSize rounds edges to multiples of 8 — e.g. 3:2 at the 1K
+ * tier becomes 1024x680.
+ */
+export function minimaxImageDimensionFields(
+  model: string,
+  size: string | undefined
+): Record {
+  const match = size?.trim().match(/^(\d+)x(\d+)$/)
+  if (!match) return {}
+  const width = Number(match[1])
+  const height = Number(match[2])
+  if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return {}
+  if (model.trim() === 'image-01') return { width, height }
+  const target = width / height
+  let best = MINIMAX_ASPECT_RATIOS[0]
+  let bestDiff = Number.POSITIVE_INFINITY
+  for (const candidate of MINIMAX_ASPECT_RATIOS) {
+    const diff = Math.abs(Math.log(candidate.value / target))
+    if (diff < bestDiff) {
+      bestDiff = diff
+      best = candidate
+    }
+  }
+  return { aspect_ratio: best.label }
+}
+
+function withTimeout(signal: AbortSignal, timeoutMs: number): AbortSignal {
+  return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
+}
+
+function telemetry(startedAt: number, provider: string): Record {
+  return { provider, durationMs: Date.now() - startedAt }
+}
+
+function toolError(code: string, message: string, toolTelemetry?: Record): { output: unknown; isError: true } {
+  return {
+    output: {
+      error: { code, message },
+      ...(toolTelemetry ? { telemetry: toolTelemetry } : {})
+    },
+    isError: true
+  }
+}
+
+function pickString(value: unknown): string | undefined {
+  return typeof value === 'string' && value.trim() ? value.trim() : undefined
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
diff --git a/kun/src/adapters/tool/local-tool-host.ts b/kun/src/adapters/tool/local-tool-host.ts
index bd69409c..3cf1aa72 100644
--- a/kun/src/adapters/tool/local-tool-host.ts
+++ b/kun/src/adapters/tool/local-tool-host.ts
@@ -12,11 +12,12 @@ import { makeToolResultItem, makeApprovalItem } from '../../domain/item.js'
 import { buildBuiltinLocalTools } from './builtin-tools.js'
 import { CapabilityRegistry } from './capability-registry.js'
 import {
-  applyPostToolHookResults,
-  applyPreToolHookResults,
-  runToolHooks,
-  type ResolvedToolHook
-} from './tool-hooks.js'
+  runPostToolUseHooks,
+  runPreToolUseHooks,
+  type PostToolUseOutcome,
+  type PreToolUseOutcome,
+  type ResolvedHook
+} from '../../hooks/hook-engine.js'
 import {
   normalizeRateLimitedToolOutput
 } from './tool-rate-limit.js'
@@ -25,6 +26,7 @@ import {
   ReadTracker,
   type ReadTrackerOptions
 } from './read-tracker.js'
+import { sandboxBlockForTool, type SandboxBlock } from './sandbox-policy.js'
 
 /**
  * A single registered tool. Tools are pure functions that observe the
@@ -60,8 +62,8 @@ export type LocalToolHostOptions = {
   registry?: CapabilityRegistry
   /** Allow-list for `untrusted` policy. Tools outside the list always prompt. */
   allowList?: string[]
-  /** Optional PreToolUse/PostToolUse hooks. */
-  hooks?: readonly ResolvedToolHook[]
+  /** Optional PreToolUse/PostToolUse hooks (lifecycle phases are ignored here). */
+  hooks?: readonly ResolvedHook[]
   /** Runtime read-before-edit guard. Disabled by default for direct unit use. */
   readTracker?: boolean | ReadTrackerOptions
 }
@@ -84,7 +86,7 @@ export class LocalToolHost implements ToolHost {
   readonly id = 'local'
   private readonly registry: CapabilityRegistry
   private readonly allowList: Set
-  private readonly hooks: readonly ResolvedToolHook[]
+  private readonly hooks: readonly ResolvedHook[]
   private readonly readTracker: ReadTracker
 
   constructor(options: LocalToolHostOptions) {
@@ -114,15 +116,18 @@ export class LocalToolHost implements ToolHost {
     if (tool.policy === 'never') {
       throw new Error(`tool ${call.toolName} is disabled by policy`)
     }
-    let preHookResults
+    const sandboxBlock = sandboxBlockForTool(tool, context)
+    if (sandboxBlock) {
+      return {
+        item: this.errorToolResult(context, call, tool, sandboxBlock.message, sandboxBlock.code),
+        approved: false
+      }
+    }
+    let preHooks: PreToolUseOutcome
     try {
-      preHookResults = await runToolHooks({
-        hooks: this.hooks,
-        invocation: {
-          phase: 'PreToolUse',
-          call,
-          context: hookContext(context)
-        }
+      preHooks = await runPreToolUseHooks(this.hooks, {
+        call,
+        context: hookContext(context)
       })
     } catch (error) {
       return {
@@ -130,14 +135,13 @@ export class LocalToolHost implements ToolHost {
         approved: false
       }
     }
-    const preHookDecision = applyPreToolHookResults(call, preHookResults)
-    if (preHookDecision.denied) {
+    if (preHooks.denied) {
       return {
-        item: this.errorToolResult(context, preHookDecision.call, tool, preHookDecision.denied, 'hook_denied'),
+        item: this.errorToolResult(context, preHooks.call, tool, preHooks.denied, 'hook_denied'),
         approved: false
       }
     }
-    const activeCall = preHookDecision.call
+    const activeCall = preHooks.call
     const readValidation = this.readTracker.validateBeforeTool({ context, call: activeCall })
     if (!readValidation.ok) {
       return {
@@ -145,19 +149,20 @@ export class LocalToolHost implements ToolHost {
         approved: false
       }
     }
-    if (this.isBlockedByRuntimePolicy(tool, activeCall, context)) {
+    const runtimeBlock = this.runtimePolicyBlock(tool, activeCall, context)
+    if (runtimeBlock) {
       return {
         item: this.errorToolResult(
           context,
           activeCall,
           tool,
-          `tool ${activeCall.toolName} is disabled by runtime approval policy`,
-          'approval_policy_blocked'
+          runtimeBlock.message,
+          runtimeBlock.code
         ),
         approved: false
       }
     }
-    const needsApproval = this.requiresApproval(tool, activeCall, context)
+    const needsApproval = !preHooks.autoApproved && this.requiresApproval(tool, activeCall, context)
     if (needsApproval) {
       const approvalId = `appr_${activeCall.callId}`
       const approval: ApprovalRequest = createApprovalRequest({
@@ -183,31 +188,40 @@ export class LocalToolHost implements ToolHost {
     if (context.abortSignal.aborted) {
       throw new Error('tool call aborted while waiting for approval')
     }
-    const result = await tool.execute(activeCall.arguments, context, async (update) => {
-      if (!onUpdate) return
-      const partialItem = makeToolResultItem({
-        id: `item_${activeCall.callId}`,
-        turnId: context.turnId,
-        threadId: context.threadId,
-        callId: activeCall.callId,
-        toolName: activeCall.toolName,
-        toolKind: activeCall.toolKind ?? tool.toolKind,
-        output: update.output,
-        isError: update.isError,
-        status: 'running'
+    let result: Awaited>
+    try {
+      result = await tool.execute(activeCall.arguments, context, async (update) => {
+        if (!onUpdate) return
+        const partialItem = makeToolResultItem({
+          id: `item_${activeCall.callId}`,
+          turnId: context.turnId,
+          threadId: context.threadId,
+          callId: activeCall.callId,
+          toolName: activeCall.toolName,
+          toolKind: activeCall.toolKind ?? tool.toolKind,
+          output: update.output,
+          isError: update.isError,
+          status: 'running'
+        })
+        await onUpdate(partialItem)
       })
-      await onUpdate(partialItem)
-    })
-    let postHookResults
+    } catch (error) {
+      // A tool blowing up (an MCP server returning a protocol error, a
+      // provider bug) is feedback for the model, not a reason to kill the
+      // whole turn. Only abort keeps propagating.
+      if (context.abortSignal.aborted) throw error
+      const message = error instanceof Error ? error.message : String(error)
+      return {
+        item: this.errorToolResult(context, activeCall, tool, message, 'tool_execution_failed'),
+        approved: true
+      }
+    }
+    let hookedResult: PostToolUseOutcome
     try {
-      postHookResults = await runToolHooks({
-        hooks: this.hooks,
-        invocation: {
-          phase: 'PostToolUse',
-          call: activeCall,
-          context: hookContext(context),
-          result
-        }
+      hookedResult = await runPostToolUseHooks(this.hooks, {
+        call: activeCall,
+        context: hookContext(context),
+        result
       })
     } catch (error) {
       return {
@@ -215,7 +229,6 @@ export class LocalToolHost implements ToolHost {
         approved: true
       }
     }
-    const hookedResult = applyPostToolHookResults(result, postHookResults)
     const rateLimited = normalizeRateLimitedToolOutput(hookedResult.output)
     const output = rateLimited.rateLimited ? rateLimited.output : hookedResult.output
     const isError = hookedResult.isError || rateLimited.isError
@@ -242,14 +255,23 @@ export class LocalToolHost implements ToolHost {
     this.readTracker.clear(threadId)
   }
 
-  private isBlockedByRuntimePolicy(
+  private runtimePolicyBlock(
     tool: LocalTool,
     call: ToolCallLike,
     context: ToolHostContext
-  ): boolean {
-    if (this.isInteractiveGuiGateTool(call.toolName)) return false
-    if (context.approvalPolicy !== 'never') return false
-    return tool.policy !== 'never'
+  ): SandboxBlock | { code: 'approval_policy_blocked'; message: string } | null {
+    const sandboxBlock = sandboxBlockForTool(
+      { name: call.toolName, toolKind: call.toolKind ?? tool.toolKind },
+      context
+    )
+    if (sandboxBlock) return sandboxBlock
+    if (this.isInteractiveGuiGateTool(call.toolName)) return null
+    if (context.approvalPolicy !== 'never') return null
+    if (tool.policy === 'never') return null
+    return {
+      code: 'approval_policy_blocked',
+      message: `tool ${call.toolName} is disabled by runtime approval policy`
+    }
   }
 
   private requiresApproval(tool: LocalTool, call: ToolCallLike, context: ToolHostContext): boolean {
@@ -318,12 +340,13 @@ export class LocalToolHost implements ToolHost {
 
 function hookContext(
   context: ToolHostContext
-): Pick {
+): Pick {
   return {
     threadId: context.threadId,
     turnId: context.turnId,
     workspace: context.workspace,
     approvalPolicy: context.approvalPolicy,
+    ...(context.sandboxMode ? { sandboxMode: context.sandboxMode } : {}),
     ...(context.threadMode ? { threadMode: context.threadMode } : {})
   }
 }
@@ -400,6 +423,9 @@ function createUserInputTool(name: string): LocalTool {
       required: []
     },
     policy: 'auto',
+    // Only advertised when the turn can actually resolve structured
+    // input (IM bridges and headless runs omit `awaitUserInput`).
+    shouldAdvertise: (context) => typeof context.awaitUserInput === 'function',
     execute: async (args, context) => {
       if (!context.awaitUserInput) {
         return {
diff --git a/kun/src/adapters/tool/mcp-tool-provider.ts b/kun/src/adapters/tool/mcp-tool-provider.ts
index 9e880ac7..9496637d 100644
--- a/kun/src/adapters/tool/mcp-tool-provider.ts
+++ b/kun/src/adapters/tool/mcp-tool-provider.ts
@@ -1,5 +1,6 @@
 import { Client } from '@modelcontextprotocol/sdk/client/index.js'
 import { createHash } from 'node:crypto'
+import { posix, win32 } from 'node:path'
 import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
 import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
 import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
@@ -77,6 +78,19 @@ export type McpToolProviderBuildResult = {
 export type McpToolProviderOptions = {
   clientFactory?: (serverId: string, server: McpServerConfig) => Promise
   nowIso?: () => string
+  /**
+   * Upper bound for connect + initial tool listing per server during startup.
+   * A slow or hung server (e.g. an npx-based stdio server resolving packages)
+   * must not keep the whole runtime from reporting ready.
+   */
+  startupConnectTimeoutMs?: number
+}
+
+const DEFAULT_MCP_STARTUP_CONNECT_TIMEOUT_MS = 10_000
+
+export type McpStdioEnvironmentOptions = {
+  platform?: NodeJS.Platform
+  baseEnv?: NodeJS.ProcessEnv
 }
 
 type McpConnectionState = {
@@ -128,36 +142,75 @@ export async function buildMcpToolProviders(
     }
   }
 
-  for (const [serverId, server] of Object.entries(mcp.servers)) {
-    if (!server.enabled) {
-      diagnostics.push(serverDiagnostic({ serverId, server }, 'disabled', 0))
+  // Connect all servers in parallel — startup previously paid the sum of
+  // every server's connect + list latency, and a single hung server (e.g.
+  // npx resolving a package) blocked the runtime ready signal forever.
+  const startupTimeoutMs = options.startupConnectTimeoutMs ?? DEFAULT_MCP_STARTUP_CONNECT_TIMEOUT_MS
+  type ConnectOutcome =
+    | { serverId: string; server: McpServerConfig; status: 'disabled' }
+    | { serverId: string; server: McpServerConfig; status: 'error'; error: unknown }
+    | {
+        serverId: string
+        server: McpServerConfig
+        status: 'connected'
+        state: McpConnectionState
+        listed: McpToolDescriptor[]
+      }
+  const outcomes = await Promise.all(
+    Object.entries(mcp.servers).map(async ([serverId, server]): Promise => {
+      if (!server.enabled) {
+        return { serverId, server, status: 'disabled' }
+      }
+      const attempt = (async () => {
+        const client = await clientFactory(serverId, server)
+        const state: McpConnectionState = {
+          serverId,
+          server,
+          client,
+          clientFactory,
+          nowIso,
+          lastConnectedAt: nowIso()
+        }
+        const listed = await refreshMcpConnectionCatalog(state)
+        return { state, listed }
+      })()
+      try {
+        const result = await raceStartupTimeout(attempt, startupTimeoutMs, serverId)
+        return { serverId, server, status: 'connected', ...result }
+      } catch (error) {
+        return { serverId, server, status: 'error', error }
+      }
+    })
+  )
+
+  for (const outcome of outcomes) {
+    if (outcome.status === 'disabled') {
+      diagnostics.push(serverDiagnostic({ serverId: outcome.serverId, server: outcome.server }, 'disabled', 0))
       continue
     }
-    try {
-      const client = await clientFactory(serverId, server)
-      const state: McpConnectionState = {
-        serverId,
-        server,
-        client,
-        clientFactory,
-        nowIso,
-        lastConnectedAt: nowIso()
-      }
-      connected.push(state)
-      const listed = await refreshMcpConnectionCatalog(state)
-      catalogState.records.push(...listed.map((tool) => createMcpSearchCatalogRecord(state, tool)))
-      const tools = listed.map((tool) => createMcpLocalTool(state, tool))
-      directProviders.push({
-        id: `mcp:${serverId}`,
-        kind: 'mcp',
-        enabled: true,
-        available: true,
-        tools
-      })
-      diagnostics.push(serverDiagnostic(state, 'connected', tools.length))
-    } catch (error) {
-      diagnostics.push(serverDiagnostic({ serverId, server }, 'error', 0, errorMessage(error)))
+    if (outcome.status === 'error') {
+      diagnostics.push(
+        serverDiagnostic(
+          { serverId: outcome.serverId, server: outcome.server },
+          'error',
+          0,
+          formatMcpConnectionError(outcome.error, outcome.server)
+        )
+      )
+      continue
     }
+    const { state, listed } = outcome
+    connected.push(state)
+    catalogState.records.push(...listed.map((tool) => createMcpSearchCatalogRecord(state, tool)))
+    const tools = listed.map((tool) => createMcpLocalTool(state, tool))
+    directProviders.push({
+      id: `mcp:${outcome.serverId}`,
+      kind: 'mcp',
+      enabled: true,
+      available: true,
+      tools
+    })
+    diagnostics.push(serverDiagnostic(state, 'connected', tools.length))
   }
 
   const connectedServers = diagnostics.filter((diagnostic) => diagnostic.status === 'connected').length
@@ -248,7 +301,7 @@ function createTransport(server: McpServerConfig): Transport {
       return new StdioClientTransport({
         command: server.command ?? '',
         args: server.args,
-        env: server.env,
+        env: buildMcpStdioEnvironment(server.env),
         stderr: 'pipe'
       })
     case 'streamable-http':
@@ -356,11 +409,58 @@ async function callMcpToolWithReconnect(
   } catch (error) {
     state.lastError = redactSecretText(errorMessage(error))
     if (signal?.aborted) throw error
+    // Deterministic server-side failures (validation errors, bad
+    // arguments) come back identically on a fresh connection; tearing
+    // down a healthy session for them just loses server state. Only
+    // transport-looking failures earn a reconnect + retry.
+    if (!looksLikeMcpTransportError(error)) throw error
     const client = await reconnectMcpConnection(state)
     return client.callTool(input, { signal, timeout })
   }
 }
 
+function looksLikeMcpTransportError(error: unknown): boolean {
+  const message = errorMessage(error).toLowerCase()
+  return (
+    message.includes('connect') ||
+    message.includes('connection') ||
+    message.includes('transport') ||
+    message.includes('timed out') ||
+    message.includes('timeout') ||
+    message.includes('epipe') ||
+    message.includes('broken pipe') ||
+    message.includes('socket') ||
+    message.includes('stream closed') ||
+    message.includes('fetch failed') ||
+    message.includes('network')
+  )
+}
+
+async function raceStartupTimeout(
+  attempt: Promise,
+  timeoutMs: number,
+  serverId: string
+): Promise {
+  let timer: ReturnType | undefined
+  try {
+    return await Promise.race([
+      attempt,
+      new Promise((_, reject) => {
+        timer = setTimeout(
+          () => reject(new Error(`MCP server "${serverId}" did not connect within ${timeoutMs}ms during startup`)),
+          timeoutMs
+        )
+      })
+    ])
+  } catch (error) {
+    // A late successful connection would otherwise leak the child process.
+    void attempt.then((result) => result.state.client.close()).catch(() => undefined)
+    throw error
+  } finally {
+    if (timer) clearTimeout(timer)
+  }
+}
+
 async function reconnectMcpConnection(state: McpConnectionState): Promise {
   await state.client.close().catch(() => undefined)
   const client = await state.clientFactory(state.serverId, state.server)
@@ -423,3 +523,127 @@ function normalizePathForTrust(value: string): string {
 function errorMessage(error: unknown): string {
   return error instanceof Error ? error.message : String(error)
 }
+
+export function buildMcpStdioEnvironment(
+  serverEnv: Record = {},
+  options: McpStdioEnvironmentOptions = {}
+): Record {
+  const platform = options.platform ?? process.platform
+  const baseEnv = options.baseEnv ?? process.env
+  const pathKey = findPathKey(serverEnv) ?? findPathKey(baseEnv) ?? 'PATH'
+  const configuredPath = readEnvPath(serverEnv)
+  const inheritedPath = readEnvPath(baseEnv)
+  const pathValue = mergePathEntries(
+    [configuredPath ?? inheritedPath ?? '', ...commonMcpCommandPathEntries(platform, baseEnv)],
+    pathDelimiter(platform)
+  )
+  return {
+    ...serverEnv,
+    ...(pathValue ? { [pathKey]: pathValue } : {})
+  }
+}
+
+export function formatMcpConnectionError(error: unknown, server: McpServerConfig): string {
+  const message = errorMessage(error)
+  if (server.transport !== 'stdio' || !isMissingExecutableError(error, message)) return message
+  const command = missingExecutableCommand(error) ?? server.command ?? 'configured command'
+  const hint = isBareCommand(command)
+    ? missingBareCommandHint(command)
+    : `Could not find MCP command "${command}". Check that the configured executable path exists.`
+  return `${message}. ${hint}`
+}
+
+function commonMcpCommandPathEntries(
+  platform: NodeJS.Platform,
+  env: NodeJS.ProcessEnv
+): string[] {
+  if (platform === 'darwin') {
+    return [
+      '/opt/homebrew/bin',
+      '/usr/local/bin',
+      '/opt/local/bin',
+      homePath(env, '.volta/bin'),
+      homePath(env, '.local/bin'),
+      homePath(env, '.bun/bin')
+    ].filter((entry): entry is string => Boolean(entry))
+  }
+  if (platform === 'linux') {
+    return [
+      '/home/linuxbrew/.linuxbrew/bin',
+      '/usr/local/bin',
+      '/usr/bin',
+      homePath(env, '.volta/bin'),
+      homePath(env, '.local/bin'),
+      homePath(env, '.bun/bin')
+    ].filter((entry): entry is string => Boolean(entry))
+  }
+  if (platform === 'win32') {
+    return [
+      env.APPDATA ? win32.join(env.APPDATA, 'npm') : '',
+      env.ProgramFiles ? win32.join(env.ProgramFiles, 'nodejs') : '',
+      env['ProgramFiles(x86)'] ? win32.join(env['ProgramFiles(x86)'], 'nodejs') : ''
+    ].filter((entry): entry is string => Boolean(entry))
+  }
+  return []
+}
+
+function findPathKey(env: Record): string | undefined {
+  return Object.keys(env).find((key) => key.toLowerCase() === 'path')
+}
+
+function readEnvPath(env: Record): string | undefined {
+  const key = findPathKey(env)
+  const value = key ? env[key] : undefined
+  return value && value.trim() ? value : undefined
+}
+
+function mergePathEntries(values: string[], delimiter: string): string {
+  const seen = new Set()
+  const entries: string[] = []
+  for (const value of values) {
+    for (const entry of value.split(delimiter)) {
+      const trimmed = entry.trim()
+      if (!trimmed) continue
+      const key = trimmed.toLowerCase()
+      if (seen.has(key)) continue
+      seen.add(key)
+      entries.push(trimmed)
+    }
+  }
+  return entries.join(delimiter)
+}
+
+function pathDelimiter(platform: NodeJS.Platform): string {
+  return platform === 'win32' ? ';' : ':'
+}
+
+function homePath(env: NodeJS.ProcessEnv, relativePath: string): string {
+  return env.HOME ? posix.join(env.HOME, relativePath) : ''
+}
+
+function isMissingExecutableError(error: unknown, message: string): boolean {
+  const code = typeof error === 'object' && error !== null && 'code' in error
+    ? String((error as { code?: unknown }).code ?? '')
+    : ''
+  return code === 'ENOENT' || /\bspawn\s+\S+\s+ENOENT\b/i.test(message)
+}
+
+function missingExecutableCommand(error: unknown): string | undefined {
+  if (!error || typeof error !== 'object') return undefined
+  const path = (error as { path?: unknown }).path
+  return typeof path === 'string' && path.trim() ? path.trim() : undefined
+}
+
+function isBareCommand(command: string): boolean {
+  return Boolean(command.trim()) && !command.includes('/') && !command.includes('\\')
+}
+
+function missingBareCommandHint(command: string): string {
+  if (process.platform === 'win32') {
+    return `Could not find "${command}" on PATH while starting the MCP server. Make sure Node/npm is installed and available to Kun, or set the MCP command to an absolute path.`
+  }
+  if (process.platform === 'darwin') {
+    return `Could not find "${command}" on PATH while starting the MCP server. If Kun was launched from Finder or the desktop, make sure Node/npm is installed and available to GUI apps, or set the MCP command to an absolute path such as /opt/homebrew/bin/${command}.`
+  }
+  return `Could not find "${command}" on PATH while starting the MCP server. Make sure Node/npm is installed and available to Kun, or set the MCP command to an absolute path such as /usr/local/bin/${command}.`
+}
diff --git a/kun/src/adapters/tool/media-gen-tool-provider.ts b/kun/src/adapters/tool/media-gen-tool-provider.ts
new file mode 100644
index 00000000..99a244d6
--- /dev/null
+++ b/kun/src/adapters/tool/media-gen-tool-provider.ts
@@ -0,0 +1,951 @@
+import { randomBytes } from 'node:crypto'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { isAbsolute, join, relative, resolve } from 'node:path'
+import type { KunCapabilitiesConfig } from '../../contracts/capabilities.js'
+import { detectImage } from '../../attachments/attachment-store.js'
+import type { ToolExecutionUpdate, ToolHostContext } from '../../ports/tool-host.js'
+import type { CapabilityToolProvider } from './capability-registry.js'
+import { ImageGenHttpError, describeNetworkError } from './image-gen-tool-provider.js'
+import { LocalToolHost } from './local-tool-host.js'
+
+const GENERATED_SPEECH_DIR = '.deepseekgui-audio'
+const GENERATED_MUSIC_DIR = '.deepseekgui-music'
+const GENERATED_VIDEO_DIR = '.deepseekgui-videos'
+const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024
+const REFERENCE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])
+const AUDIO_FORMATS = new Set(['mp3', 'wav', 'flac', 'pcm', 'pcm16'])
+const VIDEO_RESOLUTIONS = ['768P', '1080P'] as const
+
+export type GeneratedMedia = { data: Buffer; mimeType: string; extension: string }
+
+export type SpeechGenRequest = {
+  text: string
+  model: string
+  voice?: string
+  style?: string
+  format: string
+  timeoutMs: number
+  signal: AbortSignal
+}
+
+export type MusicGenRequest = {
+  prompt?: string
+  lyrics?: string
+  instrumental?: boolean
+  lyricsOptimizer?: boolean
+  referenceAudioUrl?: string
+  model: string
+  format: string
+  timeoutMs: number
+  signal: AbortSignal
+}
+
+export type VideoGenRequest = {
+  prompt: string
+  model: string
+  duration: number
+  resolution: string
+  firstFrameImage?: { mimeType: string; data: Buffer }
+  lastFrameImage?: { mimeType: string; data: Buffer }
+  timeoutMs: number
+  pollIntervalMs: number
+  signal: AbortSignal
+  onUpdate?: (update: ToolExecutionUpdate) => Promise | void
+}
+
+export interface SpeechGenClient {
+  id: string
+  generate(request: SpeechGenRequest): Promise
+}
+
+export interface MusicGenClient {
+  id: string
+  generate(request: MusicGenRequest): Promise
+}
+
+export interface VideoGenClient {
+  id: string
+  generate(request: VideoGenRequest): Promise
+}
+
+export type SpeechGenDiagnostic = {
+  id: 'speechGen'
+  enabled: boolean
+  available: boolean
+  model?: string
+  reason?: string
+}
+
+export type MusicGenDiagnostic = {
+  id: 'musicGen'
+  enabled: boolean
+  available: boolean
+  model?: string
+  reason?: string
+}
+
+export type VideoGenDiagnostic = {
+  id: 'videoGen'
+  enabled: boolean
+  available: boolean
+  model?: string
+  reason?: string
+}
+
+export type MediaGenToolProviderOptions = {
+  speechClient?: SpeechGenClient
+  musicClient?: MusicGenClient
+  videoClient?: VideoGenClient
+  nowIso?: () => string
+}
+
+export type SpeechGenToolProviderBuildResult = {
+  providers: CapabilityToolProvider[]
+  diagnostics: SpeechGenDiagnostic[]
+  available: boolean
+}
+
+export type MusicGenToolProviderBuildResult = {
+  providers: CapabilityToolProvider[]
+  diagnostics: MusicGenDiagnostic[]
+  available: boolean
+}
+
+export type VideoGenToolProviderBuildResult = {
+  providers: CapabilityToolProvider[]
+  diagnostics: VideoGenDiagnostic[]
+  available: boolean
+}
+
+export function buildSpeechGenToolProviders(
+  config: KunCapabilitiesConfig['speechGen'] | undefined,
+  options: MediaGenToolProviderOptions = {}
+): SpeechGenToolProviderBuildResult {
+  if (!config?.enabled) return { providers: [], diagnostics: [], available: false }
+  const missing = missingProviderFields(config)
+  if (missing.length > 0) {
+    const reason = `speech generation provider is not configured (missing ${missing.join(', ')})`
+    return {
+      providers: [{ id: 'speechGen', kind: 'audio', enabled: true, available: false, reason, tools: [] }],
+      diagnostics: [{ id: 'speechGen', enabled: true, available: false, model: config.model, reason }],
+      available: false
+    }
+  }
+
+  const client = options.speechClient ?? createSpeechGenClient(config)
+  const model = config.model!
+
+  const tool = LocalToolHost.defineTool({
+    name: 'generate_speech',
+    description: [
+      'Generate spoken audio from text using the configured text-to-speech provider.',
+      `The generated audio is saved under ${GENERATED_SPEECH_DIR}/ in the workspace and returned as a generated file.`,
+      'Use voice for a provider voice id/name and style for Xiaomi MiMo voice style instructions when needed.'
+    ].join(' '),
+    inputSchema: {
+      type: 'object',
+      properties: {
+        text: { type: 'string', description: 'Text to synthesize into speech' },
+        voice: { type: 'string', description: 'Optional provider voice id/name' },
+        style: { type: 'string', description: 'Optional voice style instruction for providers that support it' },
+        format: { type: 'string', enum: [...AUDIO_FORMATS] }
+      },
+      required: ['text'],
+      additionalProperties: false
+    },
+    policy: 'untrusted',
+    execute: async (args, context) => {
+      const startedAt = Date.now()
+      const text = pickString(args.text)
+      if (!text) return toolError('invalid_text', 'text is required')
+      const format = normalizeAudioFormat(pickString(args.format) || config.format)
+      const voice = pickString(args.voice) || config.voice
+      const style = pickString(args.style)
+      try {
+        const media = await client.generate({
+          text,
+          model,
+          ...(voice ? { voice } : {}),
+          ...(style ? { style } : {}),
+          format,
+          timeoutMs: config.timeoutMs,
+          signal: context.abortSignal
+        })
+        const file = await writeGeneratedMediaFile({
+          context,
+          data: media.data,
+          mimeType: media.mimeType,
+          extension: media.extension,
+          dir: GENERATED_SPEECH_DIR,
+          prefix: 'speech',
+          nowIso: options.nowIso
+        })
+        return {
+          output: {
+            files: [file],
+            model,
+            voice,
+            format,
+            telemetry: telemetry(startedAt, client.id)
+          }
+        }
+      } catch (error) {
+        return toolError('generation_failed', providerErrorMessage(error), telemetry(startedAt, client.id))
+      }
+    }
+  })
+
+  return {
+    providers: [{ id: 'speechGen', kind: 'audio', enabled: true, available: true, tools: [tool] }],
+    diagnostics: [{ id: 'speechGen', enabled: true, available: true, model }],
+    available: true
+  }
+}
+
+export function buildMusicGenToolProviders(
+  config: KunCapabilitiesConfig['musicGen'] | undefined,
+  options: MediaGenToolProviderOptions = {}
+): MusicGenToolProviderBuildResult {
+  if (!config?.enabled) return { providers: [], diagnostics: [], available: false }
+  const missing = missingProviderFields(config)
+  if (missing.length > 0) {
+    const reason = `music generation provider is not configured (missing ${missing.join(', ')})`
+    return {
+      providers: [{ id: 'musicGen', kind: 'audio', enabled: true, available: false, reason, tools: [] }],
+      diagnostics: [{ id: 'musicGen', enabled: true, available: false, model: config.model, reason }],
+      available: false
+    }
+  }
+
+  const client = options.musicClient ?? createMusicGenClient(config)
+  const model = config.model!
+
+  const tool = LocalToolHost.defineTool({
+    name: 'generate_music',
+    description: [
+      'Generate a song or instrumental audio using the configured music provider.',
+      `The generated audio is saved under ${GENERATED_MUSIC_DIR}/ in the workspace and returned as a generated file.`,
+      'Provide prompt for style/intention, lyrics for sung music, or instrumental=true for instrumental tracks.'
+    ].join(' '),
+    inputSchema: {
+      type: 'object',
+      properties: {
+        prompt: { type: 'string', description: 'Musical style, mood, arrangement, or generation prompt' },
+        lyrics: { type: 'string', description: 'Optional lyrics for sung music' },
+        instrumental: { type: 'boolean', description: 'Generate instrumental music without vocals' },
+        lyrics_optimizer: { type: 'boolean', description: 'Ask provider to generate or improve lyrics' },
+        reference_audio_url: { type: 'string', description: 'Optional public URL for cover/reference audio' },
+        format: { type: 'string', enum: [...AUDIO_FORMATS] }
+      },
+      additionalProperties: false
+    },
+    policy: 'untrusted',
+    execute: async (args, context) => {
+      const startedAt = Date.now()
+      const prompt = pickString(args.prompt)
+      const lyrics = pickString(args.lyrics)
+      const instrumental = pickBoolean(args.instrumental)
+      const lyricsOptimizer = pickBoolean(args.lyrics_optimizer)
+      if (!prompt && !lyrics && instrumental !== true) {
+        return toolError('invalid_music_request', 'provide prompt, lyrics, or instrumental=true')
+      }
+      const format = normalizeAudioFormat(pickString(args.format) || config.format)
+      try {
+        const media = await client.generate({
+          ...(prompt ? { prompt } : {}),
+          ...(lyrics ? { lyrics } : {}),
+          ...(instrumental !== undefined ? { instrumental } : {}),
+          ...(lyricsOptimizer !== undefined ? { lyricsOptimizer } : {}),
+          ...(pickString(args.reference_audio_url) ? { referenceAudioUrl: pickString(args.reference_audio_url) } : {}),
+          model,
+          format,
+          timeoutMs: config.timeoutMs,
+          signal: context.abortSignal
+        })
+        const file = await writeGeneratedMediaFile({
+          context,
+          data: media.data,
+          mimeType: media.mimeType,
+          extension: media.extension,
+          dir: GENERATED_MUSIC_DIR,
+          prefix: 'music',
+          nowIso: options.nowIso
+        })
+        return {
+          output: {
+            files: [file],
+            model,
+            format,
+            telemetry: telemetry(startedAt, client.id)
+          }
+        }
+      } catch (error) {
+        return toolError('generation_failed', providerErrorMessage(error), telemetry(startedAt, client.id))
+      }
+    }
+  })
+
+  return {
+    providers: [{ id: 'musicGen', kind: 'audio', enabled: true, available: true, tools: [tool] }],
+    diagnostics: [{ id: 'musicGen', enabled: true, available: true, model }],
+    available: true
+  }
+}
+
+export function buildVideoGenToolProviders(
+  config: KunCapabilitiesConfig['videoGen'] | undefined,
+  options: MediaGenToolProviderOptions = {}
+): VideoGenToolProviderBuildResult {
+  if (!config?.enabled) return { providers: [], diagnostics: [], available: false }
+  const missing = missingProviderFields(config)
+  if (missing.length > 0) {
+    const reason = `video generation provider is not configured (missing ${missing.join(', ')})`
+    return {
+      providers: [{ id: 'videoGen', kind: 'video', enabled: true, available: false, reason, tools: [] }],
+      diagnostics: [{ id: 'videoGen', enabled: true, available: false, model: config.model, reason }],
+      available: false
+    }
+  }
+
+  const client = options.videoClient ?? createVideoGenClient(config)
+  const model = config.model!
+
+  const tool = LocalToolHost.defineTool({
+    name: 'generate_video',
+    description: [
+      'Generate a video from a text prompt using the configured video provider.',
+      'Optionally pass workspace-relative first_frame_image_path and last_frame_image_path for image-to-video guidance.',
+      `The generated video is saved under ${GENERATED_VIDEO_DIR}/ in the workspace and returned as a generated file.`
+    ].join(' '),
+    inputSchema: {
+      type: 'object',
+      properties: {
+        prompt: { type: 'string', description: 'Detailed video generation prompt' },
+        duration: { type: 'integer', minimum: 1, maximum: 30 },
+        resolution: { type: 'string', enum: VIDEO_RESOLUTIONS },
+        first_frame_image_path: { type: 'string', description: 'Workspace-relative png/jpeg/webp first frame' },
+        last_frame_image_path: { type: 'string', description: 'Workspace-relative png/jpeg/webp last frame' }
+      },
+      required: ['prompt'],
+      additionalProperties: false
+    },
+    policy: 'untrusted',
+    execute: async (args, context, onUpdate) => {
+      const startedAt = Date.now()
+      const prompt = pickString(args.prompt)
+      if (!prompt) return toolError('invalid_prompt', 'prompt is required')
+      const firstFrame = await collectFrameImage(args.first_frame_image_path, context, 'first_frame_image_path')
+      if ('error' in firstFrame) return firstFrame.error
+      const lastFrame = await collectFrameImage(args.last_frame_image_path, context, 'last_frame_image_path')
+      if ('error' in lastFrame) return lastFrame.error
+      const duration = normalizeDuration(args.duration, config.defaultDuration)
+      const resolution = pickString(args.resolution) || config.defaultResolution
+      try {
+        const media = await client.generate({
+          prompt,
+          model,
+          duration,
+          resolution,
+          ...(firstFrame.image ? { firstFrameImage: firstFrame.image } : {}),
+          ...(lastFrame.image ? { lastFrameImage: lastFrame.image } : {}),
+          timeoutMs: config.timeoutMs,
+          pollIntervalMs: config.pollIntervalMs,
+          signal: context.abortSignal,
+          onUpdate
+        })
+        const file = await writeGeneratedMediaFile({
+          context,
+          data: media.data,
+          mimeType: media.mimeType,
+          extension: media.extension,
+          dir: GENERATED_VIDEO_DIR,
+          prefix: 'video',
+          nowIso: options.nowIso
+        })
+        return {
+          output: {
+            files: [file],
+            model,
+            duration,
+            resolution,
+            telemetry: telemetry(startedAt, client.id)
+          }
+        }
+      } catch (error) {
+        return toolError('generation_failed', providerErrorMessage(error), telemetry(startedAt, client.id))
+      }
+    }
+  })
+
+  return {
+    providers: [{ id: 'videoGen', kind: 'video', enabled: true, available: true, tools: [tool] }],
+    diagnostics: [{ id: 'videoGen', enabled: true, available: true, model }],
+    available: true
+  }
+}
+
+export function createSpeechGenClient(config: {
+  protocol?: string
+  baseUrl?: string
+  apiKey?: string
+}): SpeechGenClient {
+  if (config.protocol === 'minimax-t2a') return new MiniMaxSpeechClient(config.baseUrl!, config.apiKey!)
+  if (config.protocol === 'mimo-tts') return new MimoSpeechClient(config.baseUrl!, config.apiKey!)
+  return new OpenAiCompatSpeechClient(config.baseUrl!, config.apiKey!)
+}
+
+export function createMusicGenClient(config: {
+  protocol?: string
+  baseUrl?: string
+  apiKey?: string
+}): MusicGenClient {
+  return new MiniMaxMusicClient(config.baseUrl!, config.apiKey!)
+}
+
+export function createVideoGenClient(config: {
+  protocol?: string
+  baseUrl?: string
+  apiKey?: string
+}): VideoGenClient {
+  return new MiniMaxVideoClient(config.baseUrl!, config.apiKey!)
+}
+
+export class OpenAiCompatSpeechClient implements SpeechGenClient {
+  readonly id = 'openai-speech'
+  private readonly endpointUrl: string
+
+  constructor(
+    baseUrl: string,
+    private readonly apiKey: string
+  ) {
+    this.endpointUrl = apiUrl(baseUrl, '/v1/audio/speech')
+  }
+
+  async generate(request: SpeechGenRequest): Promise {
+    const response = await requestResponse(this.endpointUrl, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${this.apiKey}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        model: request.model,
+        input: request.text,
+        voice: request.voice || 'alloy',
+        response_format: request.format
+      }),
+      signal: withTimeout(request.signal, request.timeoutMs)
+    }, request)
+    if (!response.ok) throw new ImageGenHttpError(response.status, await response.text())
+    const mimeType = response.headers.get('content-type')?.split(';')[0] || audioMimeType(request.format)
+    return {
+      data: Buffer.from(await response.arrayBuffer()),
+      mimeType,
+      extension: audioExtension(request.format)
+    }
+  }
+}
+
+export class MiniMaxSpeechClient implements SpeechGenClient {
+  readonly id = 'minimax-t2a'
+  private readonly endpointUrl: string
+
+  constructor(
+    baseUrl: string,
+    private readonly apiKey: string
+  ) {
+    this.endpointUrl = apiUrl(baseUrl, '/v1/t2a_v2')
+  }
+
+  async generate(request: SpeechGenRequest): Promise {
+    const voiceId = request.voice || 'male-qn-qingse'
+    const payload = await requestJson(this.endpointUrl, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${this.apiKey}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        model: request.model,
+        text: request.text,
+        output_format: 'hex',
+        voice_setting: {
+          voice_id: voiceId,
+          speed: 1,
+          vol: 1,
+          pitch: 0
+        },
+        audio_setting: {
+          format: request.format,
+          sample_rate: request.format === 'mp3' ? 32_000 : 44_100,
+          bitrate: 128_000,
+          channel: 1
+        }
+      }),
+      signal: withTimeout(request.signal, request.timeoutMs)
+    }, request)
+    assertMiniMaxOk(payload.base_resp, 'MiniMax speech provider')
+    const audio = payload.data?.audio
+    if (!audio) throw new Error('MiniMax speech provider returned no audio data')
+    return {
+      data: bufferFromHex(audio),
+      mimeType: audioMimeType(request.format),
+      extension: audioExtension(request.format)
+    }
+  }
+}
+
+export class MimoSpeechClient implements SpeechGenClient {
+  readonly id = 'mimo-tts'
+  private readonly endpointUrl: string
+
+  constructor(
+    baseUrl: string,
+    private readonly apiKey: string
+  ) {
+    this.endpointUrl = apiUrl(baseUrl, '/v1/chat/completions')
+  }
+
+  async generate(request: SpeechGenRequest): Promise {
+    const messages = [
+      ...(request.style ? [{ role: 'user', content: request.style }] : []),
+      { role: 'assistant', content: request.text }
+    ]
+    const payload = await requestJson(this.endpointUrl, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${this.apiKey}`,
+        'api-key': this.apiKey,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        model: request.model,
+        messages,
+        audio: {
+          format: request.format,
+          ...(request.voice ? { voice: request.voice } : {})
+        }
+      }),
+      signal: withTimeout(request.signal, request.timeoutMs)
+    }, request)
+    const audio = payload.choices?.[0]?.message?.audio?.data
+    if (!audio) throw new Error('MiMo speech provider returned no audio data')
+    return {
+      data: Buffer.from(audio, 'base64'),
+      mimeType: audioMimeType(request.format),
+      extension: audioExtension(request.format)
+    }
+  }
+}
+
+export class MiniMaxMusicClient implements MusicGenClient {
+  readonly id = 'minimax-music'
+  private readonly endpointUrl: string
+
+  constructor(
+    baseUrl: string,
+    private readonly apiKey: string
+  ) {
+    this.endpointUrl = apiUrl(baseUrl, '/v1/music_generation')
+  }
+
+  async generate(request: MusicGenRequest): Promise {
+    const payload = await requestJson(this.endpointUrl, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${this.apiKey}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        model: request.model,
+        ...(request.prompt ? { prompt: request.prompt } : {}),
+        ...(request.lyrics ? { lyrics: request.lyrics } : {}),
+        output_format: 'hex',
+        audio_setting: {
+          format: request.format,
+          sample_rate: 44_100,
+          bitrate: 256_000
+        },
+        lyrics_optimizer: request.lyricsOptimizer ?? (!request.lyrics && request.instrumental !== true),
+        ...(request.instrumental !== undefined ? { is_instrumental: request.instrumental } : {}),
+        ...(request.referenceAudioUrl ? { audio_url: request.referenceAudioUrl } : {})
+      }),
+      signal: withTimeout(request.signal, request.timeoutMs)
+    }, request)
+    assertMiniMaxOk(payload.base_resp, 'MiniMax music provider')
+    const audio = payload.data?.audio
+    if (!audio) throw new Error('MiniMax music provider returned no audio data')
+    return {
+      data: bufferFromHex(audio),
+      mimeType: audioMimeType(request.format),
+      extension: audioExtension(request.format)
+    }
+  }
+}
+
+export class MiniMaxVideoClient implements VideoGenClient {
+  readonly id = 'minimax-video'
+  private readonly rootUrl: string
+
+  constructor(
+    baseUrl: string,
+    private readonly apiKey: string
+  ) {
+    this.rootUrl = minimaxRootUrl(baseUrl)
+  }
+
+  async generate(request: VideoGenRequest): Promise {
+    const signal = withTimeout(request.signal, request.timeoutMs)
+    const createPayload = await requestJson(`${this.rootUrl}/v1/video_generation`, {
+      method: 'POST',
+      headers: this.headers(),
+      body: JSON.stringify({
+        model: request.model,
+        prompt: request.prompt,
+        duration: request.duration,
+        resolution: request.resolution,
+        ...(request.firstFrameImage
+          ? { first_frame_image: dataUri(request.firstFrameImage.mimeType, request.firstFrameImage.data) }
+          : {}),
+        ...(request.lastFrameImage
+          ? { last_frame_image: dataUri(request.lastFrameImage.mimeType, request.lastFrameImage.data) }
+          : {})
+      }),
+      signal
+    }, request)
+    assertMiniMaxOk(createPayload.base_resp, 'MiniMax video provider')
+    const taskId = createPayload.task_id
+    if (!taskId) throw new Error('MiniMax video provider returned no task_id')
+    await request.onUpdate?.({
+      output: { status: 'submitted', taskId, provider: this.id }
+    })
+
+    const deadline = Date.now() + request.timeoutMs
+    let lastStatus = 'submitted'
+    while (Date.now() < deadline) {
+      await delay(request.pollIntervalMs, signal)
+      const queryUrl = new URL(`${this.rootUrl}/v1/query/video_generation`)
+      queryUrl.searchParams.set('task_id', taskId)
+      const queryPayload = await requestJson(queryUrl.toString(), {
+        method: 'GET',
+        headers: this.headers(),
+        signal
+      }, request)
+      assertMiniMaxOk(queryPayload.base_resp, 'MiniMax video provider')
+      lastStatus = queryPayload.status || lastStatus
+      await request.onUpdate?.({
+        output: { status: lastStatus, taskId, provider: this.id }
+      })
+      if (isFailureStatus(lastStatus)) {
+        throw new Error(`MiniMax video generation failed with status ${lastStatus}`)
+      }
+      if (!isSuccessStatus(lastStatus)) continue
+      const fileId = queryPayload.file_id
+      if (!fileId) throw new Error('MiniMax video provider finished without file_id')
+      const downloadUrl = await this.retrieveDownloadUrl(fileId, request)
+      const response = await requestResponse(downloadUrl, { method: 'GET', signal }, request)
+      if (!response.ok) throw new ImageGenHttpError(response.status, await response.text())
+      const mimeType = response.headers.get('content-type')?.split(';')[0] || 'video/mp4'
+      return {
+        data: Buffer.from(await response.arrayBuffer()),
+        mimeType,
+        extension: videoExtension(mimeType)
+      }
+    }
+    throw new Error(`MiniMax video generation timed out after ${request.timeoutMs}ms (last status: ${lastStatus})`)
+  }
+
+  private async retrieveDownloadUrl(fileId: string, request: { timeoutMs: number; signal: AbortSignal }): Promise {
+    const retrieveUrl = new URL(`${this.rootUrl}/v1/files/retrieve`)
+    retrieveUrl.searchParams.set('file_id', fileId)
+    const payload = await requestJson(retrieveUrl.toString(), {
+      method: 'GET',
+      headers: this.headers(),
+      signal: withTimeout(request.signal, request.timeoutMs)
+    }, request)
+    assertMiniMaxOk(payload.base_resp, 'MiniMax video provider')
+    const downloadUrl = payload.file?.download_url
+    if (!downloadUrl) throw new Error('MiniMax video provider returned no download_url')
+    return downloadUrl
+  }
+
+  private headers(): Record {
+    return {
+      Authorization: `Bearer ${this.apiKey}`,
+      'Content-Type': 'application/json'
+    }
+  }
+}
+
+type MiniMaxAudioPayload = {
+  data?: { audio?: string }
+  base_resp?: MiniMaxBaseResponse
+}
+
+type MiniMaxVideoCreatePayload = {
+  task_id?: string
+  base_resp?: MiniMaxBaseResponse
+}
+
+type MiniMaxVideoQueryPayload = {
+  status?: string
+  file_id?: string
+  base_resp?: MiniMaxBaseResponse
+}
+
+type MiniMaxFileRetrievePayload = {
+  file?: { download_url?: string }
+  base_resp?: MiniMaxBaseResponse
+}
+
+type MiniMaxBaseResponse = {
+  status_code?: number
+  status_msg?: string
+}
+
+type MimoSpeechPayload = {
+  choices?: Array<{
+    message?: {
+      audio?: {
+        data?: string
+      }
+    }
+  }>
+}
+
+function missingProviderFields(config: { baseUrl?: string; apiKey?: string; model?: string }): string[] {
+  return [
+    !config.baseUrl ? 'baseUrl' : undefined,
+    !config.apiKey ? 'apiKey' : undefined,
+    !config.model ? 'model' : undefined
+  ].filter((field): field is string => Boolean(field))
+}
+
+async function writeGeneratedMediaFile(input: {
+  context: ToolHostContext
+  data: Buffer
+  mimeType: string
+  extension: string
+  dir: string
+  prefix: string
+  nowIso?: () => string
+}): Promise<{
+  relativePath: string
+  absolutePath: string
+  mimeType: string
+  byteSize: number
+}> {
+  const stamp = (input.nowIso?.() ?? new Date().toISOString()).replace(/\D/g, '').slice(0, 14)
+  const fileName = `${input.prefix}-${stamp}-${randomBytes(2).toString('hex')}.${input.extension}`
+  const relativePath = `${input.dir}/${fileName}`
+  const absolutePath = join(input.context.workspace, input.dir, fileName)
+  await mkdir(join(input.context.workspace, input.dir), { recursive: true })
+  await writeFile(absolutePath, input.data)
+  return {
+    relativePath,
+    absolutePath,
+    mimeType: input.mimeType,
+    byteSize: input.data.byteLength
+  }
+}
+
+type FrameImageResult = { image?: { mimeType: string; data: Buffer } }
+type FrameImageError = { error: { output: unknown; isError: true } }
+
+async function collectFrameImage(
+  value: unknown,
+  context: ToolHostContext,
+  fieldName: string
+): Promise {
+  const rawPath = pickString(value)
+  if (!rawPath) return {}
+  const resolved = resolve(context.workspace, rawPath)
+  const rel = relative(context.workspace, resolved)
+  if (rel.startsWith('..') || isAbsolute(rel)) {
+    return { error: toolError('invalid_reference_path', `${fieldName} must be inside the workspace: ${rawPath}`) }
+  }
+  let data: Buffer
+  try {
+    data = await readFile(resolved)
+  } catch {
+    return { error: toolError('invalid_reference_path', `${fieldName} not found: ${rawPath}`) }
+  }
+  if (data.byteLength > MAX_REFERENCE_IMAGE_BYTES) {
+    return { error: toolError('invalid_reference_path', `${fieldName} exceeds ${MAX_REFERENCE_IMAGE_BYTES} byte limit: ${rawPath}`) }
+  }
+  const detected = detectImage(data)
+  if (!detected || !REFERENCE_MIME_TYPES.has(detected.mimeType)) {
+    return { error: toolError('invalid_reference_path', `${fieldName} must be png, jpeg, or webp: ${rawPath}`) }
+  }
+  return { image: { mimeType: detected.mimeType, data } }
+}
+
+async function requestJson(
+  url: string,
+  init: RequestInit,
+  request: { timeoutMs: number; signal: AbortSignal }
+): Promise {
+  const response = await requestResponse(url, init, request)
+  const text = await response.text()
+  if (!response.ok) throw new ImageGenHttpError(response.status, text)
+  try {
+    return JSON.parse(text) as T
+  } catch {
+    throw new Error(`provider returned invalid JSON from ${url.split('?')[0]}`)
+  }
+}
+
+async function requestResponse(
+  url: string,
+  init: RequestInit,
+  request: { timeoutMs: number; signal: AbortSignal }
+): Promise {
+  try {
+    return await fetch(url, init)
+  } catch (error) {
+    throw mediaFetchFailure(url, error, request)
+  }
+}
+
+function mediaFetchFailure(
+  url: string,
+  error: unknown,
+  request: { timeoutMs: number }
+): Error {
+  const target = url.split('?')[0]
+  if (error instanceof DOMException && error.name === 'TimeoutError') {
+    return new Error(`media request to ${target} timed out after ${request.timeoutMs}ms`, { cause: error })
+  }
+  if (error instanceof DOMException && error.name === 'AbortError') {
+    return new Error(`media request to ${target} was canceled`, { cause: error })
+  }
+  return new Error(`media request to ${target} failed: ${describeNetworkError(error)}`, { cause: error })
+}
+
+function apiUrl(baseUrl: string, v1Path: string): string {
+  const normalized = baseUrl.trim().replace(/\/+$/, '')
+  const lower = normalized.toLowerCase()
+  const path = v1Path.startsWith('/') ? v1Path : `/${v1Path}`
+  const pathWithoutV1 = path.replace(/^\/v1\//, '/')
+  if (!normalized) return path
+  if (lower.endsWith(path.toLowerCase()) || lower.endsWith(pathWithoutV1.toLowerCase())) return normalized
+  if (lower.endsWith('/v1')) return `${normalized}${pathWithoutV1}`
+  return `${normalized}${path}`
+}
+
+function minimaxRootUrl(baseUrl: string): string {
+  const normalized = baseUrl.trim().replace(/\/+$/, '')
+  if (!normalized) return ''
+  for (const suffix of ['/v1/video_generation', '/video_generation', '/v1/query/video_generation']) {
+    if (normalized.toLowerCase().endsWith(suffix)) {
+      return normalized.slice(0, -suffix.length).replace(/\/+$/, '')
+    }
+  }
+  if (normalized.toLowerCase().endsWith('/v1')) return normalized.slice(0, -3).replace(/\/+$/, '')
+  return normalized
+}
+
+function assertMiniMaxOk(baseResp: MiniMaxBaseResponse | undefined, label: string): void {
+  const statusCode = baseResp?.status_code
+  if (typeof statusCode === 'number' && statusCode !== 0) {
+    throw new Error(`${label} failed (${statusCode}): ${baseResp?.status_msg ?? 'unknown error'}`)
+  }
+}
+
+function bufferFromHex(value: string): Buffer {
+  const normalized = value.replace(/\s+/g, '')
+  if (!normalized || normalized.length % 2 !== 0 || /[^0-9a-f]/i.test(normalized)) {
+    throw new Error('provider returned invalid hex audio data')
+  }
+  return Buffer.from(normalized, 'hex')
+}
+
+function withTimeout(signal: AbortSignal, timeoutMs: number): AbortSignal {
+  return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
+}
+
+function dataUri(mimeType: string, data: Buffer): string {
+  return `data:${mimeType};base64,${data.toString('base64')}`
+}
+
+function normalizeAudioFormat(value: string | undefined): string {
+  const normalized = value?.trim().toLowerCase()
+  return normalized && AUDIO_FORMATS.has(normalized) ? normalized : 'mp3'
+}
+
+function audioMimeType(format: string): string {
+  switch (normalizeAudioFormat(format)) {
+    case 'wav':
+      return 'audio/wav'
+    case 'flac':
+      return 'audio/flac'
+    case 'pcm':
+    case 'pcm16':
+      return 'audio/L16'
+    case 'mp3':
+    default:
+      return 'audio/mpeg'
+  }
+}
+
+function audioExtension(format: string): string {
+  const normalized = normalizeAudioFormat(format)
+  return normalized === 'pcm16' ? 'pcm' : normalized
+}
+
+function videoExtension(mimeType: string): string {
+  if (mimeType.includes('webm')) return 'webm'
+  if (mimeType.includes('quicktime')) return 'mov'
+  return 'mp4'
+}
+
+function normalizeDuration(value: unknown, fallback: number): number {
+  const candidate = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback
+  return Math.min(30, Math.max(1, candidate))
+}
+
+function isSuccessStatus(status: string): boolean {
+  return ['success', 'succeeded', 'completed', 'complete'].includes(status.trim().toLowerCase())
+}
+
+function isFailureStatus(status: string): boolean {
+  return ['fail', 'failed', 'error', 'canceled', 'cancelled'].includes(status.trim().toLowerCase())
+}
+
+function delay(ms: number, signal: AbortSignal): Promise {
+  if (signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'))
+  return new Promise((resolveDelay, rejectDelay) => {
+    const timer = setTimeout(resolveDelay, ms)
+    const abort = () => {
+      clearTimeout(timer)
+      rejectDelay(new DOMException('Aborted', 'AbortError'))
+    }
+    signal.addEventListener('abort', abort, { once: true })
+  })
+}
+
+function telemetry(startedAt: number, provider: string): Record {
+  return { provider, durationMs: Date.now() - startedAt }
+}
+
+function toolError(code: string, message: string, toolTelemetry?: Record): { output: unknown; isError: true } {
+  return {
+    output: {
+      error: { code, message },
+      ...(toolTelemetry ? { telemetry: toolTelemetry } : {})
+    },
+    isError: true
+  }
+}
+
+function pickString(value: unknown): string | undefined {
+  return typeof value === 'string' && value.trim() ? value.trim() : undefined
+}
+
+function pickBoolean(value: unknown): boolean | undefined {
+  return typeof value === 'boolean' ? value : undefined
+}
+
+function providerErrorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
diff --git a/kun/src/adapters/tool/output-accumulator.ts b/kun/src/adapters/tool/output-accumulator.ts
index b8f0cf2b..8d3c0e8a 100644
--- a/kun/src/adapters/tool/output-accumulator.ts
+++ b/kun/src/adapters/tool/output-accumulator.ts
@@ -176,19 +176,21 @@ export class OutputAccumulator {
   }
 
   snapshot(options: { persistIfTruncated?: boolean } = {}): OutputAccumulatorSnapshot {
-    const tailTruncation = truncateTail(this.getSnapshotText(), {
+    const pendingPreview = this.pendingDecodePreview()
+    const snapshotText = this.getSnapshotText(pendingPreview)
+    const totalDecodedBytes = this.totalDecodedBytes + byteLength(pendingPreview)
+    const totalLines = this.totalLinesAfterPreview(pendingPreview)
+    const tailTruncation = truncateTail(snapshotText, {
       maxLines: this.maxLines,
       maxBytes: this.maxBytes
     })
-    const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes
+    const truncated = totalLines > this.maxLines || totalDecodedBytes > this.maxBytes
     const truncation: OutputAccumulatorTruncation = {
       ...tailTruncation,
       truncated,
-      truncatedBy: truncated
-        ? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? 'bytes' : 'lines'))
-        : null,
-      totalLines: this.totalLines,
-      totalBytes: this.totalDecodedBytes,
+      truncatedBy: truncated ? (tailTruncation.truncatedBy ?? (totalDecodedBytes > this.maxBytes ? 'bytes' : 'lines')) : null,
+      totalLines,
+      totalBytes: totalDecodedBytes,
       maxLines: this.maxLines,
       maxBytes: this.maxBytes
     }
@@ -281,10 +283,34 @@ export class OutputAccumulator {
     this.tailBytes = byteLength(this.tailText)
   }
 
-  private getSnapshotText(): string {
-    if (this.tailStartsAtLineBoundary) return this.tailText
-    const firstNewline = this.tailText.indexOf('\n')
-    return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1)
+  private getSnapshotText(pendingPreview = ''): string {
+    const text = this.tailText + pendingPreview
+    if (this.tailStartsAtLineBoundary) return text
+    const firstNewline = text.indexOf('\n')
+    return firstNewline === -1 ? text : text.slice(firstNewline + 1)
+  }
+
+  private pendingDecodePreview(): string {
+    if (this.decoder || this.decodeBuffer.length === 0) return ''
+    if (startsWithUtf16LeBom(this.decodeBuffer) || looksLikeUtf16Le(this.decodeBuffer)) {
+      return new TextDecoder('utf-16le').decode(stripKnownBom(this.decodeBuffer, 'utf-16le'))
+    }
+    return new TextDecoder('utf-8').decode(this.decodeBuffer)
+  }
+
+  private totalLinesAfterPreview(pendingPreview: string): number {
+    if (!pendingPreview) return this.totalLines
+    let newlines = 0
+    let lastNewline = -1
+    for (let index = pendingPreview.indexOf('\n'); index !== -1; index = pendingPreview.indexOf('\n', index + 1)) {
+      newlines += 1
+      lastNewline = index
+    }
+    if (newlines === 0) {
+      return this.completedLines + (this.hasOpenLine || pendingPreview.length > 0 ? 1 : 0)
+    }
+    const tail = pendingPreview.slice(lastNewline + 1)
+    return this.completedLines + newlines + (tail.length > 0 ? 1 : 0)
   }
 
   private shouldUseTempFile(): boolean {
diff --git a/kun/src/adapters/tool/sandbox-policy.ts b/kun/src/adapters/tool/sandbox-policy.ts
new file mode 100644
index 00000000..4ac69f1b
--- /dev/null
+++ b/kun/src/adapters/tool/sandbox-policy.ts
@@ -0,0 +1,117 @@
+import { isAbsolute, relative, resolve, sep } from 'node:path'
+import {
+  DEFAULT_SANDBOX_MODE,
+  SandboxModeSchema,
+  type SandboxMode
+} from '../../contracts/policy.js'
+import type { ToolHostContext } from '../../ports/tool-host.js'
+import type { LocalTool } from './local-tool-host.js'
+import { workspaceRoot } from './builtin-tool-utils.js'
+
+export type SandboxBlock = {
+  code: 'sandbox_read_only' | 'sandbox_command_blocked' | 'sandbox_write_blocked'
+  message: string
+}
+
+export function effectiveSandboxMode(
+  context?: Pick
+): SandboxMode {
+  const parsed = SandboxModeSchema.safeParse(context?.sandboxMode)
+  return parsed.success ? parsed.data : DEFAULT_SANDBOX_MODE
+}
+
+export function isToolAdvertisedInSandbox(
+  tool: Pick,
+  context?: Pick
+): boolean {
+  if (!context) return true
+  return sandboxBlockForTool(tool, context) === null
+}
+
+export function sandboxBlockForTool(
+  tool: Pick,
+  context: Pick
+): SandboxBlock | null {
+  const mode = effectiveSandboxMode(context)
+  if (mode === 'danger-full-access') return null
+  if (isInteractiveGuiGateTool(tool.name)) return null
+
+  if (tool.toolKind === 'file_change') {
+    if (mode === 'workspace-write') return null
+    return {
+      code: mode === 'read-only' ? 'sandbox_read_only' : 'sandbox_write_blocked',
+      message:
+        mode === 'read-only'
+          ? `tool ${tool.name} is blocked by the read-only sandbox`
+          : `tool ${tool.name} is blocked because ${mode} does not allow in-process file mutation`
+    }
+  }
+
+  if (tool.toolKind === 'command_execution') {
+    return {
+      code: 'sandbox_command_blocked',
+      message:
+        mode === 'read-only'
+          ? `tool ${tool.name} is blocked by the read-only sandbox`
+          : `tool ${tool.name} is blocked because ${mode} cannot sandbox host shell commands`
+    }
+  }
+
+  return null
+}
+
+export function canWritePath(
+  absolutePath: string,
+  context: Pick
+): { ok: true } | { ok: false; block: SandboxBlock } {
+  const mode = effectiveSandboxMode(context)
+  if (mode === 'danger-full-access') return { ok: true }
+  if (mode === 'read-only') {
+    return {
+      ok: false,
+      block: {
+        code: 'sandbox_read_only',
+        message: `writing is blocked by the read-only sandbox: ${absolutePath}`
+      }
+    }
+  }
+  if (mode === 'external-sandbox') {
+    return {
+      ok: false,
+      block: {
+        code: 'sandbox_write_blocked',
+        message: `writing is blocked because external-sandbox is not enforced by in-process file tools: ${absolutePath}`
+      }
+    }
+  }
+
+  const root = workspaceRoot(context.workspace)
+  const resolvedPath = isAbsolute(absolutePath) ? resolve(absolutePath) : resolve(root, absolutePath)
+  if (isPathInsideOrEqual(root, resolvedPath)) return { ok: true }
+  return {
+    ok: false,
+    block: {
+      code: 'sandbox_write_blocked',
+      message: `writing is limited to the workspace sandbox: ${absolutePath}`
+    }
+  }
+}
+
+export function assertCanWritePath(
+  absolutePath: string,
+  context: Pick
+): void {
+  const decision = canWritePath(absolutePath, context)
+  if (!decision.ok) throw new Error(decision.block.message)
+}
+
+function isPathInsideOrEqual(root: string, candidate: string): boolean {
+  const rootPath = resolve(root)
+  const candidatePath = resolve(candidate)
+  const rel = relative(rootPath, candidatePath)
+  return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
+}
+
+function isInteractiveGuiGateTool(toolName: string): boolean {
+  return toolName === 'user_input' || toolName === 'request_user_input'
+}
diff --git a/kun/src/adapters/tool/tool-hooks.ts b/kun/src/adapters/tool/tool-hooks.ts
deleted file mode 100644
index f8198a81..00000000
--- a/kun/src/adapters/tool/tool-hooks.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import { spawn } from 'node:child_process'
-import type { ToolCallLike, ToolHostContext } from '../../ports/tool-host.js'
-import { terminateSpawnTree } from './builtin-tool-utils.js'
-
-export type ToolHookPhase = 'PreToolUse' | 'PostToolUse'
-
-export type ToolHookInvocation = {
-  phase: ToolHookPhase
-  call: ToolCallLike
-  context: Pick
-  result?: {
-    output: unknown
-    isError?: boolean
-  }
-}
-
-export type ToolHookResult = {
-  decision?: 'allow' | 'deny'
-  message?: string
-  arguments?: Record
-  output?: unknown
-  isError?: boolean
-}
-
-export type ResolvedToolHook =
-  | {
-      phase: ToolHookPhase
-      toolNames?: readonly string[]
-      timeoutMs?: number
-      run: (invocation: ToolHookInvocation) => Promise | ToolHookResult | void
-    }
-  | {
-      phase: ToolHookPhase
-      toolNames?: readonly string[]
-      timeoutMs?: number
-      command: string
-      cwd?: string
-    }
-
-export async function runToolHooks(input: {
-  hooks: readonly ResolvedToolHook[]
-  invocation: ToolHookInvocation
-}): Promise {
-  const matching = input.hooks.filter((hook) => hook.phase === input.invocation.phase && hookMatchesTool(hook, input.invocation.call.toolName))
-  const results: ToolHookResult[] = []
-  for (const hook of matching) {
-    const result = 'run' in hook
-      ? await runFunctionHook(hook, input.invocation)
-      : await runCommandHook(hook, input.invocation)
-    if (result) results.push(result)
-  }
-  return results
-}
-
-export function applyPreToolHookResults(
-  call: ToolCallLike,
-  results: readonly ToolHookResult[]
-): { call: ToolCallLike; denied?: string } {
-  let next = call
-  for (const result of results) {
-    if (result.decision === 'deny') {
-      return { call: next, denied: result.message || 'tool call denied by PreToolUse hook' }
-    }
-    if (result.arguments && typeof result.arguments === 'object') {
-      next = { ...next, arguments: result.arguments }
-    }
-  }
-  return { call: next }
-}
-
-export function applyPostToolHookResults(
-  result: { output: unknown; isError?: boolean },
-  results: readonly ToolHookResult[]
-): { output: unknown; isError?: boolean } {
-  let next = result
-  for (const hookResult of results) {
-    if ('output' in hookResult) {
-      next = {
-        output: hookResult.output,
-        isError: hookResult.isError ?? next.isError
-      }
-    } else if (hookResult.isError !== undefined) {
-      next = { ...next, isError: hookResult.isError }
-    }
-  }
-  return next
-}
-
-function hookMatchesTool(hook: Pick, toolName: string): boolean {
-  if (!hook.toolNames || hook.toolNames.length === 0) return true
-  return hook.toolNames.includes(toolName)
-}
-
-async function runFunctionHook(
-  hook: Extract,
-  invocation: ToolHookInvocation
-): Promise {
-  return withTimeout(
-    Promise.resolve(hook.run(invocation)),
-    hook.timeoutMs ?? 5_000,
-    `${hook.phase} hook timed out`
-  )
-}
-
-async function runCommandHook(
-  hook: Extract,
-  invocation: ToolHookInvocation
-): Promise {
-  const payload = JSON.stringify(invocation)
-  const child = spawn(hook.command, {
-    cwd: hook.cwd || invocation.context.workspace || undefined,
-    shell: true,
-    stdio: ['pipe', 'pipe', 'pipe']
-  })
-  child.stdin.end(payload)
-  let stdout = ''
-  let stderr = ''
-  child.stdout.on('data', (chunk) => {
-    stdout += String(chunk)
-  })
-  child.stderr.on('data', (chunk) => {
-    stderr += String(chunk)
-  })
-  const exitCode = await withTimeout(
-    new Promise((resolve) => {
-      child.on('close', (code) => resolve(code ?? 0))
-    }),
-    hook.timeoutMs ?? 5_000,
-    `${hook.phase} command hook timed out`
-  ).catch((error) => {
-    terminateSpawnTree(child)
-    throw error
-  })
-  if (exitCode !== 0) {
-    return {
-      decision: hook.phase === 'PreToolUse' ? 'deny' : undefined,
-      isError: hook.phase === 'PostToolUse' ? true : undefined,
-      message: stderr.trim() || `${hook.phase} command hook exited with ${exitCode}`
-    }
-  }
-  const text = stdout.trim()
-  if (!text) return undefined
-  try {
-    return JSON.parse(text) as ToolHookResult
-  } catch {
-    return { message: text }
-  }
-}
-
-async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise {
-  let timer: ReturnType | undefined
-  try {
-    return await Promise.race([
-      promise,
-      new Promise((_resolve, reject) => {
-        timer = setTimeout(() => reject(new Error(message)), Math.max(1, timeoutMs))
-      })
-    ])
-  } finally {
-    if (timer) clearTimeout(timer)
-  }
-}
diff --git a/kun/src/adapters/tool/web-tool-provider.ts b/kun/src/adapters/tool/web-tool-provider.ts
index 2472dc75..831fb69c 100644
--- a/kun/src/adapters/tool/web-tool-provider.ts
+++ b/kun/src/adapters/tool/web-tool-provider.ts
@@ -6,6 +6,9 @@ import { LocalToolHost } from './local-tool-host.js'
 
 const DEFAULT_WEB_TIMEOUT_MS = 15_000
 const DEFAULT_WEB_MAX_BYTES = 1_000_000
+// Models sometimes pass tiny max_bytes budgets (2000 was common in the
+// wild); below this floor the extracted text is too small to be useful.
+const MIN_WEB_FETCH_BYTES = 4_096
 const DEFAULT_SEARCH_LIMIT = 5
 const MAX_SEARCH_LIMIT = 10
 
@@ -110,7 +113,13 @@ function createFetchTool(config: WebCapabilityConfig, provider: WebProvider) {
       const policy = validateUrlPolicy(rawUrl, config)
       if (!policy.ok) return toolError('policy_blocked', policy.reason, telemetry({ startedAt, policy: 'blocked', url: rawUrl }))
       if (!provider.fetch) return toolError('provider_unavailable', 'web fetch provider is unavailable')
-      const maxBytes = boundedInt(args.max_bytes, DEFAULT_WEB_MAX_BYTES, 1, DEFAULT_WEB_MAX_BYTES)
+      const maxBytesCap = config.maxFetchBytes ?? DEFAULT_WEB_MAX_BYTES
+      const maxBytes = boundedInt(
+        args.max_bytes,
+        maxBytesCap,
+        Math.min(MIN_WEB_FETCH_BYTES, maxBytesCap),
+        maxBytesCap
+      )
       const timeoutMs = boundedInt(args.timeout_ms, DEFAULT_WEB_TIMEOUT_MS, 1, DEFAULT_WEB_TIMEOUT_MS)
       try {
         const result = await provider.fetch({
@@ -212,11 +221,9 @@ class FetchWebProvider implements WebProvider {
       const response = await fetch(request.url, { signal: controller.signal })
       if (!response.ok) throw new Error(`HTTP ${response.status}`)
 
-      // Fast-fail if content-length is known and exceeds limit
-      const contentLength = response.headers.get('content-length')
-      if (contentLength && Number(contentLength) > request.maxBytes) {
-        throw new Error(`content exceeds ${request.maxBytes} byte limit`)
-      }
+      // Oversized pages truncate at maxBytes via the streaming read below.
+      // Hard-failing on the declared content-length made most real pages
+      // unfetchable whenever the model passed a small byte budget.
 
       // Stream response body with size limit
       const reader = response.body?.getReader()
diff --git a/kun/src/attachments/attachment-store.ts b/kun/src/attachments/attachment-store.ts
index 99fb3bc3..3543da8e 100644
--- a/kun/src/attachments/attachment-store.ts
+++ b/kun/src/attachments/attachment-store.ts
@@ -14,6 +14,7 @@ export interface AttachmentStore {
     name: string
     data: Buffer
     mimeType?: string
+    localFilePath?: string
     textFallback?: AttachmentTextFallback
     threadId?: string
     workspace?: string
@@ -40,6 +41,7 @@ export class FileAttachmentStore implements AttachmentStore {
     name: string
     data: Buffer
     mimeType?: string
+    localFilePath?: string
     textFallback?: AttachmentTextFallback
     threadId?: string
     workspace?: string
@@ -64,6 +66,7 @@ export class FileAttachmentStore implements AttachmentStore {
     if (existing) {
       const next = mergeScope({
         ...existing,
+        ...(input.localFilePath ? { localFilePath: input.localFilePath } : {}),
         ...(input.textFallback ? { textFallback: input.textFallback } : {}),
         updatedAt: now
       }, input)
@@ -79,6 +82,7 @@ export class FileAttachmentStore implements AttachmentStore {
       hash,
       ...(image.width ? { width: image.width } : {}),
       ...(image.height ? { height: image.height } : {}),
+      ...(input.localFilePath ? { localFilePath: input.localFilePath } : {}),
       ...(input.textFallback ? { textFallback: input.textFallback } : {}),
       threadIds: [],
       workspaces: [],
@@ -179,7 +183,7 @@ function validateTextFallback(fallback: AttachmentTextFallback, config: Attachme
   }
 }
 
-function detectImage(buffer: Buffer): { mimeType: string; width?: number; height?: number } | null {
+export function detectImage(buffer: Buffer): { mimeType: string; width?: number; height?: number } | null {
   if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
     return { mimeType: 'image/png', width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
   }
diff --git a/kun/src/cli/agent-cli.ts b/kun/src/cli/agent-cli.ts
index a53a2b8f..54b0b11d 100644
--- a/kun/src/cli/agent-cli.ts
+++ b/kun/src/cli/agent-cli.ts
@@ -339,6 +339,7 @@ function buildExecContext(options: ServeOptions, workspace: string): ToolHostCon
     memoryPolicy: { enabled: false },
     delegationPolicy: { enabled: false },
     approvalPolicy: options.approvalPolicy,
+    sandboxMode: options.sandboxMode,
     abortSignal: new AbortController().signal,
     awaitApproval: async () => (options.approvalPolicy === 'auto' ? 'allow' : 'deny')
   }
diff --git a/kun/src/cli/cli-options.ts b/kun/src/cli/cli-options.ts
index ff6518fb..368f9653 100644
--- a/kun/src/cli/cli-options.ts
+++ b/kun/src/cli/cli-options.ts
@@ -23,6 +23,7 @@ import {
   MODEL_ENDPOINT_FORMATS,
   normalizeModelEndpointFormat
 } from '../contracts/model-endpoint-format.js'
+import { HooksConfigSchema } from '../hooks/hook-config.js'
 
 export const DEFAULT_SERVE_PORT = 8899
 export const DEFAULT_SERVE_MODEL = DEFAULT_KUN_MODEL
@@ -54,7 +55,8 @@ export const ServeOptionsSchema = z.object({
   models: ModelConfigSchema.optional(),
   contextCompaction: ContextCompactionConfigSchema.optional(),
   runtime: RuntimeTuningConfigSchema.optional(),
-  capabilities: KunCapabilitiesConfig.default(DEFAULT_KUN_CAPABILITIES_CONFIG)
+  capabilities: KunCapabilitiesConfig.default(DEFAULT_KUN_CAPABILITIES_CONFIG),
+  hooks: HooksConfigSchema.optional()
 })
 export type ServeOptions = z.infer
 
diff --git a/kun/src/cli/serve-entry.ts b/kun/src/cli/serve-entry.ts
index 7994ff98..4f1a0588 100644
--- a/kun/src/cli/serve-entry.ts
+++ b/kun/src/cli/serve-entry.ts
@@ -6,10 +6,40 @@ import {
   runAgentCommand,
   splitKunCliCommand
 } from './agent-cli.js'
-import { startKunServe } from '../server/runtime-factory.js'
+import { startKunServe, type KunServeHandle } from '../server/runtime-factory.js'
 
 export const KUN_READY_PREFIX = 'KUN_READY '
 
+/**
+ * Serve mode runs unattended under the GUI. An uncaught error must not
+ * leave a half-dead process: report it on stderr (the GUI captures the
+ * tail), attempt a bounded graceful close, then exit non-zero so the
+ * GUI supervisor can restart us.
+ */
+function installServeCrashHandlers(getHandle: () => KunServeHandle | null): void {
+  let crashing = false
+  const crash = (kind: string, error: unknown): void => {
+    if (crashing) return
+    crashing = true
+    const detail = error instanceof Error ? (error.stack ?? error.message) : String(error)
+    process.stderr.write(`kun serve: ${kind}: ${detail}\n`)
+    const finish = (): void => process.exit(ServeExitCode.runtime)
+    const handle = getHandle()
+    if (!handle) {
+      finish()
+      return
+    }
+    const deadline = setTimeout(finish, 3000)
+    deadline.unref()
+    void handle
+      .close()
+      .catch(() => undefined)
+      .finally(finish)
+  }
+  process.on('uncaughtException', (error) => crash('uncaughtException', error))
+  process.on('unhandledRejection', (reason) => crash('unhandledRejection', reason))
+}
+
 /**
  * Serve-mode command. Kept separate from the dispatcher so GUI startup
  * still has the exact same KUN_READY handshake behavior.
@@ -27,13 +57,16 @@ async function serveMain(argv: readonly string[]): Promise {
     }
     return parsed.exitCode
   }
-  const handle = await startKunServe(parsed.options)
-  const info = handle.runtime.info()
+  let handle: KunServeHandle | null = null
+  installServeCrashHandlers(() => handle)
+  const server = await startKunServe(parsed.options)
+  handle = server
+  const info = server.runtime.info()
   const startupInfo = {
     service: 'kun',
     mode: 'serve',
-    host: handle.host,
-    port: handle.port,
+    host: server.host,
+    port: server.port,
     configPath: info.configPath,
     dataDir: info.dataDir,
     model: info.model,
@@ -42,13 +75,13 @@ async function serveMain(argv: readonly string[]): Promise {
     insecure: info.insecure,
     startedAt: info.startedAt,
     pid: info.pid,
-    message: `kun runtime listening on http://${handle.host}:${handle.port}`
+    message: `kun runtime listening on http://${server.host}:${server.port}`
   }
   process.stdout.write(`${KUN_READY_PREFIX}${JSON.stringify(startupInfo)}\n`)
   process.stdout.write(JSON.stringify(startupInfo, null, 2) + '\n')
   await new Promise((resolve) => {
     const stop = () => {
-      void handle.close().finally(resolve)
+      void server.close().finally(resolve)
     }
     process.once('SIGTERM', stop)
     process.once('SIGINT', stop)
diff --git a/kun/src/cli/serve.ts b/kun/src/cli/serve.ts
index 707ee364..754f9fd4 100644
--- a/kun/src/cli/serve.ts
+++ b/kun/src/cli/serve.ts
@@ -136,7 +136,8 @@ export function parseServeOptions(
     models: loadedConfig?.config.models,
     contextCompaction: loadedConfig?.config.contextCompaction,
     runtime: loadedConfig?.config.runtime,
-    capabilities: loadedConfig?.config.capabilities ?? DEFAULT_SERVE_OPTIONS.capabilities
+    capabilities: loadedConfig?.config.capabilities ?? DEFAULT_SERVE_OPTIONS.capabilities,
+    hooks: loadedConfig?.config.hooks
   }
   return ServeOptionsSchema.parse(merged)
 }
diff --git a/kun/src/config/kun-config.ts b/kun/src/config/kun-config.ts
index 7181e612..72a64ffa 100644
--- a/kun/src/config/kun-config.ts
+++ b/kun/src/config/kun-config.ts
@@ -12,13 +12,15 @@ import {
   DEFAULT_KUN_CAPABILITIES_CONFIG,
   KunCapabilitiesConfig,
   ModelInputModality,
-  ModelMessagePartSupport
+  ModelMessagePartSupport,
+  ModelReasoningCapabilityMetadata
 } from '../contracts/capabilities.js'
 import {
   DEFAULT_MODEL_ENDPOINT_FORMAT,
   MODEL_ENDPOINT_FORMATS,
   normalizeModelEndpointFormat
 } from '../contracts/model-endpoint-format.js'
+import { HooksConfigSchema } from '../hooks/hook-config.js'
 
 export const KUN_CONFIG_FILENAME = 'config.json'
 export const DEFAULT_KUN_MODEL = 'deepseek-v4-pro'
@@ -59,7 +61,8 @@ export const ModelContextProfileConfigSchema = z
     inputModalities: z.array(ModelInputModality).optional(),
     outputModalities: z.array(ModelInputModality).optional(),
     supportsToolCalling: z.boolean().optional(),
-    messageParts: z.array(ModelMessagePartSupport).optional()
+    messageParts: z.array(ModelMessagePartSupport).optional(),
+    reasoning: ModelReasoningCapabilityMetadata.optional()
   })
   .strict()
   .superRefine((profile, ctx) => {
@@ -193,7 +196,8 @@ export const KunConfigSchema = z
     models: ModelConfigSchema.optional(),
     contextCompaction: ContextCompactionConfigSchema.optional(),
     runtime: RuntimeTuningConfigSchema.optional(),
-    capabilities: KunCapabilitiesConfig.default(DEFAULT_KUN_CAPABILITIES_CONFIG)
+    capabilities: KunCapabilitiesConfig.default(DEFAULT_KUN_CAPABILITIES_CONFIG),
+    hooks: HooksConfigSchema.optional()
   })
   .strict()
 
diff --git a/kun/src/contracts/attachments.ts b/kun/src/contracts/attachments.ts
index 98bab076..b253a002 100644
--- a/kun/src/contracts/attachments.ts
+++ b/kun/src/contracts/attachments.ts
@@ -18,6 +18,7 @@ export const AttachmentMetadata = z.object({
   hash: z.string().min(1),
   width: z.number().int().positive().optional(),
   height: z.number().int().positive().optional(),
+  localFilePath: z.string().min(1).optional(),
   textFallback: AttachmentTextFallback.optional(),
   threadIds: z.array(z.string().min(1)).default([]),
   workspaces: z.array(z.string().min(1)).default([]),
@@ -30,6 +31,7 @@ export const AttachmentUploadRequest = z.object({
   name: z.string().min(1),
   mimeType: z.string().min(1).optional(),
   dataBase64: z.string().min(1),
+  localFilePath: z.string().min(1).optional(),
   textFallback: AttachmentTextFallback.optional(),
   threadId: z.string().min(1).optional(),
   workspace: z.string().min(1).optional()
diff --git a/kun/src/contracts/capabilities.ts b/kun/src/contracts/capabilities.ts
index cd7d7326..4fa32aa1 100644
--- a/kun/src/contracts/capabilities.ts
+++ b/kun/src/contracts/capabilities.ts
@@ -21,6 +21,27 @@ export type ModelInputModality = z.infer
 export const ModelMessagePartSupport = z.enum(['text', 'image_url', 'input_image'])
 export type ModelMessagePartSupport = z.infer
 
+export const ModelReasoningEffort = z.enum(['auto', 'off', 'low', 'medium', 'high', 'max'])
+export type ModelReasoningEffort = z.infer
+
+export const ModelReasoningRequestProtocol = z.enum([
+  'none',
+  'deepseek-chat-completions',
+  'mimo-chat-completions',
+  'openai-responses',
+  'anthropic-thinking'
+])
+export type ModelReasoningRequestProtocol = z.infer
+
+export const ModelReasoningCapabilityMetadata = z
+  .object({
+    supportedEfforts: z.array(ModelReasoningEffort).min(1),
+    defaultEffort: ModelReasoningEffort,
+    requestProtocol: ModelReasoningRequestProtocol
+  })
+  .strict()
+export type ModelReasoningCapabilityMetadata = z.infer
+
 export const ModelCapabilityMetadata = z
   .object({
     id: z.string().min(1),
@@ -28,7 +49,8 @@ export const ModelCapabilityMetadata = z
     outputModalities: z.array(ModelInputModality).min(1),
     supportsToolCalling: z.boolean(),
     contextWindowTokens: z.number().int().positive().optional(),
-    messageParts: z.array(ModelMessagePartSupport).min(1)
+    messageParts: z.array(ModelMessagePartSupport).min(1),
+    reasoning: ModelReasoningCapabilityMetadata.optional()
   })
   .strict()
 export type ModelCapabilityMetadata = z.infer
@@ -146,7 +168,9 @@ export const WebCapabilityConfig = CapabilityToggleConfig.extend({
   searchEnabled: z.boolean().default(false),
   provider: z.string().min(1).optional(),
   allowDomains: z.array(z.string().min(1)).default([]),
-  denyDomains: z.array(z.string().min(1)).default([])
+  denyDomains: z.array(z.string().min(1)).default([]),
+  /** Upper bound for web_fetch body bytes; fetched pages truncate here. */
+  maxFetchBytes: z.number().int().positive().default(1_000_000)
 }).strict()
 export type WebCapabilityConfig = z.infer
 
@@ -186,6 +210,62 @@ export const MemoryCapabilityConfig = CapabilityToggleConfig.extend({
 }).strict()
 export type MemoryCapabilityConfig = z.infer
 
+export const ImageGenerationProtocol = z.enum(['openai-images', 'minimax-image'])
+export type ImageGenerationProtocol = z.infer
+
+export const ImageGenCapabilityConfig = CapabilityToggleConfig.extend({
+  protocol: ImageGenerationProtocol.default('openai-images'),
+  baseUrl: z.string().min(1).optional(),
+  apiKey: z.string().min(1).optional(),
+  model: z.string().min(1).optional(),
+  defaultSize: z.string().min(1).optional(),
+  timeoutMs: z.number().int().positive().default(180_000),
+  maxReferenceImages: z.number().int().positive().max(8).default(4)
+}).strict()
+export type ImageGenCapabilityConfig = z.infer
+
+export const TextToSpeechProtocol = z.enum(['openai-speech', 'minimax-t2a', 'mimo-tts'])
+export type TextToSpeechProtocol = z.infer
+
+export const SpeechGenCapabilityConfig = CapabilityToggleConfig.extend({
+  protocol: TextToSpeechProtocol.default('openai-speech'),
+  baseUrl: z.string().min(1).optional(),
+  apiKey: z.string().min(1).optional(),
+  model: z.string().min(1).optional(),
+  voice: z.string().min(1).optional(),
+  format: z.string().min(1).default('mp3'),
+  timeoutMs: z.number().int().positive().default(120_000)
+}).strict()
+export type SpeechGenCapabilityConfig = z.infer
+
+export const MusicGenerationProtocol = z.enum(['minimax-music'])
+export type MusicGenerationProtocol = z.infer
+
+export const MusicGenCapabilityConfig = CapabilityToggleConfig.extend({
+  protocol: MusicGenerationProtocol.default('minimax-music'),
+  baseUrl: z.string().min(1).optional(),
+  apiKey: z.string().min(1).optional(),
+  model: z.string().min(1).optional(),
+  format: z.string().min(1).default('mp3'),
+  timeoutMs: z.number().int().positive().default(300_000)
+}).strict()
+export type MusicGenCapabilityConfig = z.infer
+
+export const VideoGenerationProtocol = z.enum(['minimax-video'])
+export type VideoGenerationProtocol = z.infer
+
+export const VideoGenCapabilityConfig = CapabilityToggleConfig.extend({
+  protocol: VideoGenerationProtocol.default('minimax-video'),
+  baseUrl: z.string().min(1).optional(),
+  apiKey: z.string().min(1).optional(),
+  model: z.string().min(1).optional(),
+  defaultDuration: z.number().int().positive().default(6),
+  defaultResolution: z.string().min(1).default('1080P'),
+  timeoutMs: z.number().int().positive().default(900_000),
+  pollIntervalMs: z.number().int().positive().default(10_000)
+}).strict()
+export type VideoGenCapabilityConfig = z.infer
+
 export const KunCapabilitiesConfig = z
   .object({
     mcp: McpCapabilityConfig.default(() => McpCapabilityConfig.parse({})),
@@ -193,7 +273,11 @@ export const KunCapabilitiesConfig = z
     skills: SkillsCapabilityConfig.default(() => SkillsCapabilityConfig.parse({})),
     subagents: SubagentsCapabilityConfig.default(() => SubagentsCapabilityConfig.parse({})),
     attachments: AttachmentsCapabilityConfig.default(() => AttachmentsCapabilityConfig.parse({})),
-    memory: MemoryCapabilityConfig.default(() => MemoryCapabilityConfig.parse({}))
+    memory: MemoryCapabilityConfig.default(() => MemoryCapabilityConfig.parse({})),
+    imageGen: ImageGenCapabilityConfig.default(() => ImageGenCapabilityConfig.parse({})),
+    speechGen: SpeechGenCapabilityConfig.default(() => SpeechGenCapabilityConfig.parse({})),
+    musicGen: MusicGenCapabilityConfig.default(() => MusicGenCapabilityConfig.parse({})),
+    videoGen: VideoGenCapabilityConfig.default(() => VideoGenCapabilityConfig.parse({}))
   })
   .strict()
 export type KunCapabilitiesConfig = z.infer
@@ -250,6 +334,18 @@ export const RuntimeCapabilityManifest = z
     memory: RuntimeCapabilityState.extend({
       scopes: z.array(z.enum(['user', 'workspace', 'project'])),
       maxInjectedRecords: z.number().int().positive()
+    }).strict(),
+    imageGen: RuntimeCapabilityState.extend({
+      model: z.string().optional()
+    }).strict(),
+    speechGen: RuntimeCapabilityState.extend({
+      model: z.string().optional()
+    }).strict(),
+    musicGen: RuntimeCapabilityState.extend({
+      model: z.string().optional()
+    }).strict(),
+    videoGen: RuntimeCapabilityState.extend({
+      model: z.string().optional()
     }).strict()
   })
   .strict()
@@ -292,6 +388,22 @@ export function buildRuntimeCapabilityManifest(input: {
     available?: boolean
     reason?: string
   }
+  imageGen?: {
+    available?: boolean
+    reason?: string
+  }
+  speechGen?: {
+    available?: boolean
+    reason?: string
+  }
+  musicGen?: {
+    available?: boolean
+    reason?: string
+  }
+  videoGen?: {
+    available?: boolean
+    reason?: string
+  }
 }): RuntimeCapabilityManifest {
   const config = KunCapabilitiesConfig.parse(input.config ?? {})
   const configuredMcpServers = input.mcp?.configuredServers ?? Object.keys(config.mcp.servers).length
@@ -380,6 +492,42 @@ export function buildRuntimeCapabilityManifest(input: {
       ),
       scopes: config.memory.scopes,
       maxInjectedRecords: config.memory.maxInjectedRecords
+    },
+    imageGen: {
+      ...providerCapabilityState(
+        config.imageGen.enabled,
+        'image generation is disabled by config',
+        input.imageGen?.available === true,
+        input.imageGen?.reason ?? 'image generation provider is not configured'
+      ),
+      ...(config.imageGen.model ? { model: config.imageGen.model } : {})
+    },
+    speechGen: {
+      ...providerCapabilityState(
+        config.speechGen.enabled,
+        'speech generation is disabled by config',
+        input.speechGen?.available === true,
+        input.speechGen?.reason ?? 'speech generation provider is not configured'
+      ),
+      ...(config.speechGen.model ? { model: config.speechGen.model } : {})
+    },
+    musicGen: {
+      ...providerCapabilityState(
+        config.musicGen.enabled,
+        'music generation is disabled by config',
+        input.musicGen?.available === true,
+        input.musicGen?.reason ?? 'music generation provider is not configured'
+      ),
+      ...(config.musicGen.model ? { model: config.musicGen.model } : {})
+    },
+    videoGen: {
+      ...providerCapabilityState(
+        config.videoGen.enabled,
+        'video generation is disabled by config',
+        input.videoGen?.available === true,
+        input.videoGen?.reason ?? 'video generation provider is not configured'
+      ),
+      ...(config.videoGen.model ? { model: config.videoGen.model } : {})
     }
   })
 }
diff --git a/kun/src/contracts/errors.ts b/kun/src/contracts/errors.ts
index 9a1f3777..89307146 100644
--- a/kun/src/contracts/errors.ts
+++ b/kun/src/contracts/errors.ts
@@ -3,7 +3,7 @@ import { z } from 'zod'
 /**
  * Structured API error codes returned by every Kun HTTP/SSE endpoint.
  *
- * The error contract mirrors what DeepSeek-GUI diagnostics can render:
+ * The error contract mirrors what Kun diagnostics can render:
  * the renderer needs a stable `code` to drive UI state and a human-readable
  * `message` to surface in toasts. `details` carries optional, JSON-encodable
  * per-endpoint information (for example a Zod issue list).
diff --git a/kun/src/contracts/events.ts b/kun/src/contracts/events.ts
index 0d31c52f..736e1a11 100644
--- a/kun/src/contracts/events.ts
+++ b/kun/src/contracts/events.ts
@@ -3,6 +3,7 @@ import { TurnItem } from './items.js'
 import { ThreadGoalSchema, ThreadTodoListSchema } from './threads.js'
 import { UsageSnapshotSchema } from './usage.js'
 import { RuntimeErrorSeverity } from './errors.js'
+import { ApprovalPolicySchema, SandboxModeSchema } from './policy.js'
 
 /**
  * Persisted runtime events. Every event has a per-thread `seq` so the
@@ -118,6 +119,8 @@ export const ApprovalEvent = RuntimeEventBase.extend({
   approvalId: z.string().min(1),
   toolName: z.string().min(1),
   status: z.enum(['pending', 'allowed', 'denied', 'expired']),
+  approvalPolicy: ApprovalPolicySchema.optional(),
+  sandboxMode: SandboxModeSchema.optional(),
   summary: z.string().optional()
 })
 export type ApprovalEvent = z.infer
diff --git a/kun/src/contracts/threads.ts b/kun/src/contracts/threads.ts
index a37a8d11..0d01ecd1 100644
--- a/kun/src/contracts/threads.ts
+++ b/kun/src/contracts/threads.ts
@@ -123,6 +123,8 @@ export const ThreadSummarySchema = ThreadSchema.pick({
   model: true,
   mode: true,
   status: true,
+  approvalPolicy: true,
+  sandboxMode: true,
   costBudgetUsd: true,
   costBudgetWarningSent: true,
   relation: true,
diff --git a/kun/src/contracts/turns.ts b/kun/src/contracts/turns.ts
index d893d0dc..0a73d3a1 100644
--- a/kun/src/contracts/turns.ts
+++ b/kun/src/contracts/turns.ts
@@ -1,7 +1,7 @@
 import { z } from 'zod'
 import { TurnItem } from './items.js'
 import { isGuiPlanRelativePath } from '../shared/gui-plan.js'
-import { ApprovalPolicySchema } from './policy.js'
+import { ApprovalPolicySchema, SandboxModeSchema } from './policy.js'
 
 /**
  * Mode enum, inlined here (instead of importing `ThreadMode` from
@@ -77,6 +77,12 @@ export const TurnSchema = z.object({
    * otherwise agent thread, or a Build turn that runs as agent).
    */
   mode: TurnModeSchema.optional(),
+  /**
+   * True when no interactive user is attached to this turn (IM bridges,
+   * headless runs). Kun hides `user_input`/`request_user_input` and
+   * rejects calls to them instead of blocking on a GUI answer.
+   */
+  disableUserInput: z.boolean().optional(),
   error: z.string().optional()
 })
 export type Turn = z.infer
@@ -87,6 +93,7 @@ export const StartTurnRequest = z.object({
   model: z.string().optional(),
   reasoningEffort: TurnReasoningEffortSchema.optional(),
   approvalPolicy: ApprovalPolicySchema.optional(),
+  sandboxMode: SandboxModeSchema.optional(),
   /**
    * Optional per-turn mode. Overrides the thread mode for this turn so
    * the GUI can toggle Plan/agent without recreating the thread. In Plan
@@ -107,7 +114,13 @@ export const StartTurnRequest = z.object({
    * `create_plan` tool for the turn and writes only to the reserved
    * path advertised in the context.
    */
-  guiPlan: GuiPlanContextSchema.optional()
+  guiPlan: GuiPlanContextSchema.optional(),
+  /**
+   * True when the caller cannot relay structured input prompts to a
+   * user (IM bridges such as WeChat/Feishu, headless runs). The turn
+   * runs without the `user_input`/`request_user_input` tools.
+   */
+  disableUserInput: z.boolean().optional()
 })
 export type StartTurnRequest = z.input
 
diff --git a/kun/src/contracts/usage.ts b/kun/src/contracts/usage.ts
index 064ec0e9..f9bd78b8 100644
--- a/kun/src/contracts/usage.ts
+++ b/kun/src/contracts/usage.ts
@@ -19,6 +19,12 @@ export const UsageSnapshotSchema = z.object({
   turns: z.number().int().nonnegative(),
   costUsd: z.number().nonnegative().optional(),
   costCny: z.number().nonnegative().optional(),
+  /**
+   * @deprecated Savings are reported in tokens only (cache hits via
+   * `cacheHitTokens`, compression via `tokenEconomySavingsTokens`).
+   * The money fields remain parseable for persisted threads recorded
+   * by older runtimes but are no longer populated.
+   */
   cacheSavingsUsd: z.number().nonnegative().optional(),
   cacheSavingsCny: z.number().nonnegative().optional(),
   tokenEconomySavingsTokens: z.number().int().nonnegative().optional(),
@@ -121,9 +127,5 @@ export const emptyUsageSnapshot = (): UsageSnapshot => ({
   cacheMissTokens: 0,
   cacheHitRate: null,
   turns: 0,
-  cacheSavingsUsd: 0,
-  cacheSavingsCny: 0,
-  tokenEconomySavingsTokens: 0,
-  tokenEconomySavingsUsd: 0,
-  tokenEconomySavingsCny: 0
+  tokenEconomySavingsTokens: 0
 })
diff --git a/kun/src/domain/thread.ts b/kun/src/domain/thread.ts
index e07667a7..9a79454c 100644
--- a/kun/src/domain/thread.ts
+++ b/kun/src/domain/thread.ts
@@ -77,7 +77,7 @@ export function toThreadSummary(
   thread: ThreadEntity
 ): Pick<
   ThreadEntity,
-  'id' | 'title' | 'workspace' | 'model' | 'mode' | 'status' | 'createdAt' | 'updatedAt'
+  'id' | 'title' | 'workspace' | 'model' | 'mode' | 'status' | 'approvalPolicy' | 'sandboxMode' | 'createdAt' | 'updatedAt'
   | 'costBudgetUsd' | 'costBudgetWarningSent'
   | 'relation' | 'parentThreadId'
   | 'forkedFromThreadId' | 'forkedFromTitle' | 'forkedAt' | 'forkedFromMessageCount' | 'forkedFromTurnCount'
@@ -90,6 +90,8 @@ export function toThreadSummary(
     model: thread.model,
     mode: thread.mode,
     status: thread.status,
+    approvalPolicy: thread.approvalPolicy,
+    sandboxMode: thread.sandboxMode,
     ...(thread.costBudgetUsd !== undefined ? { costBudgetUsd: thread.costBudgetUsd } : {}),
     ...(thread.costBudgetWarningSent !== undefined ? { costBudgetWarningSent: thread.costBudgetWarningSent } : {}),
     relation: thread.relation ?? 'primary',
diff --git a/kun/src/domain/turn.ts b/kun/src/domain/turn.ts
index 586c74f7..41ddec21 100644
--- a/kun/src/domain/turn.ts
+++ b/kun/src/domain/turn.ts
@@ -13,6 +13,7 @@ export function createTurnRecord(input: {
   attachmentIds?: string[]
   guiPlan?: GuiPlanContextJson
   mode?: ThreadMode
+  disableUserInput?: boolean
   createdAt?: string
   status?: TurnStatus
 }): TurnEntity {
@@ -32,6 +33,7 @@ export function createTurnRecord(input: {
     ...(reasoningEffort ? { reasoningEffort } : {}),
     ...(input.guiPlan ? { guiPlan: input.guiPlan } : {}),
     ...(input.mode ? { mode: input.mode } : {}),
+    ...(input.disableUserInput ? { disableUserInput: true } : {}),
     createdAt: input.createdAt ?? new Date().toISOString()
   }
 }
diff --git a/kun/src/hooks/hook-config.ts b/kun/src/hooks/hook-config.ts
new file mode 100644
index 00000000..ca4e141b
--- /dev/null
+++ b/kun/src/hooks/hook-config.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod'
+import { HOOK_PHASES, type ResolvedHook } from './hook-engine.js'
+
+/**
+ * Command hook entry as written in `config.json` under the top-level
+ * `hooks` key. Only command hooks are configurable from JSON; function
+ * hooks are reserved for embedders that assemble the runtime in code.
+ */
+export const HookCommandConfigSchema = z
+  .object({
+    phase: z.enum(HOOK_PHASES),
+    /** Glob matched against the tool name (`*` wildcard, `|` alternation). Tool phases only. */
+    matcher: z.string().min(1).optional(),
+    /** Exact tool-name list; matches when either this or `matcher` matches. Tool phases only. */
+    toolNames: z.array(z.string().min(1)).optional(),
+    /** Shell command. Receives the invocation as JSON on stdin. */
+    command: z.string().min(1),
+    /** Working directory; defaults to the active workspace. */
+    cwd: z.string().min(1).optional(),
+    timeoutMs: z.number().int().positive().optional()
+  })
+  .strict()
+
+export const HooksConfigSchema = z.array(HookCommandConfigSchema)
+
+export type HookCommandConfig = z.infer
+export type HooksConfig = z.infer
+
+/** Map validated config entries onto runnable command hooks. */
+export function resolveConfiguredHooks(config: HooksConfig | undefined): ResolvedHook[] {
+  return (config ?? []).map((entry) => ({
+    phase: entry.phase,
+    ...(entry.matcher ? { matcher: entry.matcher } : {}),
+    ...(entry.toolNames ? { toolNames: entry.toolNames } : {}),
+    ...(entry.timeoutMs ? { timeoutMs: entry.timeoutMs } : {}),
+    command: entry.command,
+    ...(entry.cwd ? { cwd: entry.cwd } : {})
+  }))
+}
diff --git a/kun/src/hooks/hook-engine.ts b/kun/src/hooks/hook-engine.ts
new file mode 100644
index 00000000..48d4cebd
--- /dev/null
+++ b/kun/src/hooks/hook-engine.ts
@@ -0,0 +1,387 @@
+import { spawn } from 'node:child_process'
+import type { ToolCallLike, ToolHostContext } from '../ports/tool-host.js'
+import { terminateSpawnTree } from '../adapters/tool/builtin-tool-utils.js'
+
+/**
+ * Hook phases. Tool phases run inside the tool host around every tool
+ * call; lifecycle phases run inside the agent loop. `UserPromptSubmit`
+ * may deny the turn or inject extra context; `TurnStart`, `TurnEnd`,
+ * and `PreCompact` are observe-only.
+ */
+export const HOOK_PHASES = [
+  'PreToolUse',
+  'PostToolUse',
+  'UserPromptSubmit',
+  'TurnStart',
+  'TurnEnd',
+  'PreCompact'
+] as const
+
+export type HookPhase = (typeof HOOK_PHASES)[number]
+
+export type ToolHookContext = Pick<
+  ToolHostContext,
+  'threadId' | 'turnId' | 'workspace' | 'threadMode' | 'approvalPolicy' | 'sandboxMode'
+>
+
+export type ToolHookResultPayload = {
+  output: unknown
+  isError?: boolean
+}
+
+export type HookInvocation =
+  | { phase: 'PreToolUse'; call: ToolCallLike; context: ToolHookContext }
+  | { phase: 'PostToolUse'; call: ToolCallLike; context: ToolHookContext; result: ToolHookResultPayload }
+  | { phase: 'UserPromptSubmit'; threadId: string; turnId: string; prompt: string; workspace?: string }
+  | { phase: 'TurnStart'; threadId: string; turnId: string; prompt: string; workspace?: string }
+  | {
+      phase: 'TurnEnd'
+      threadId: string
+      turnId: string
+      status: 'completed' | 'failed' | 'aborted'
+      error?: string
+      workspace?: string
+    }
+  | { phase: 'PreCompact'; threadId: string; turnId: string; reason: string; mode?: string; workspace?: string }
+
+export type HookResult = {
+  /**
+   * `deny` blocks the action (tool call or turn) with `message` as the
+   * reason. `allow` on PreToolUse additionally skips approval prompting
+   * for this call. Later hooks can still deny an earlier allow.
+   */
+  decision?: 'allow' | 'deny'
+  message?: string
+  /** PreToolUse only: replaces the tool arguments for subsequent hooks and execution. */
+  arguments?: Record
+  /** PostToolUse only: replaces the tool output for subsequent hooks and the model. */
+  output?: unknown
+  isError?: boolean
+  /** UserPromptSubmit only: extra context appended to the turn as a persisted message. */
+  additionalContext?: string
+}
+
+/**
+ * A hook ready to run. Function hooks (`run`) are for embedders that
+ * assemble the runtime programmatically. Command hooks (`command`) are
+ * what `config.json` resolves to: the invocation is written to stdin as
+ * JSON and the result is read from stdout (see `runCommandHook`).
+ */
+export type ResolvedHook =
+  | {
+      phase: HookPhase
+      /** Glob pattern matched against the tool name: `*` wildcard, `|` alternation. */
+      matcher?: string
+      /** Exact tool-name allow-list. Matches when either this or `matcher` matches. */
+      toolNames?: readonly string[]
+      timeoutMs?: number
+      run: (invocation: HookInvocation) => Promise | HookResult | void
+    }
+  | {
+      phase: HookPhase
+      matcher?: string
+      toolNames?: readonly string[]
+      timeoutMs?: number
+      command: string
+      cwd?: string
+    }
+
+export const DEFAULT_HOOK_TIMEOUT_MS = 60_000
+
+/** Exit code a command hook uses to block the action (deny / mark error). */
+export const HOOK_BLOCKING_EXIT_CODE = 2
+
+export type PreToolUseOutcome = {
+  call: ToolCallLike
+  denied?: string
+  /** True when a hook returned `decision: 'allow'` and nothing denied: skips approval. */
+  autoApproved: boolean
+  warnings: string[]
+}
+
+export type PostToolUseOutcome = {
+  output: unknown
+  isError?: boolean
+  warnings: string[]
+}
+
+export type UserPromptSubmitOutcome = {
+  denied?: string
+  additionalContext: string[]
+  warnings: string[]
+}
+
+export type ObserverOutcome = {
+  warnings: string[]
+}
+
+export function hasHooksForPhase(hooks: readonly ResolvedHook[] | undefined, phase: HookPhase): boolean {
+  return (hooks ?? []).some((hook) => hook.phase === phase)
+}
+
+/**
+ * Run PreToolUse hooks in order. Argument rewrites chain: each hook
+ * sees the call as rewritten by the hooks before it. A deny stops the
+ * chain. Hook crashes and timeouts propagate to the caller, which
+ * contains them as a `hook_failed` tool error.
+ */
+export async function runPreToolUseHooks(
+  hooks: readonly ResolvedHook[] | undefined,
+  input: { call: ToolCallLike; context: ToolHookContext }
+): Promise {
+  let call = input.call
+  let autoApproved = false
+  const warnings: string[] = []
+  for (const hook of hooksForTool(hooks, 'PreToolUse', call.toolName)) {
+    const outcome = await executeHook(hook, { phase: 'PreToolUse', call, context: input.context })
+    if (outcome.warning) warnings.push(outcome.warning)
+    const result = outcome.result
+    if (!result) continue
+    if (result.decision === 'deny') {
+      return {
+        call,
+        denied: result.message || 'tool call denied by PreToolUse hook',
+        autoApproved: false,
+        warnings
+      }
+    }
+    if (result.decision === 'allow') autoApproved = true
+    if (result.arguments && typeof result.arguments === 'object') {
+      call = { ...call, arguments: result.arguments }
+    }
+  }
+  return { call, autoApproved, warnings }
+}
+
+/**
+ * Run PostToolUse hooks in order. Output rewrites chain: each hook sees
+ * the result as rewritten by the hooks before it.
+ */
+export async function runPostToolUseHooks(
+  hooks: readonly ResolvedHook[] | undefined,
+  input: { call: ToolCallLike; context: ToolHookContext; result: ToolHookResultPayload }
+): Promise {
+  let current = input.result
+  const warnings: string[] = []
+  for (const hook of hooksForTool(hooks, 'PostToolUse', input.call.toolName)) {
+    const outcome = await executeHook(hook, {
+      phase: 'PostToolUse',
+      call: input.call,
+      context: input.context,
+      result: current
+    })
+    if (outcome.warning) warnings.push(outcome.warning)
+    const result = outcome.result
+    if (!result) continue
+    if ('output' in result) {
+      current = { output: result.output, isError: result.isError ?? current.isError }
+    } else if (result.isError !== undefined) {
+      current = { ...current, isError: result.isError }
+    }
+  }
+  return { output: current.output, isError: current.isError, warnings }
+}
+
+/**
+ * Run UserPromptSubmit hooks. A deny fails the turn before the first
+ * model call. `additionalContext` strings are collected for the loop to
+ * persist as extra turn context. Hook crashes fail open with a warning:
+ * a broken gate must not lock the user out of their own agent.
+ */
+export async function runUserPromptSubmitHooks(
+  hooks: readonly ResolvedHook[] | undefined,
+  input: { threadId: string; turnId: string; prompt: string; workspace?: string }
+): Promise {
+  const additionalContext: string[] = []
+  const warnings: string[] = []
+  for (const hook of hooksForPhase(hooks, 'UserPromptSubmit')) {
+    let outcome: HookExecutionOutcome
+    try {
+      outcome = await executeHook(hook, { phase: 'UserPromptSubmit', ...input })
+    } catch (error) {
+      warnings.push(`UserPromptSubmit hook failed: ${errorMessage(error)}`)
+      continue
+    }
+    if (outcome.warning) warnings.push(outcome.warning)
+    const result = outcome.result
+    if (!result) continue
+    if (result.decision === 'deny') {
+      return {
+        denied: result.message || 'turn denied by UserPromptSubmit hook',
+        additionalContext,
+        warnings
+      }
+    }
+    if (result.additionalContext?.trim()) additionalContext.push(result.additionalContext.trim())
+    if (result.message?.trim() && !result.additionalContext) warnings.push(result.message.trim())
+  }
+  return { additionalContext, warnings }
+}
+
+/**
+ * Run observe-only hooks (TurnStart, TurnEnd, PreCompact). Results are
+ * ignored except messages; crashes and timeouts become warnings.
+ */
+export async function runObserverHooks(
+  hooks: readonly ResolvedHook[] | undefined,
+  invocation: Extract
+): Promise {
+  const warnings: string[] = []
+  for (const hook of hooksForPhase(hooks, invocation.phase)) {
+    try {
+      const outcome = await executeHook(hook, invocation)
+      if (outcome.warning) warnings.push(outcome.warning)
+      else if (outcome.result?.message?.trim()) warnings.push(outcome.result.message.trim())
+    } catch (error) {
+      warnings.push(`${invocation.phase} hook failed: ${errorMessage(error)}`)
+    }
+  }
+  return { warnings }
+}
+
+function hooksForPhase(hooks: readonly ResolvedHook[] | undefined, phase: HookPhase): ResolvedHook[] {
+  return (hooks ?? []).filter((hook) => hook.phase === phase)
+}
+
+function hooksForTool(
+  hooks: readonly ResolvedHook[] | undefined,
+  phase: HookPhase,
+  toolName: string
+): ResolvedHook[] {
+  return hooksForPhase(hooks, phase).filter((hook) => hookMatchesTool(hook, toolName))
+}
+
+export function hookMatchesTool(
+  hook: Pick,
+  toolName: string
+): boolean {
+  const hasNames = Boolean(hook.toolNames && hook.toolNames.length > 0)
+  const hasMatcher = Boolean(hook.matcher)
+  if (!hasNames && !hasMatcher) return true
+  if (hasNames && hook.toolNames!.includes(toolName)) return true
+  if (hasMatcher && compileMatcher(hook.matcher!).test(toolName)) return true
+  return false
+}
+
+const matcherCache = new Map()
+
+/** Compile a glob matcher: `*` matches any run of characters, `|` separates alternatives. */
+function compileMatcher(pattern: string): RegExp {
+  const cached = matcherCache.get(pattern)
+  if (cached) return cached
+  const alternatives = pattern
+    .split('|')
+    .map((part) => part.trim())
+    .filter(Boolean)
+    .map((part) => part.replace(/[.+?^${}()[\]\\]/g, '\\$&').replaceAll('*', '.*'))
+  const regex = new RegExp(`^(?:${alternatives.join('|') || '$.'})$`)
+  matcherCache.set(pattern, regex)
+  return regex
+}
+
+type HookExecutionOutcome = {
+  result?: HookResult
+  /** Non-blocking diagnostic (command hook exited non-zero without blocking). */
+  warning?: string
+}
+
+async function executeHook(hook: ResolvedHook, invocation: HookInvocation): Promise {
+  if ('run' in hook) {
+    const result = await withTimeout(
+      Promise.resolve(hook.run(invocation)),
+      hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
+      `${hook.phase} hook timed out`
+    )
+    return result ? { result } : {}
+  }
+  return runCommandHook(hook, invocation)
+}
+
+/**
+ * Command hook protocol:
+ * - The invocation is written to stdin as a single JSON document.
+ * - Exit 0: stdout is parsed as a JSON `HookResult`. Plain (non-JSON)
+ *   stdout becomes `additionalContext` for UserPromptSubmit and
+ *   `message` for every other phase.
+ * - Exit 2: blocks. PreToolUse/UserPromptSubmit deny, PostToolUse marks
+ *   the result as an error; stderr is the reason.
+ * - Any other exit code: non-blocking warning with stderr attached.
+ * - Timeout kills the spawned process tree and propagates as an error.
+ */
+async function runCommandHook(
+  hook: Extract,
+  invocation: HookInvocation
+): Promise {
+  const payload = JSON.stringify(invocation)
+  const child = spawn(hook.command, {
+    cwd: hook.cwd || workspaceOf(invocation) || undefined,
+    shell: true,
+    stdio: ['pipe', 'pipe', 'pipe']
+  })
+  child.stdin.end(payload)
+  let stdout = ''
+  let stderr = ''
+  child.stdout.on('data', (chunk) => {
+    stdout += String(chunk)
+  })
+  child.stderr.on('data', (chunk) => {
+    stderr += String(chunk)
+  })
+  const exitCode = await withTimeout(
+    new Promise((resolve) => {
+      child.on('close', (code) => resolve(code ?? 0))
+    }),
+    hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
+    `${hook.phase} command hook timed out`
+  ).catch((error) => {
+    terminateSpawnTree(child)
+    throw error
+  })
+  if (exitCode === HOOK_BLOCKING_EXIT_CODE) {
+    const reason = stderr.trim() || `${hook.phase} command hook blocked (exit ${exitCode})`
+    if (invocation.phase === 'PostToolUse') {
+      return { result: { isError: true, message: reason } }
+    }
+    return { result: { decision: 'deny', message: reason } }
+  }
+  if (exitCode !== 0) {
+    return {
+      warning: stderr.trim() || `${hook.phase} command hook exited with ${exitCode}`
+    }
+  }
+  const text = stdout.trim()
+  if (!text) return {}
+  try {
+    return { result: JSON.parse(text) as HookResult }
+  } catch {
+    if (invocation.phase === 'UserPromptSubmit') {
+      return { result: { additionalContext: text } }
+    }
+    return { result: { message: text } }
+  }
+}
+
+function workspaceOf(invocation: HookInvocation): string | undefined {
+  if (invocation.phase === 'PreToolUse' || invocation.phase === 'PostToolUse') {
+    return invocation.context.workspace
+  }
+  return invocation.workspace
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
+
+async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise {
+  let timer: ReturnType | undefined
+  try {
+    return await Promise.race([
+      promise,
+      new Promise((_resolve, reject) => {
+        timer = setTimeout(() => reject(new Error(message)), Math.max(1, timeoutMs))
+      })
+    ])
+  } finally {
+    if (timer) clearTimeout(timer)
+  }
+}
diff --git a/kun/src/hooks/index.ts b/kun/src/hooks/index.ts
new file mode 100644
index 00000000..f522e5ca
--- /dev/null
+++ b/kun/src/hooks/index.ts
@@ -0,0 +1,27 @@
+export {
+  DEFAULT_HOOK_TIMEOUT_MS,
+  HOOK_BLOCKING_EXIT_CODE,
+  HOOK_PHASES,
+  hasHooksForPhase,
+  hookMatchesTool,
+  runObserverHooks,
+  runPostToolUseHooks,
+  runPreToolUseHooks,
+  runUserPromptSubmitHooks,
+  type HookInvocation,
+  type HookPhase,
+  type HookResult,
+  type ObserverOutcome,
+  type PostToolUseOutcome,
+  type PreToolUseOutcome,
+  type ResolvedHook,
+  type ToolHookContext,
+  type UserPromptSubmitOutcome
+} from './hook-engine.js'
+export {
+  HookCommandConfigSchema,
+  HooksConfigSchema,
+  resolveConfiguredHooks,
+  type HookCommandConfig,
+  type HooksConfig
+} from './hook-config.js'
diff --git a/kun/src/index.ts b/kun/src/index.ts
index c4b052e8..f80808fa 100644
--- a/kun/src/index.ts
+++ b/kun/src/index.ts
@@ -1,7 +1,7 @@
 /**
  * Kun public surface.
  *
- * The package exposes a small set of named entrypoints that the DeepSeek-GUI
+ * The package exposes a small set of named entrypoints that the Kun
  * main process and CLI use. The submodules contain the actual implementation
  * and additional re-exports.
  */
@@ -13,6 +13,7 @@ export * from './ports/index.js'
 export * from './adapters/index.js'
 export * from './attachments/index.js'
 export * from './services/index.js'
+export * from './hooks/index.js'
 export * from './loop/index.js'
 export * from './memory/index.js'
 export * from './cache/index.js'
diff --git a/kun/src/loop/agent-loop.test.ts b/kun/src/loop/agent-loop.test.ts
new file mode 100644
index 00000000..3079c6ea
--- /dev/null
+++ b/kun/src/loop/agent-loop.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from 'vitest'
+import { resolvePlanModeToolSpecs } from './agent-loop.js'
+import type { ModelToolSpec } from '../ports/model-client.js'
+
+function spec(name: string): ModelToolSpec {
+  return {
+    name,
+    description: `Tool: ${name}`,
+    toolKind: name === 'create_plan' || name === 'write' || name === 'edit'
+      ? 'file_change'
+      : 'tool_call',
+    inputSchema: { type: 'object', properties: {} }
+  }
+}
+
+const ALL_TOOLS: ModelToolSpec[] = [
+  spec('read'),
+  spec('write'),
+  spec('edit'),
+  spec('ls'),
+  spec('find'),
+  spec('grep'),
+  spec('bash'),
+  spec('web_search'),
+  spec('web_fetch'),
+  spec('create_plan')
+]
+
+const READ_ONLY_TOOLS = new Set([
+  'read', 'ls', 'find', 'grep', 'web_search', 'web_fetch'
+])
+
+describe('resolvePlanModeToolSpecs', () => {
+  it('step 0: read-only tools + create_plan only', () => {
+    const result = resolvePlanModeToolSpecs(ALL_TOOLS, {
+      planTurnActive: true,
+      createPlanSatisfied: false,
+      stepIndex: 0,
+      readOnlyToolNames: READ_ONLY_TOOLS
+    })
+    const names = result.map((t) => t.name)
+    expect(names).toContain('read')
+    expect(names).toContain('ls')
+    expect(names).toContain('find')
+    expect(names).toContain('grep')
+    expect(names).toContain('web_search')
+    expect(names).toContain('web_fetch')
+    expect(names).toContain('create_plan')
+    expect(names).not.toContain('write')
+    expect(names).not.toContain('edit')
+    expect(names).not.toContain('bash')
+  })
+
+  it('step > 0: only create_plan', () => {
+    const result = resolvePlanModeToolSpecs(ALL_TOOLS, {
+      planTurnActive: true,
+      createPlanSatisfied: false,
+      stepIndex: 1,
+      readOnlyToolNames: READ_ONLY_TOOLS
+    })
+    expect(result).toHaveLength(1)
+    expect(result[0].name).toBe('create_plan')
+  })
+
+  it('plan satisfied: returns all tools unchanged (pass-through)', () => {
+    const result = resolvePlanModeToolSpecs(ALL_TOOLS, {
+      planTurnActive: true,
+      createPlanSatisfied: true,
+      stepIndex: 0,
+      readOnlyToolNames: READ_ONLY_TOOLS
+    })
+    expect(result).toBe(ALL_TOOLS)
+  })
+
+  it('not plan-active: returns all tools unchanged (pass-through)', () => {
+    const result = resolvePlanModeToolSpecs(ALL_TOOLS, {
+      planTurnActive: false,
+      createPlanSatisfied: false,
+      stepIndex: 0,
+      readOnlyToolNames: READ_ONLY_TOOLS
+    })
+    expect(result).toBe(ALL_TOOLS)
+  })
+
+  it('uses PLAN_READ_ONLY_TOOL_NAMES default when readOnlyToolNames omitted', () => {
+    const result = resolvePlanModeToolSpecs(ALL_TOOLS, {
+      planTurnActive: true,
+      createPlanSatisfied: false,
+      stepIndex: 0
+    })
+    const names = result.map((t) => t.name)
+    // Default set excludes bash
+    expect(names).not.toContain('bash')
+    expect(names).toContain('create_plan')
+    expect(names).toContain('read')
+  })
+
+  it('uses CREATE_PLAN_TOOL_NAME default when planToolName omitted', () => {
+    const result = resolvePlanModeToolSpecs(ALL_TOOLS, {
+      planTurnActive: true,
+      createPlanSatisfied: false,
+      stepIndex: 1
+    })
+    expect(result).toHaveLength(1)
+    expect(result[0].name).toBe('create_plan')
+  })
+
+  it('custom readOnlyToolNames and planToolName', () => {
+    const customTools: ModelToolSpec[] = [
+      spec('custom-read'),
+      spec('custom-plan'),
+      spec('write'),
+      spec('bash')
+    ]
+    const result = resolvePlanModeToolSpecs(customTools, {
+      planTurnActive: true,
+      createPlanSatisfied: false,
+      stepIndex: 0,
+      readOnlyToolNames: new Set(['custom-read']),
+      planToolName: 'custom-plan'
+    })
+    const names = result.map((t) => t.name)
+    expect(names).toContain('custom-read')
+    expect(names).toContain('custom-plan')
+    expect(names).not.toContain('write')
+    expect(names).not.toContain('bash')
+  })
+})
diff --git a/kun/src/loop/agent-loop.ts b/kun/src/loop/agent-loop.ts
index 394ee218..aa784f83 100644
--- a/kun/src/loop/agent-loop.ts
+++ b/kun/src/loop/agent-loop.ts
@@ -8,7 +8,7 @@ import type {
   ToolProviderKind
 } from '../ports/tool-host.js'
 import type { ModelCapabilityMetadata } from '../contracts/capabilities.js'
-import { DEFAULT_APPROVAL_POLICY } from '../contracts/policy.js'
+import { DEFAULT_APPROVAL_POLICY, DEFAULT_SANDBOX_MODE } from '../contracts/policy.js'
 import type { ThreadStore } from '../ports/thread-store.js'
 import type { SessionStore } from '../ports/session-store.js'
 import type { ApprovalGate } from '../ports/approval-gate.js'
@@ -17,6 +17,7 @@ import type { UsageService } from '../services/usage-service.js'
 import type { TurnService } from '../services/turn-service.js'
 import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js'
 import type { PipelineStage } from '../contracts/events.js'
+import type { RuntimeErrorSeverity } from '../contracts/errors.js'
 import type { IdGenerator } from '../ports/id-generator.js'
 import type { ImmutablePrefix } from '../cache/immutable-prefix.js'
 import { ContextCompactor } from './context-compactor.js'
@@ -50,6 +51,12 @@ import type { SkillRuntime } from '../skills/skill-runtime.js'
 import type { AttachmentContent, AttachmentStore } from '../attachments/attachment-store.js'
 import type { ModelInputAttachment, ModelTextAttachmentFallback } from '../ports/model-client.js'
 import type { MemoryStore } from '../memory/memory-store.js'
+import {
+  hasHooksForPhase,
+  runObserverHooks,
+  runUserPromptSubmitHooks,
+  type ResolvedHook
+} from '../hooks/hook-engine.js'
 import {
   applyTokenEconomyToRequest,
   normalizeTokenEconomyConfig,
@@ -57,7 +64,6 @@ import {
 } from './token-economy.js'
 import { applyRequestHistoryHygiene } from './request-history-hygiene.js'
 import { estimateModelRequestInputTokens } from './model-request-estimator.js'
-import { estimateDeepseekInputTokenCost } from '../adapters/model/deepseek-pricing.js'
 import {
   recentAutoRouterContext,
   resolveAutoModelRoute,
@@ -73,10 +79,26 @@ import { shellRuntimeInstruction } from '../adapters/tool/builtin-tool-utils.js'
 
 const PARALLEL_READ_ONLY_TOOL_NAMES = new Set(['read', 'grep', 'find', 'ls'])
 const MAX_PARALLEL_TOOL_CALLS = 3
+const MAX_TURN_MODEL_STEPS = 64
+const MAX_TOOL_CATALOG_SNAPSHOTS = 256
 const DEFAULT_COMPACTION_SUMMARY_TIMEOUT_MS = 15_000
 const DEFAULT_COMPACTION_SUMMARY_MAX_TOKENS = 1_200
 const DEFAULT_COMPACTION_SUMMARY_INPUT_MAX_BYTES = 96 * 1024
 
+type TurnFailure = {
+  error: string
+  code?: string
+  details?: unknown
+  severity?: RuntimeErrorSeverity
+}
+
+type ModelClientDiagnostics = {
+  provider?: string
+  providerBaseUrl?: string
+  endpointFormat?: string
+  configuredModel?: string
+}
+
 const PIPELINE_STAGE_LABELS: Record = {
   setup: 'Setup',
   pre_start: 'Pre-Start',
@@ -116,14 +138,55 @@ type ToolCatalogDrift =
  */
 export const PLAN_MODE_INSTRUCTION = [
   'You are in Plan mode.',
-  'Investigate the task first using read-only tools and commands: prefer `read`, `grep`, `find`, `ls`, and safe read-only shell commands appropriate for the host platform via `bash` to gather the facts you need.',
-  'Do NOT modify project files, apply edits, or run mutating commands in this mode.',
+  'Investigate the task first using read-only tools: prefer `read`, `grep`, `find`, and `ls` to gather the facts you need.',
+  'Do NOT modify project files, apply edits, run shell commands, or run mutating commands in this mode.',
   'When you understand the task well enough, call the `create_plan` tool to save a complete implementation plan as Markdown.',
   'Use `operation: "draft"` for the first plan, and `operation: "refine"` when revising an existing plan; you may call `create_plan` multiple times as the plan evolves.',
   'Write concrete, actionable steps (summary, implementation steps, tests, risks) rather than vague intentions.',
   'After saving, give the user a short summary of the plan and what to review.'
 ].join('\n')
 
+/** Read-only tools allowed during the investigation phase of a Plan-mode
+ * turn (step 0, before `create_plan` has been called). Matches the
+ * PLAN_MODE_INSTRUCTION guidance. `bash` is intentionally excluded —
+ * it can execute arbitrary commands and its policy is `on-request` which
+ * auto-approves under `approvalPolicy: auto`. */
+const PLAN_READ_ONLY_TOOL_NAMES = new Set([
+  'read',
+  'ls',
+  'find',
+  'grep',
+  'web_search',
+  'web_fetch'
+])
+
+/**
+ * Resolve the tool list for a Plan-mode turn step. Extracted as a pure
+ * function so the behaviour can be unit-tested without spinning up the
+ * full agent loop.
+ *
+ * - Not plan-active or plan already satisfied → pass through unchanged.
+ * - Step 0 (investigation): read-only tools + create_plan.
+ * - Step > 0 (must produce plan): only create_plan.
+ */
+export function resolvePlanModeToolSpecs(
+  toolSpecs: ModelToolSpec[],
+  options: {
+    planTurnActive: boolean
+    createPlanSatisfied: boolean
+    stepIndex: number
+    readOnlyToolNames?: ReadonlySet
+    planToolName?: string
+  }
+): ModelToolSpec[] {
+  if (!options.planTurnActive || options.createPlanSatisfied) return toolSpecs
+  const readOnly = options.readOnlyToolNames ?? PLAN_READ_ONLY_TOOL_NAMES
+  const planTool = options.planToolName ?? CREATE_PLAN_TOOL_NAME
+  return options.stepIndex === 0
+    ? toolSpecs.filter((tool) => tool.name === planTool || readOnly.has(tool.name))
+    : toolSpecs.filter((tool) => tool.name === planTool)
+}
+
 function goalContinuationInstruction(goal: ThreadGoal | undefined): string | null {
   if (!goal || goal.status !== 'active') return null
   const tokenBudget = goal.tokenBudget == null ? 'none' : String(goal.tokenBudget)
@@ -162,6 +225,63 @@ function goalContinuationInstruction(goal: ThreadGoal | undefined): string | nul
   ].join('\n')
 }
 
+const GOAL_NO_TOOL_REPEAT_SIMILARITY = 0.85
+const GOAL_NO_TOOL_REPEAT_MIN_LENGTH = 12
+const GOAL_NO_TOOL_REPEAT_MAX_RECOVERY_STEPS = 3
+
+function goalNoToolRecoveryInstruction(recoveryStep: number): string {
+  return [
+    'Goal continuation recovery:',
+    `- The active goal continuation has produced near-identical no-tool replies ${recoveryStep} time(s).`,
+    '- Do not repeat the same status update, promise, or summary again.',
+    `- If the objective is actually achieved, call ${UPDATE_GOAL_TOOL_NAME} with status "complete" after verifying the current state.`,
+    `- If the strict blocked audit is satisfied, call ${UPDATE_GOAL_TOOL_NAME} with status "blocked".`,
+    '- Otherwise, continue with new substantive work or call an available tool to make concrete progress.'
+  ].join('\n')
+}
+
+/**
+ * Goal continuation re-prompts the model whenever it stops without tool
+ * calls, which can spin forever on "I will do X next" filler that never
+ * acts. Exact-equality checks miss this: the filler usually varies in
+ * punctuation, casing, or word order between rounds, so the guard
+ * normalizes both texts and falls back to character-bigram similarity.
+ */
+function isRepeatedNoToolAssistantText(previous: string | undefined, current: string): boolean {
+  if (previous === undefined) return false
+  const a = normalizeNoToolAssistantText(previous)
+  const b = normalizeNoToolAssistantText(current)
+  if (a === b) return true
+  if (a.length < GOAL_NO_TOOL_REPEAT_MIN_LENGTH || b.length < GOAL_NO_TOOL_REPEAT_MIN_LENGTH) {
+    return false
+  }
+  return charBigramDiceSimilarity(a, b) >= GOAL_NO_TOOL_REPEAT_SIMILARITY
+}
+
+function normalizeNoToolAssistantText(text: string): string {
+  return text.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
+}
+
+function charBigramDiceSimilarity(a: string, b: string): number {
+  const bigramsA = charBigramCounts(a)
+  const bigramsB = charBigramCounts(b)
+  let shared = 0
+  for (const [bigram, countA] of bigramsA) {
+    const countB = bigramsB.get(bigram)
+    if (countB) shared += Math.min(countA, countB)
+  }
+  return (2 * shared) / (a.length - 1 + b.length - 1)
+}
+
+function charBigramCounts(text: string): Map {
+  const counts = new Map()
+  for (let index = 0; index < text.length - 1; index += 1) {
+    const bigram = text.slice(index, index + 2)
+    counts.set(bigram, (counts.get(bigram) ?? 0) + 1)
+  }
+  return counts
+}
+
 function todoContinuationInstruction(todos: ThreadTodoList | undefined): string | null {
   const items = todos?.items ?? []
   if (items.length === 0) return null
@@ -207,6 +327,19 @@ function latestUserMessageText(items: readonly TurnItem[], turnId: string): stri
   return ''
 }
 
+/**
+ * Injected when the turn runs without an interactive user (IM bridges,
+ * headless runs). The user-input tools are also withheld from the tool
+ * catalog; this line keeps the model from promising a GUI dialog that
+ * nobody can answer.
+ */
+function userInputUnavailableInstruction(): string {
+  return [
+    'Interactive user input is unavailable for this turn: the user is on a remote channel (IM) and cannot answer GUI prompts.',
+    'Do not ask for structured input or wait for confirmation. If information is missing, state your assumption and continue, or finish your reply with the question so the user can answer in their next message.'
+  ].join(' ')
+}
+
 function allowedToolNamesWithGuiStateTools(
   allowedToolNames: readonly string[] | undefined,
   activeGoal: boolean
@@ -249,6 +382,11 @@ export type AgentLoopOptions = {
   toolArgumentRepair?: {
     maxStringBytes?: number
   }
+  /**
+   * Lifecycle hooks (UserPromptSubmit, TurnStart, TurnEnd, PreCompact).
+   * Tool phases are handled by the tool host; the loop ignores them.
+   */
+  hooks?: readonly ResolvedHook[]
   /**
    * Optional fallback GUI plan context for embedders that run the loop
    * without persisted turn metadata. Normal serve mode reads GUI plan
@@ -288,6 +426,9 @@ export class AgentLoop {
   private readonly promptTokenPressure = new Map()
   private readonly toolStormBreakers = new Map()
   private readonly toolCatalogSnapshots = new Map()
+  private readonly lastNoToolTextByTurn = new Map()
+  private readonly goalNoToolRecoveryStepsByTurn = new Map()
+  private readonly turnFailures = new Map()
 
   constructor(opts: AgentLoopOptions) {
     this.opts = opts
@@ -309,6 +450,8 @@ export class AgentLoop {
       return 'aborted'
     }
     let goalTimer: GoalElapsedTimer | null = null
+    let finalStatus: 'completed' | 'failed' | 'aborted' | undefined
+    let finalError: string | undefined
     try {
       goalTimer = await this.startGoalElapsedTimer(threadId)
       await this.recordPipelineStage(threadId, turnId, 'setup')
@@ -316,10 +459,44 @@ export class AgentLoop {
         this.toolStormBreakers.set(turnId, new ToolStormBreaker(this.opts.toolStorm))
       }
       await this.recordPipelineStage(threadId, turnId, 'pre_start')
+      const denial = await this.runTurnStartLifecycleHooks(threadId, turnId)
+      if (denial) {
+        await this.opts.events.record({
+          kind: 'error',
+          threadId,
+          turnId,
+          message: denial,
+          code: 'hook_denied',
+          severity: 'error'
+        })
+        await this.opts.turns.applyItem(
+          threadId,
+          makeErrorItem({
+            id: this.opts.ids.next('item_error'),
+            turnId,
+            threadId,
+            message: denial,
+            code: 'hook_denied',
+            severity: 'error'
+          })
+        )
+        await this.opts.turns.finishTurn({ threadId, turnId, status: 'failed', error: denial })
+        finalStatus = 'failed'
+        finalError = denial
+        return 'failed'
+      }
       await this.drainSteering(threadId, turnId, signal)
       await this.recordPipelineStage(threadId, turnId, 'post_start')
       const status = await this.loop(threadId, turnId, signal)
-      await this.opts.turns.finishTurn({ threadId, turnId, status })
+      const failure = status === 'failed' ? this.turnFailures.get(turnId) : undefined
+      await this.opts.turns.finishTurn({
+        threadId,
+        turnId,
+        status,
+        ...(failure ?? {})
+      })
+      finalStatus = status
+      finalError = failure?.error
       return status
     } catch (error) {
       const raw = error instanceof Error ? error.message : String(error)
@@ -343,11 +520,101 @@ export class AgentLoop {
         stack ? `stack=${stack}` : ''
       ].filter(Boolean).join(' ')
       await this.failTurn(threadId, turnId, message)
+      finalStatus = 'failed'
+      finalError = message
       return 'failed'
     } finally {
       await this.finishGoalElapsedTimer(threadId, goalTimer)
       this.autoModelRoutes.delete(autoModelRouteKey(threadId, turnId))
       this.toolStormBreakers.delete(turnId)
+      this.lastNoToolTextByTurn.delete(turnId)
+      this.goalNoToolRecoveryStepsByTurn.delete(turnId)
+      this.turnFailures.delete(turnId)
+      await this.runTurnEndHooks(threadId, turnId, finalStatus ?? 'failed', finalError)
+    }
+  }
+
+  /**
+   * TurnStart (observe-only) then UserPromptSubmit hooks. Returns the
+   * denial message when a UserPromptSubmit hook blocks the turn.
+   * Accepted `additionalContext` is persisted as an extra user message
+   * so replays and the prompt cache see a stable history.
+   */
+  private async runTurnStartLifecycleHooks(threadId: string, turnId: string): Promise {
+    const hooks = this.opts.hooks
+    const hasStart = hasHooksForPhase(hooks, 'TurnStart')
+    const hasSubmit = hasHooksForPhase(hooks, 'UserPromptSubmit')
+    if (!hasStart && !hasSubmit) return undefined
+    const turn = await this.opts.turns.getTurn(threadId, turnId)
+    const thread = await this.opts.threadStore.get(threadId)
+    const payload = {
+      threadId,
+      turnId,
+      prompt: turn?.prompt ?? '',
+      ...(thread?.workspace ? { workspace: thread.workspace } : {})
+    }
+    if (hasStart) {
+      const started = await runObserverHooks(hooks, { phase: 'TurnStart', ...payload })
+      await this.recordHookWarnings(threadId, turnId, started.warnings)
+    }
+    if (!hasSubmit) return undefined
+    const submit = await runUserPromptSubmitHooks(hooks, payload)
+    await this.recordHookWarnings(threadId, turnId, submit.warnings)
+    if (submit.denied) return submit.denied
+    if (submit.additionalContext.length > 0) {
+      const now = this.opts.nowIso()
+      const item: TurnItem = {
+        id: this.opts.ids.next('item_hook'),
+        turnId,
+        threadId,
+        role: 'user',
+        status: 'completed',
+        createdAt: now,
+        finishedAt: now,
+        kind: 'user_message',
+        text: `\n${submit.additionalContext.join('\n\n')}\n`
+      }
+      await this.opts.turns.applyItem(threadId, item)
+    }
+    return undefined
+  }
+
+  /** Observe-only TurnEnd hooks; run after the turn is finalized and must never throw. */
+  private async runTurnEndHooks(
+    threadId: string,
+    turnId: string,
+    status: 'completed' | 'failed' | 'aborted',
+    error?: string
+  ): Promise {
+    if (!hasHooksForPhase(this.opts.hooks, 'TurnEnd')) return
+    try {
+      const outcome = await runObserverHooks(this.opts.hooks, {
+        phase: 'TurnEnd',
+        threadId,
+        turnId,
+        status,
+        ...(error ? { error } : {})
+      })
+      await this.recordHookWarnings(threadId, turnId, outcome.warnings)
+    } catch {
+      // Observe-only: a TurnEnd hook must never break turn cleanup.
+    }
+  }
+
+  private async recordHookWarnings(
+    threadId: string,
+    turnId: string,
+    warnings: readonly string[]
+  ): Promise {
+    for (const message of warnings) {
+      await this.opts.events.record({
+        kind: 'error',
+        threadId,
+        turnId,
+        message,
+        code: 'hook_warning',
+        severity: 'warning'
+      })
     }
   }
 
@@ -355,6 +622,27 @@ export class AgentLoop {
     await this.opts.turns.finishTurn({ threadId, turnId, status: 'failed', error: message })
   }
 
+  private rememberTurnFailure(turnId: string, failure: TurnFailure): void {
+    if (!failure.error.trim()) return
+    this.turnFailures.set(turnId, failure)
+  }
+
+  private modelClientDiagnostics(): ModelClientDiagnostics {
+    const client = this.opts.model as ModelClient & {
+      config?: {
+        baseUrl?: string
+        endpointFormat?: string
+        model?: string
+      }
+    }
+    return {
+      provider: client.provider,
+      ...(client.config?.baseUrl ? { providerBaseUrl: sanitizeProviderBaseUrl(client.config.baseUrl) } : {}),
+      ...(client.config?.endpointFormat ? { endpointFormat: client.config.endpointFormat } : {}),
+      ...(client.config?.model ? { configuredModel: client.config.model } : {})
+    }
+  }
+
   private nowMs(): number {
     return this.opts.nowMs?.() ?? Date.now()
   }
@@ -427,6 +715,30 @@ export class AgentLoop {
   ): Promise<'completed' | 'failed' | 'aborted'> {
     for (let step = 0; ; step += 1) {
       if (signal.aborted) return 'aborted'
+      if (step >= MAX_TURN_MODEL_STEPS) {
+        const message =
+          `Turn stopped after ${MAX_TURN_MODEL_STEPS} model steps without reaching a final response.`
+        await this.opts.events.record({
+          kind: 'error',
+          threadId,
+          turnId,
+          message,
+          code: 'turn_step_limit_exceeded',
+          severity: 'error'
+        })
+        await this.opts.turns.applyItem(
+          threadId,
+          makeErrorItem({
+            id: this.opts.ids.next('item_error'),
+            turnId,
+            threadId,
+            message,
+            code: 'turn_step_limit_exceeded',
+            severity: 'error'
+          })
+        )
+        return 'failed'
+      }
       await this.drainSteering(threadId, turnId, signal)
       const stepResult = await this.modelStep(threadId, turnId, signal, step)
       if (stepResult === 'stop') return 'completed'
@@ -455,9 +767,16 @@ export class AgentLoop {
     const budgetGate = await this.checkBudgetGate(thread, threadId, turnId)
     if (budgetGate === 'blocked') return 'stop'
     const loadedItems = await this.opts.sessionStore.loadItems(threadId)
-    const healed = healLoadedHistoryItems(loadedItems)
-    if (healed.changed) {
-      await this.opts.sessionStore.rewriteItems(threadId, healed.items)
+    // Heal (and possibly rewrite) on-disk history once per turn: within a
+    // turn the loop only appends well-formed items, and healing's deep
+    // change detection costs two full-history stringifies per call.
+    let historyItems: TurnItem[] = loadedItems
+    if (stepIndex === 0) {
+      const healed = healLoadedHistoryItems(loadedItems)
+      if (healed.changed) {
+        await this.opts.sessionStore.rewriteItems(threadId, healed.items)
+      }
+      historyItems = healed.items
     }
     await this.recordPipelineStage(
       threadId,
@@ -466,7 +785,7 @@ export class AgentLoop {
       prefixVolatilityStageDetails(detectVolatilePrefixContent(this.opts.prefix))
     )
     if (stepIndex > 0) {
-      const toolResultCount = healed.items.filter(
+      const toolResultCount = historyItems.filter(
         (item) => item.turnId === turnId && item.kind === 'tool_result'
       ).length
       await this.opts.events.record({
@@ -478,9 +797,10 @@ export class AgentLoop {
       })
     }
     const items = repairModelHistoryItems(
-      effectiveHistoryAfterLatestCompaction(healed.items)
+      effectiveHistoryAfterLatestCompaction(historyItems)
     )
     const approvalPolicy = normalizeApprovalPolicy(thread?.approvalPolicy)
+    const sandboxMode = normalizeSandboxMode(thread?.sandboxMode)
     // Per-turn mode overrides the thread mode so the GUI can toggle
     // Plan/agent (and run Build as agent) without recreating the thread.
     const effectiveMode = turn?.mode ?? thread?.mode
@@ -522,11 +842,20 @@ export class AgentLoop {
     const activeGoalInstruction = planTurnActive
       ? null
       : goalContinuationInstruction(thread?.goal)
-    const activeTodoInstruction = todoContinuationInstruction(thread?.todos)
+    const goalRecoveryInstruction = activeGoalInstruction
+      ? goalNoToolRecoveryInstruction(this.goalNoToolRecoveryStepsByTurn.get(turnId) ?? 0)
+      : null
+    const activeTodoInstruction = planTurnActive
+      ? null
+      : todoContinuationInstruction(thread?.todos)
     const allowedToolNames = allowedToolNamesWithGuiStateTools(
       skillResolution.allowedToolNames,
       activeGoalInstruction !== null
     )
+    // IM/headless turns run without the user-input gate; the tools key
+    // their advertisement off `awaitUserInput`, so omitting it hides
+    // `user_input`/`request_user_input` and rejects stray calls.
+    const userInputDisabled = turn?.disableUserInput === true
     const toolContext: ToolHostContext = {
       threadId,
       turnId,
@@ -539,9 +868,12 @@ export class AgentLoop {
       delegationPolicy: { enabled: false },
       ...(allowedToolNames ? { allowedToolNames } : {}),
       approvalPolicy,
+      sandboxMode,
       abortSignal: signal,
       awaitApproval: async () => 'allow',
-      awaitUserInput: (input) => this.awaitUserInput(threadId, turnId, input, signal)
+      ...(userInputDisabled
+        ? {}
+        : { awaitUserInput: (input) => this.awaitUserInput(threadId, turnId, input, signal) })
     }
     const tools = await this.opts.toolHost.listTools(toolContext)
     const toolSpecs: ModelToolSpec[] = tools
@@ -556,6 +888,7 @@ export class AgentLoop {
       model: modelCapabilities.id,
       activeSkillIds: skillResolution.activeSkillIds,
       allowedToolNames,
+      userInputDisabled,
       fingerprint: toolCatalog.fingerprint,
       toolNames: toolCatalog.toolNames,
       toolHashes: toolCatalog.toolHashes
@@ -587,7 +920,7 @@ export class AgentLoop {
     if (toolCatalogDrift.kind === 'breaking') return 'stop'
     const toolKinds = new Map(toolSpecs.map((tool) => [tool.name, tool.toolKind]))
     const createPlanSatisfied = planTurnActive
-      ? hasSuccessfulCreatePlanResult(healed.items, turnId)
+      ? hasSuccessfulCreatePlanResult(historyItems, turnId)
       : false
     const requiredToolName =
       planTurnActive &&
@@ -595,10 +928,11 @@ export class AgentLoop {
       toolSpecs.some((tool) => tool.name === CREATE_PLAN_TOOL_NAME)
         ? CREATE_PLAN_TOOL_NAME
         : undefined
-    // Final step of a plan turn that still owes a plan. Offer ONLY create_plan
-    // (this DeepSeek-compatible provider ignores a forced tool_choice, so we
-    // remove the investigation tools instead) so the model can only save the
-    // plan or answer with plan text that the create_plan fallback materializes.
+    const effectiveToolSpecs = resolvePlanModeToolSpecs(toolSpecs, {
+      planTurnActive,
+      createPlanSatisfied,
+      stepIndex
+    })
     const history = await this.compactIfNeeded(items, model, signal, { threadId, turnId })
     if (signal.aborted) return 'aborted'
     await this.recordPipelineStage(threadId, turnId, 'input_compressed', {
@@ -606,10 +940,14 @@ export class AgentLoop {
     })
     const contextInstructions = [
       ...(activeGoalInstruction ? [activeGoalInstruction] : []),
+      ...(goalRecoveryInstruction && (this.goalNoToolRecoveryStepsByTurn.get(turnId) ?? 0) > 0
+        ? [goalRecoveryInstruction]
+        : []),
       ...(activeTodoInstruction ? [activeTodoInstruction] : []),
       ...memoryInstructions(memories),
       ...skillResolution.instructions,
-      ...(toolSpecs.some((tool) => tool.name === 'bash') ? [shellRuntimeInstruction()] : []),
+      ...(userInputDisabled ? [userInputUnavailableInstruction()] : []),
+      ...(effectiveToolSpecs.some((tool) => tool.name === 'bash') ? [shellRuntimeInstruction()] : []),
       ...(toolCatalogDriftMessage ? [toolCatalogDriftMessage] : [])
     ]
     await this.recordPipelineStage(threadId, turnId, 'input_remembered', {
@@ -628,7 +966,7 @@ export class AgentLoop {
       history,
       ...(attachments.imageAttachments.length ? { attachments: attachments.imageAttachments } : {}),
       ...(attachments.textFallbacks.length ? { attachmentTextFallbacks: attachments.textFallbacks } : {}),
-      tools: toolSpecs,
+      tools: effectiveToolSpecs,
       ...(requiredToolName ? { requiredToolName } : {}),
       ...(modelRoute.reasoningEffort ? { reasoningEffort: modelRoute.reasoningEffort } : {}),
       abortSignal: signal
@@ -656,8 +994,10 @@ export class AgentLoop {
     let reasoningItemId = ''
     const completedToolCalls: ToolCallLike[] = []
     let stopReason: 'stop' | 'tool_calls' | 'length' | 'error' = 'stop'
+    const modelClientDiagnostics = this.modelClientDiagnostics()
     await this.recordPipelineStage(threadId, turnId, 'pre_send', {
       model: request.model,
+      ...modelClientDiagnostics,
       historyItems: request.history.length,
       toolCount: request.tools.length,
       ...(request.requiredToolName ? { requiredToolName: request.requiredToolName } : {}),
@@ -669,7 +1009,8 @@ export class AgentLoop {
       })
     })
     await this.recordPipelineStage(threadId, turnId, 'post_send', {
-      model: request.model
+      model: request.model,
+      ...modelClientDiagnostics
     })
     for await (const chunk of this.opts.model.stream(request)) {
       if (signal.aborted) return 'aborted'
@@ -767,15 +1108,21 @@ export class AgentLoop {
           break
         }
         case 'completed':
-          stopReason = chunk.stopReason
+          if (stopReason !== 'error') stopReason = chunk.stopReason
           break
         case 'error':
+          this.rememberTurnFailure(turnId, {
+            error: chunk.message,
+            ...(chunk.code ? { code: chunk.code } : {}),
+            severity: 'error'
+          })
           await this.opts.events.record({
             kind: 'error',
             threadId,
             turnId,
             message: chunk.message,
-            code: chunk.code
+            code: chunk.code,
+            severity: 'error'
           })
           stopReason = 'error'
           break
@@ -822,7 +1169,7 @@ export class AgentLoop {
           const provider = toolProviderMetadata.get(CREATE_PLAN_TOOL_NAME)
           const toolKind = toolKinds.get(CREATE_PLAN_TOOL_NAME)
           const sourceRequest = activePlanContext?.sourceRequest ||
-            latestUserMessageText(healed.items, turnId) ||
+            latestUserMessageText(historyItems, turnId) ||
             turn?.prompt ||
             ''
           const argumentsForFallback: Record = activePlanContext
@@ -881,9 +1228,11 @@ export class AgentLoop {
             allowedToolNames,
             toolProviderKinds: new Map(tools.map((tool) => [tool.name, tool.providerKind])),
             approvalPolicy,
+            sandboxMode,
             signal
           })
           if (dispatched === 'aborted') return 'aborted'
+          if (dispatched === 'all_suppressed') return 'stop'
           return 'continue'
         }
         const message = `Model did not call the required \`${request.requiredToolName}\` tool for this GUI plan turn.`
@@ -906,9 +1255,50 @@ export class AgentLoop {
         )
         return 'failed'
       }
-      if (stopReason === 'stop' && activeGoalInstruction) return 'continue'
+      if (stopReason === 'stop' && activeGoalInstruction) {
+        const previousText = this.lastNoToolTextByTurn.get(turnId)
+        if (isRepeatedNoToolAssistantText(previousText, textAccumulator.value)) {
+          const recoverySteps = (this.goalNoToolRecoveryStepsByTurn.get(turnId) ?? 0) + 1
+          if (recoverySteps <= GOAL_NO_TOOL_REPEAT_MAX_RECOVERY_STEPS) {
+            this.goalNoToolRecoveryStepsByTurn.set(turnId, recoverySteps)
+            this.lastNoToolTextByTurn.set(turnId, textAccumulator.value)
+            return 'continue'
+          }
+          const message =
+            'Goal continuation stopped: the model kept repeating near-identical replies without calling tools or updating the goal.'
+          await this.opts.turns.applyItem(
+            threadId,
+            makeErrorItem({
+              id: this.opts.ids.next('item_error'),
+              turnId,
+              threadId,
+              message,
+              code: 'goal_repetition_stop',
+              severity: 'warning'
+            })
+          )
+          await this.opts.events.record({
+            kind: 'error',
+            threadId,
+            turnId,
+            message,
+            code: 'goal_repetition_stop',
+            severity: 'warning'
+          })
+          this.lastNoToolTextByTurn.delete(turnId)
+          this.goalNoToolRecoveryStepsByTurn.delete(turnId)
+          return 'stop'
+        }
+        this.goalNoToolRecoveryStepsByTurn.delete(turnId)
+        this.lastNoToolTextByTurn.set(turnId, textAccumulator.value)
+        return 'continue'
+      }
       return 'stop'
     }
+    // Tool calls mean the turn is making progress again; reset the no-tool
+    // repetition window so unrelated later status texts are not compared.
+    this.lastNoToolTextByTurn.delete(turnId)
+    this.goalNoToolRecoveryStepsByTurn.delete(turnId)
     const dispatched = await this.dispatchToolCalls({
       calls: completedToolCalls,
       threadId,
@@ -919,11 +1309,14 @@ export class AgentLoop {
       modelCapabilities,
       activeSkillIds: skillResolution.activeSkillIds,
       allowedToolNames,
+      userInputDisabled,
       toolProviderKinds: new Map(tools.map((tool) => [tool.name, tool.providerKind])),
       approvalPolicy,
+      sandboxMode,
       signal
     })
     if (dispatched === 'aborted') return 'aborted'
+    if (dispatched === 'all_suppressed') return 'stop'
     return 'continue'
   }
 
@@ -937,12 +1330,15 @@ export class AgentLoop {
     modelCapabilities: ModelCapabilityMetadata
     activeSkillIds: readonly string[]
     allowedToolNames?: readonly string[]
+    userInputDisabled?: boolean
     toolProviderKinds: ReadonlyMap
     approvalPolicy: ToolHostContext['approvalPolicy']
+    sandboxMode: NonNullable
     signal: AbortSignal
-  }): Promise<'continue' | 'aborted'> {
+  }): Promise<'continue' | 'aborted' | 'all_suppressed'> {
     const context = this.createToolContext(input)
     let index = 0
+    let executedAny = false
 
     while (index < input.calls.length) {
       if (input.signal.aborted) return 'aborted'
@@ -963,12 +1359,13 @@ export class AgentLoop {
       }
 
       if (!this.isParallelSafeToolCall(call, input.approvalPolicy, input.toolProviderKinds)) {
-        const result = await this.executeToolCall({
+        const result = await this.executeToolCallSafely({
           threadId: input.threadId,
           turnId: input.turnId,
           call,
           context
         })
+        executedAny = true
         await this.persistToolCallResult(input.threadId, input.turnId, call, result)
         index += 1
         continue
@@ -996,7 +1393,7 @@ export class AgentLoop {
 
       const settled = await Promise.allSettled(
         batch.map((entry) =>
-          this.executeToolCall({
+          this.executeToolCallSafely({
             threadId: input.threadId,
             turnId: input.turnId,
             call: entry,
@@ -1004,6 +1401,7 @@ export class AgentLoop {
           })
         )
       )
+      executedAny = true
       for (let batchIndex = 0; batchIndex < batch.length; batchIndex += 1) {
         const result = settled[batchIndex]
         const batchCall = batch[batchIndex]
@@ -1022,7 +1420,7 @@ export class AgentLoop {
       }
     }
 
-    return 'continue'
+    return executedAny ? 'continue' : 'all_suppressed'
   }
 
   private isParallelSafeToolCall(
@@ -1045,7 +1443,9 @@ export class AgentLoop {
     modelCapabilities: ModelCapabilityMetadata
     activeSkillIds: readonly string[]
     allowedToolNames?: readonly string[]
+    userInputDisabled?: boolean
     approvalPolicy: ToolHostContext['approvalPolicy']
+    sandboxMode: NonNullable
     signal: AbortSignal
   }): ToolHostContext {
     return {
@@ -1060,6 +1460,7 @@ export class AgentLoop {
       delegationPolicy: { enabled: false },
       ...(input.allowedToolNames ? { allowedToolNames: input.allowedToolNames } : {}),
       approvalPolicy: input.approvalPolicy,
+      sandboxMode: input.sandboxMode,
       abortSignal: input.signal,
       awaitApproval: async (approval) => {
         await this.opts.events.record({
@@ -1069,12 +1470,18 @@ export class AgentLoop {
           approvalId: approval.id,
           toolName: approval.toolName,
           status: 'pending',
+          approvalPolicy: input.approvalPolicy,
+          sandboxMode: input.sandboxMode,
           summary: approval.summary
         })
         return this.opts.approvalGate.request(approval)
       },
-      awaitUserInput: (inputRequest) =>
-        this.awaitUserInput(input.threadId, input.turnId, inputRequest, input.signal)
+      ...(input.userInputDisabled
+        ? {}
+        : {
+            awaitUserInput: (inputRequest) =>
+              this.awaitUserInput(input.threadId, input.turnId, inputRequest, input.signal)
+          })
     }
   }
 
@@ -1138,6 +1545,51 @@ export class AgentLoop {
     )
   }
 
+  /**
+   * A crashing tool handler must surface as an error tool_result the
+   * model can react to, not kill the whole turn. Only turn aborts are
+   * allowed to propagate.
+   */
+  private async executeToolCallSafely(input: {
+    threadId: string
+    turnId: string
+    call: ToolCallLike
+    context: ToolHostContext
+  }): Promise {
+    try {
+      return await this.executeToolCall(input)
+    } catch (error) {
+      if (input.context.abortSignal.aborted) throw error
+      const message = error instanceof Error ? error.message : String(error)
+      await this.opts.events.record({
+        kind: 'error',
+        threadId: input.threadId,
+        turnId: input.turnId,
+        message: `Tool call ${input.call.toolName} failed: ${message}`,
+        code: 'tool_execution_failed',
+        severity: 'warning'
+      })
+      return {
+        item: makeToolResultItem({
+          id: `item_${input.call.callId}`,
+          turnId: input.turnId,
+          threadId: input.threadId,
+          callId: input.call.callId,
+          toolName: input.call.toolName,
+          toolKind: input.call.toolKind ?? 'tool_call',
+          output: {
+            code: 'tool_execution_failed',
+            error: message,
+            guidance:
+              'The tool crashed while executing. Adjust the arguments or take a different approach instead of retrying the identical call.'
+          },
+          isError: true
+        }),
+        approved: false
+      }
+    }
+  }
+
   private isRecoverableToolDispatchError(error: unknown): boolean {
     const message = error instanceof Error ? error.message : String(error)
     return (
@@ -1344,6 +1796,16 @@ export class AgentLoop {
     if (!plan) return items
     const threadId = context.threadId
     const turnId = context.turnId
+    if (hasHooksForPhase(this.opts.hooks, 'PreCompact')) {
+      const observed = await runObserverHooks(this.opts.hooks, {
+        phase: 'PreCompact',
+        threadId,
+        turnId,
+        reason: String(plan.reason),
+        mode: String(plan.mode)
+      })
+      await this.recordHookWarnings(threadId, turnId, observed.warnings)
+    }
     let result = this.opts.compactor.compact({
       threadId,
       turnId,
@@ -1520,14 +1982,8 @@ export class AgentLoop {
   }): Promise {
     const savedTokens = Math.max(0, Math.floor(input.rawInputTokens - input.sentInputTokens))
     if (savedTokens <= 0) return
-    const estimatedCost = estimateDeepseekInputTokenCost({
-      model: input.model,
-      inputTokens: savedTokens
-    })
     const usage = this.opts.usage.recordTokenEconomySavings(input.threadId, {
-      tokenEconomySavingsTokens: savedTokens,
-      ...(estimatedCost ? { tokenEconomySavingsUsd: estimatedCost.costUsd } : {}),
-      ...(estimatedCost ? { tokenEconomySavingsCny: estimatedCost.costCny } : {})
+      tokenEconomySavingsTokens: savedTokens
     })
     await this.opts.events.record({
       kind: 'usage',
@@ -1597,6 +2053,7 @@ export class AgentLoop {
     model: string
     activeSkillIds: readonly string[]
     allowedToolNames?: readonly string[]
+    userInputDisabled?: boolean
     fingerprint: string
     toolNames: string[]
     toolHashes: Record
@@ -1607,7 +2064,8 @@ export class AgentLoop {
       mode: input.mode,
       model: input.model,
       activeSkillIds: [...input.activeSkillIds].sort(),
-      allowedToolNames: input.allowedToolNames ? [...input.allowedToolNames].sort() : []
+      allowedToolNames: input.allowedToolNames ? [...input.allowedToolNames].sort() : [],
+      userInputDisabled: input.userInputDisabled === true
     })
     const current: ToolCatalogSnapshot = {
       fingerprint: input.fingerprint,
@@ -1615,7 +2073,12 @@ export class AgentLoop {
       toolHashes: input.toolHashes
     }
     const previous = this.toolCatalogSnapshots.get(key)
+    this.toolCatalogSnapshots.delete(key)
     this.toolCatalogSnapshots.set(key, current)
+    if (this.toolCatalogSnapshots.size > MAX_TOOL_CATALOG_SNAPSHOTS) {
+      const oldest = this.toolCatalogSnapshots.keys().next().value
+      if (oldest !== undefined) this.toolCatalogSnapshots.delete(oldest)
+    }
     if (!previous || previous.fingerprint === input.fingerprint) return { kind: 'none' }
     return isAdditiveToolCatalogChange(previous, current)
       ? { kind: 'additive', previous }
@@ -1810,6 +2273,7 @@ function buildTextAttachmentFallback(
       byteSize: fallback.byteSize,
       ...(fallback.width ? { width: fallback.width } : {}),
       ...(fallback.height ? { height: fallback.height } : {}),
+      ...(attachment.localFilePath ? { localFilePath: attachment.localFilePath } : {}),
       ...(fallback.wasCompressed !== undefined ? { wasCompressed: fallback.wasCompressed } : {})
     }
   }
@@ -1828,6 +2292,7 @@ function buildTextAttachmentFallback(
     byteSize: attachment.byteSize,
     ...(attachment.width ? { width: attachment.width } : {}),
     ...(attachment.height ? { height: attachment.height } : {}),
+    ...(attachment.localFilePath ? { localFilePath: attachment.localFilePath } : {}),
     wasCompressed: false
   }
 }
@@ -1868,6 +2333,7 @@ function normalizeApprovalPolicy(
   value: string | undefined
 ): ToolHostContext['approvalPolicy'] {
   switch (value) {
+    case 'on-request':
     case 'never':
     case 'auto':
     case 'suggest':
@@ -1878,6 +2344,20 @@ function normalizeApprovalPolicy(
   }
 }
 
+function normalizeSandboxMode(
+  value: string | undefined
+): NonNullable {
+  switch (value) {
+    case 'read-only':
+    case 'workspace-write':
+    case 'danger-full-access':
+    case 'external-sandbox':
+      return value
+    default:
+      return DEFAULT_SANDBOX_MODE
+  }
+}
+
 function isAdditiveToolCatalogChange(previous: ToolCatalogSnapshot, current: ToolCatalogSnapshot): boolean {
   let added = false
   for (const name of current.toolNames) {
@@ -1922,14 +2402,31 @@ function buildModelCompactionPrompt(input: {
     Math.max(1_024, input.maxBytes)
   )
   return [
-    'Summarize the following Kun conversation history for a context fold.',
-    'Preserve user goals, requirements, decisions, files touched, tool outcomes, errors, constraints, active/pinned skills, and unresolved next steps.',
-    'Do not invent facts. Do not include generic advice. Prefer concise bullets grouped by topic.',
+    'You are compacting a long agent conversation so work can continue past the context window.',
+    'Write a dense, factual handoff summary using EXACTLY the following section headers, in this order.',
+    'Keep every section; write "- (none)" when a section has no content. Use short bullets, not prose.',
+    'Do not invent facts, do not add generic advice, and preserve concrete identifiers verbatim',
+    '(file paths, function/variable names, commands, URLs, IDs, error messages).',
+    '',
+    '## Goal',
+    "- The user's overall objective and any explicit requirements or constraints.",
+    '## Completed',
+    '- Work already done and decisions made, with the concrete outcome of each.',
+    '## Key findings',
+    '- Important facts discovered (root causes, data values, API shapes) needed to continue.',
+    '## Files & locations',
+    '- Files created/edited/inspected and the relevant paths or line ranges.',
+    '## Tool & command results',
+    '- Notable tool/command outcomes, especially errors and their resolution status.',
+    '## Pending',
+    '- Unresolved next steps and anything explicitly requested but not yet done.',
+    '## Constraints & pins',
+    '- Durable rules, user preferences, and active/pinned skills that must survive.',
     '',
-    'Existing heuristic summary to cross-check:',
+    'Existing heuristic summary to cross-check (may be incomplete):',
     input.heuristicSummary.trim() || '(none)',
     '',
-    'History excerpt to fold:',
+    'Conversation history to fold:',
     transcript || '(empty)'
   ].join('\n')
 }
@@ -2014,6 +2511,19 @@ function normalizeRequestedReasoningEffort(effort: string | undefined): string |
   return normalized && normalized !== 'auto' ? normalized : undefined
 }
 
+function sanitizeProviderBaseUrl(baseUrl: string): string {
+  try {
+    const url = new URL(baseUrl)
+    url.username = ''
+    url.password = ''
+    url.search = ''
+    url.hash = ''
+    return url.toString().replace(/\/$/, '')
+  } catch {
+    return baseUrl.replace(/[?#].*$/, '').replace(/\/+$/, '')
+  }
+}
+
 function autoModelRouteKey(threadId: string, turnId: string): string {
   return `${threadId}:${turnId}`
 }
diff --git a/kun/src/loop/context-estimator.ts b/kun/src/loop/context-estimator.ts
index dd1d87fa..aa9147e8 100644
--- a/kun/src/loop/context-estimator.ts
+++ b/kun/src/loop/context-estimator.ts
@@ -1,28 +1,60 @@
 import type { TurnItem } from '../contracts/items.js'
 
 /**
- * Very small token estimator. The estimator prefers reported usage
- * when available, otherwise approximates one token per ~4 characters of
- * item text. The estimator is intentionally simple: the goal is to
- * trigger compaction at a reasonable threshold, not to model provider
- * tokenizers exactly.
+ * Token estimator for compaction decisions.
+ *
+ * The estimator prefers reported usage when available. When it must
+ * approximate from text, it counts CJK and other wide characters as
+ * roughly one token each and packs runs of ASCII at ~4 chars/token.
+ * This avoids the severe under-counting that a naive `length / 4`
+ * heuristic produces for Chinese/Japanese/Korean text, which is the
+ * dominant language for many users of this app. Accurate estimates are
+ * what let compaction trigger *before* the real context window is
+ * exceeded rather than after.
  */
 export class ContextEstimator {
   private readonly charsPerToken: number
 
   constructor(charsPerToken = 4) {
-    this.charsPerToken = charsPerToken
+    this.charsPerToken = Math.max(1, charsPerToken)
   }
 
   estimateItem(item: TurnItem): number {
     const text = this.collectText(item)
-    return Math.max(1, Math.ceil(text.length / this.charsPerToken))
+    return Math.max(1, this.estimateText(text))
   }
 
   estimateItems(items: TurnItem[]): number {
     return items.reduce((sum, item) => sum + this.estimateItem(item), 0)
   }
 
+  /**
+   * Estimate tokens for a raw string. ASCII bytes are packed at
+   * `charsPerToken` per token; non-ASCII characters (CJK, emoji, etc.)
+   * count as ~1 token each, except zero-width combining marks.
+   */
+  estimateText(text: string): number {
+    if (!text) return 0
+    let asciiRun = 0
+    let tokens = 0
+    const flushAscii = (): void => {
+      if (asciiRun > 0) {
+        tokens += Math.ceil(asciiRun / this.charsPerToken)
+        asciiRun = 0
+      }
+    }
+    for (const char of text) {
+      if (char.charCodeAt(0) <= 0x7f) {
+        asciiRun += 1
+        continue
+      }
+      flushAscii()
+      tokens += isCombiningMark(char) ? 0 : 1
+    }
+    flushAscii()
+    return tokens
+  }
+
   private collectText(item: TurnItem): string {
     switch (item.kind) {
       case 'user_message':
@@ -46,3 +78,7 @@ export class ContextEstimator {
     }
   }
 }
+
+function isCombiningMark(char: string): boolean {
+  return /[\u0300-\u036f\ufe00-\ufe0f]/u.test(char)
+}
diff --git a/kun/src/loop/model-context-profile.test.ts b/kun/src/loop/model-context-profile.test.ts
new file mode 100644
index 00000000..5b3bec81
--- /dev/null
+++ b/kun/src/loop/model-context-profile.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { contextThresholdsForModel } from './model-context-profile.js'
+
+describe('contextThresholdsForModel safety cap', () => {
+  it('caps soft/hard thresholds to 75%/85% of the context window', () => {
+    // A config-provided profile that sets thresholds dangerously close to
+    // the full window (98%/99%) must be clamped so compaction still has
+    // headroom to run before the real window is exceeded.
+    const profiles = [
+      {
+        canonicalModel: 'deepseek-v4-pro',
+        modelIds: ['deepseek-v4-pro'] as readonly string[],
+        contextWindowTokens: 1_000_000,
+        softThreshold: 980_000,
+        hardThreshold: 990_000,
+        inputModalities: ['text'] as const,
+        outputModalities: ['text'] as const,
+        supportsToolCalling: true,
+        messageParts: ['text'] as const
+      }
+    ]
+    const thresholds = contextThresholdsForModel('deepseek-v4-pro', undefined, profiles)
+    expect(thresholds.softThreshold).toBe(750_000)
+    expect(thresholds.hardThreshold).toBe(850_000)
+  })
+
+  it('leaves already-safe thresholds untouched', () => {
+    const profiles = [
+      {
+        canonicalModel: 'deepseek-v4-pro',
+        modelIds: ['deepseek-v4-pro'] as readonly string[],
+        contextWindowTokens: 1_000_000,
+        softThreshold: 500_000,
+        hardThreshold: 600_000,
+        inputModalities: ['text'] as const,
+        outputModalities: ['text'] as const,
+        supportsToolCalling: true,
+        messageParts: ['text'] as const
+      }
+    ]
+    const thresholds = contextThresholdsForModel('deepseek-v4-pro', undefined, profiles)
+    expect(thresholds.softThreshold).toBe(500_000)
+    expect(thresholds.hardThreshold).toBe(600_000)
+  })
+
+  it('returns the fallback when no profile matches', () => {
+    const fallback = { softThreshold: 1234, hardThreshold: 5678 }
+    const thresholds = contextThresholdsForModel('unknown-model', fallback, [])
+    expect(thresholds).toEqual(fallback)
+  })
+})
diff --git a/kun/src/loop/model-context-profile.ts b/kun/src/loop/model-context-profile.ts
index 4a52c7b5..fca6b54f 100644
--- a/kun/src/loop/model-context-profile.ts
+++ b/kun/src/loop/model-context-profile.ts
@@ -1,7 +1,8 @@
 import type {
   ModelCapabilityMetadata,
   ModelInputModality,
-  ModelMessagePartSupport
+  ModelMessagePartSupport,
+  ModelReasoningCapabilityMetadata
 } from '../contracts/capabilities.js'
 
 export type ModelContextThresholds = {
@@ -24,6 +25,7 @@ export type ModelContextProfile = ModelContextThresholds & {
   outputModalities: readonly ModelInputModality[]
   supportsToolCalling: boolean
   messageParts: readonly ModelMessagePartSupport[]
+  reasoning?: ModelReasoningCapabilityMetadata
 }
 
 export type ModelContextProfileConfig = {
@@ -42,6 +44,7 @@ export type ModelContextProfileConfig = {
   outputModalities?: readonly ModelInputModality[]
   supportsToolCalling?: boolean
   messageParts?: readonly ModelMessagePartSupport[]
+  reasoning?: ModelReasoningCapabilityMetadata
 }
 
 export type ModelConfig = {
@@ -68,13 +71,23 @@ export type ModelProfileConfigSource = {
 }
 
 export const DEFAULT_CONTEXT_THRESHOLDS: ModelContextThresholds = {
-  softThreshold: 16_000,
-  hardThreshold: 24_000
+  // Fallback for models without a registered profile. These assume a
+  // reasonably large window (>=128k). A custom endpoint with a small
+  // window (e.g. 32k) should register a profile with explicit thresholds,
+  // otherwise it may exceed its window before the first compaction.
+  softThreshold: 96_000,
+  hardThreshold: 120_000
 }
 
 const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS = 1_000_000
-const DEEPSEEK_V4_SOFT_THRESHOLD_RATIO = 0.98
-const DEEPSEEK_V4_HARD_THRESHOLD_RATIO = 0.99
+// Trigger compaction well before the real window is full. Compacting at
+// ~98% (the previous default) left no headroom: a single large tool
+// result could blow past the window before the next compaction ran,
+// which is what caused runaway context growth and dropped tool tables.
+// 0.75 / 0.85 mirrors the "compact before 100%" guidance used by mature
+// coding agents and leaves room for the post-compaction request to fit.
+const DEEPSEEK_V4_SOFT_THRESHOLD_RATIO = 0.75
+const DEEPSEEK_V4_HARD_THRESHOLD_RATIO = 0.85
 const DEFAULT_MODEL_INPUT_MODALITIES: readonly ModelInputModality[] = ['text']
 const DEFAULT_MODEL_OUTPUT_MODALITIES: readonly ModelInputModality[] = ['text']
 const DEFAULT_MODEL_MESSAGE_PARTS: readonly ModelMessagePartSupport[] = ['text']
@@ -107,9 +120,19 @@ export function contextThresholdsForModel(
 ): ModelContextThresholds {
   const profile = resolveModelContextProfile(model, profiles)
   if (!profile) return fallback
+  // Safety cap: never let thresholds exceed 75%/85% of the context
+  // window, even if a config-provided model profile sets them higher
+  // (e.g. 98%/99%). Compacting too late leaves no headroom and lets a
+  // single large turn blow past the real window, causing runaway growth.
+  const maxSoft = profile.contextWindowTokens
+    ? Math.floor(profile.contextWindowTokens * 0.75)
+    : profile.softThreshold
+  const maxHard = profile.contextWindowTokens
+    ? Math.floor(profile.contextWindowTokens * 0.85)
+    : profile.hardThreshold
   return {
-    softThreshold: profile.softThreshold,
-    hardThreshold: profile.hardThreshold
+    softThreshold: Math.min(profile.softThreshold, maxSoft),
+    hardThreshold: Math.min(profile.hardThreshold, maxHard)
   }
 }
 
@@ -124,7 +147,8 @@ export function modelCapabilitiesForModel(
     outputModalities: [...(profile?.outputModalities ?? DEFAULT_MODEL_OUTPUT_MODALITIES)],
     supportsToolCalling: profile?.supportsToolCalling ?? true,
     contextWindowTokens: profile?.contextWindowTokens,
-    messageParts: [...(profile?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS)]
+    messageParts: [...(profile?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS)],
+    ...(profile?.reasoning ? { reasoning: copyReasoningCapability(profile.reasoning) } : {})
   }
 }
 
@@ -162,7 +186,12 @@ function deepseekV4Profile(
     inputModalities: DEFAULT_MODEL_INPUT_MODALITIES,
     outputModalities: DEFAULT_MODEL_OUTPUT_MODALITIES,
     supportsToolCalling: true,
-    messageParts: DEFAULT_MODEL_MESSAGE_PARTS
+    messageParts: DEFAULT_MODEL_MESSAGE_PARTS,
+    reasoning: {
+      supportedEfforts: ['off', 'high', 'max'],
+      defaultEffort: 'max',
+      requestProtocol: 'deepseek-chat-completions'
+    }
   }
 }
 
@@ -202,6 +231,7 @@ function mergeModelContextProfile(
     ...(current?.modelIds ?? []),
     ...(input.aliases ?? [])
   ])
+  const reasoning = input.reasoning ?? current?.reasoning
   return {
     canonicalModel,
     modelIds,
@@ -211,7 +241,20 @@ function mergeModelContextProfile(
     inputModalities: uniqueModelCapabilityValues(input.inputModalities ?? current?.inputModalities ?? DEFAULT_MODEL_INPUT_MODALITIES),
     outputModalities: uniqueModelCapabilityValues(input.outputModalities ?? current?.outputModalities ?? DEFAULT_MODEL_OUTPUT_MODALITIES),
     supportsToolCalling: input.supportsToolCalling ?? current?.supportsToolCalling ?? true,
-    messageParts: uniqueModelCapabilityValues(input.messageParts ?? current?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS)
+    messageParts: uniqueModelCapabilityValues(input.messageParts ?? current?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS),
+    ...(reasoning
+      ? { reasoning: copyReasoningCapability(reasoning) }
+      : {})
+  }
+}
+
+function copyReasoningCapability(
+  reasoning: ModelReasoningCapabilityMetadata
+): ModelReasoningCapabilityMetadata {
+  return {
+    supportedEfforts: [...reasoning.supportedEfforts],
+    defaultEffort: reasoning.defaultEffort,
+    requestProtocol: reasoning.requestProtocol
   }
 }
 
diff --git a/kun/src/loop/request-history-hygiene.test.ts b/kun/src/loop/request-history-hygiene.test.ts
new file mode 100644
index 00000000..a7be3353
--- /dev/null
+++ b/kun/src/loop/request-history-hygiene.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import type { TurnItem } from '../contracts/items.js'
+import { applyRequestHistoryHygiene } from './request-history-hygiene.js'
+
+function toolResult(id: string, output: string): TurnItem {
+  return {
+    id: `item_${id}`,
+    turnId: 'turn_1',
+    threadId: 'thread_1',
+    role: 'tool',
+    status: 'completed',
+    createdAt: '2026-01-01T00:00:00.000Z',
+    kind: 'tool_result',
+    toolName: 'read',
+    callId: id,
+    toolKind: 'tool_call',
+    output,
+    isError: false
+  } as TurnItem
+}
+
+describe('applyRequestHistoryHygiene cumulative tool-result budget', () => {
+  it('collapses older tool results once the cumulative budget is exhausted', () => {
+    // Each result is ~250 ASCII tokens (1000 chars / 4). With a 600-token
+    // budget and keepRecent=1, only the most recent couple should survive
+    // verbatim; older ones become a one-line digest.
+    const big = 'x'.repeat(1000)
+    const items = [
+      toolResult('a', big),
+      toolResult('b', big),
+      toolResult('c', big),
+      toolResult('d', big)
+    ]
+    const out = applyRequestHistoryHygiene(items, {
+      maxCumulativeToolResultTokens: 600,
+      keepRecentToolResults: 1,
+      // Keep per-result limits high so only the cumulative pass acts here.
+      maxToolResultTokens: 100_000,
+      maxToolResultBytes: 10_000_000,
+      maxToolResultLines: 100_000
+    })
+    const outputs = out.map((item) => (item.kind === 'tool_result' ? String(item.output) : ''))
+    // Newest (d) is always kept verbatim.
+    expect(outputs[3]).toBe(big)
+    // Oldest (a) must be collapsed to a digest marker.
+    expect(outputs[0]).toContain('cache hygiene')
+    expect(outputs[0]).not.toBe(big)
+  })
+
+  it('keeps everything when under budget', () => {
+    const small = 'hello world'
+    const items = [toolResult('a', small), toolResult('b', small)]
+    const out = applyRequestHistoryHygiene(items, {
+      maxCumulativeToolResultTokens: 100_000,
+      keepRecentToolResults: 4
+    })
+    expect(out).toBe(items)
+  })
+
+  it('does nothing when no cumulative cap is configured', () => {
+    const big = 'y'.repeat(5000)
+    const items = [toolResult('a', big), toolResult('b', big)]
+    const out = applyRequestHistoryHygiene(items, {
+      maxCumulativeToolResultTokens: 0,
+      maxToolResultTokens: 100_000,
+      maxToolResultBytes: 10_000_000,
+      maxToolResultLines: 100_000
+    })
+    expect(out).toBe(items)
+  })
+})
diff --git a/kun/src/loop/request-history-hygiene.ts b/kun/src/loop/request-history-hygiene.ts
index 42a3e89f..de904426 100644
--- a/kun/src/loop/request-history-hygiene.ts
+++ b/kun/src/loop/request-history-hygiene.ts
@@ -7,6 +7,21 @@ export type RequestHistoryHygieneOptions = {
   maxToolArgumentStringBytes?: number
   maxToolArgumentStringTokens?: number
   maxArrayItems?: number
+  /**
+   * Cumulative token budget for ALL tool results combined across the
+   * sent history. Tool results are kept in full from newest to oldest
+   * until this budget is consumed; older results beyond the budget are
+   * collapsed to a one-line digest. This bounds total context growth
+   * regardless of how many tool calls a long session accumulates, which
+   * is what prevents runaway growth (e.g. a session ballooning to
+   * millions of tokens of stale tool output).
+   */
+  maxCumulativeToolResultTokens?: number
+  /**
+   * Number of most-recent tool results that are always kept at full
+   * per-result fidelity, even if the cumulative budget is exhausted.
+   */
+  keepRecentToolResults?: number
 }
 
 const DEFAULT_MAX_TOOL_RESULT_LINES = 320
@@ -15,6 +30,10 @@ const DEFAULT_MAX_TOOL_RESULT_TOKENS = 8_000
 const DEFAULT_MAX_TOOL_ARGUMENT_STRING_BYTES = 8 * 1024
 const DEFAULT_MAX_TOOL_ARGUMENT_STRING_TOKENS = 2_000
 const DEFAULT_MAX_ARRAY_ITEMS = 80
+// 0 means "no cumulative cap" (back-compat). A positive value bounds the
+// combined size of all tool results in the sent history.
+const DEFAULT_MAX_CUMULATIVE_TOOL_RESULT_TOKENS = 0
+const DEFAULT_KEEP_RECENT_TOOL_RESULTS = 4
 const MAX_SIGNAL_LINES = 48
 const MAX_LINE_CHARS = 280
 const LONG_ARGUMENT_PREVIEW_CHARS = 160
@@ -69,7 +88,87 @@ export function applyRequestHistoryHygiene(
     }
     return item
   })
-  return changed ? next : items
+  const budgeted = applyCumulativeToolResultBudget(next, limits)
+  if (budgeted !== next) changed = true
+  return changed ? budgeted : items
+}
+
+/**
+ * Enforce a combined token budget across all tool results in the sent
+ * history. The most recent `keepRecentToolResults` results are always
+ * kept verbatim; remaining results are kept newest-first until the
+ * cumulative budget is exhausted, after which older results are
+ * collapsed to a single-line digest. This bounds total context growth
+ * no matter how many tool calls accumulate over a long session.
+ */
+function applyCumulativeToolResultBudget(
+  items: TurnItem[],
+  limits: Required
+): TurnItem[] {
+  const budget = limits.maxCumulativeToolResultTokens
+  if (budget <= 0) return items
+
+  const toolResultIndexes: number[] = []
+  for (let index = 0; index < items.length; index += 1) {
+    if (items[index].kind === 'tool_result') toolResultIndexes.push(index)
+  }
+  if (toolResultIndexes.length === 0) return items
+
+  const alwaysKeep = new Set(toolResultIndexes.slice(-limits.keepRecentToolResults))
+  let used = 0
+  const collapse = new Set()
+  // Walk newest -> oldest so recent context is preserved first.
+  for (let cursor = toolResultIndexes.length - 1; cursor >= 0; cursor -= 1) {
+    const index = toolResultIndexes[cursor]
+    const item = items[index]
+    if (item.kind !== 'tool_result') continue
+    const cost = estimateTokens(stringifyOutput(item.output))
+    if (alwaysKeep.has(index)) {
+      used += cost
+      continue
+    }
+    if (used + cost <= budget) {
+      used += cost
+      continue
+    }
+    collapse.add(index)
+  }
+  if (collapse.size === 0) return items
+
+  return items.map((item, index) => {
+    if (!collapse.has(index) || item.kind !== 'tool_result') return item
+    return { ...item, output: digestStaleToolResult(item.toolName, item.isError, item.output) }
+  })
+}
+
+function digestStaleToolResult(toolName: string, isError: boolean | undefined, output: unknown): string {
+  const text = stringifyOutput(output)
+  const tokens = estimateTokens(text)
+  const firstLine = text
+    .split('\n')
+    .map((line) => line.trim())
+    .find((line) => line.length > 0) ?? ''
+  const preview = firstLine ? ` first line: ${clipInline(firstLine, 160)}` : ''
+  return (
+    `[cache hygiene: older ${toolName}${isError ? ' (error)' : ''} result elided to bound context, ` +
+    `approx ${tokens} token(s); re-run the tool or use narrower read/grep/bash ranges if needed.]${preview}`
+  )
+}
+
+function stringifyOutput(output: unknown): string {
+  if (typeof output === 'string') return output
+  if (output == null) return ''
+  try {
+    return JSON.stringify(output)
+  } catch {
+    return String(output)
+  }
+}
+
+function clipInline(text: string, max: number): string {
+  const compact = text.replace(/\s+/g, ' ').trim()
+  if (compact.length <= max) return compact
+  return `${compact.slice(0, Math.max(0, max - 3)).trim()}...`
 }
 
 function normalizeOptions(options: RequestHistoryHygieneOptions): Required {
@@ -81,7 +180,15 @@ function normalizeOptions(options: RequestHistoryHygieneOptions): Required
   /** Highest known per-thread `seq`. Returns 0 when no events have been recorded. */
   highestSeq(threadId: string): Promise
+  /**
+   * Optional indexed usage query. Implementations may return per-event
+   * usage deltas without replaying the full event log.
+   */
+  loadUsageRecords?(options?: { threadId?: string }): Promise
+  /** Optional indexed latest cumulative usage snapshot query. */
+  loadLatestUsageSnapshots?(options?: { threadIds?: string[] }): Promise
   /** Forget the per-thread in-memory state without touching disk. */
   resetMemory(): Promise
 }
diff --git a/kun/src/ports/tool-host.ts b/kun/src/ports/tool-host.ts
index 93818e89..d849a5c3 100644
--- a/kun/src/ports/tool-host.ts
+++ b/kun/src/ports/tool-host.ts
@@ -1,4 +1,4 @@
-import type { ApprovalPolicy } from '../contracts/policy.js'
+import type { ApprovalPolicy, SandboxMode } from '../contracts/policy.js'
 import type { ApprovalRequest } from '../domain/approval.js'
 import type { TurnItem } from '../contracts/items.js'
 import type { ModelCapabilityMetadata } from '../contracts/capabilities.js'
@@ -15,6 +15,9 @@ export type ToolProviderKind =
   | 'memory'
   | 'gui'
   | 'delegation'
+  | 'image'
+  | 'audio'
+  | 'video'
 
 export type ToolProviderPolicy = {
   id: string
@@ -80,6 +83,8 @@ export type ToolHostContext = {
   /** Optional tool-name allow-list. When set, other tools are not advertised or executed. */
   allowedToolNames?: readonly string[]
   approvalPolicy: ApprovalPolicy
+  /** Filesystem/command sandbox selected for this turn. Defaults at execution time for old callers. */
+  sandboxMode?: SandboxMode
   abortSignal: AbortSignal
   /** Resolves a pending approval with the user's decision. */
   awaitApproval: (approval: ApprovalRequest) => Promise<'allow' | 'deny'>
diff --git a/kun/src/prompt/kun-system-prompt.ts b/kun/src/prompt/kun-system-prompt.ts
index 838477b8..ea319d3d 100644
--- a/kun/src/prompt/kun-system-prompt.ts
+++ b/kun/src/prompt/kun-system-prompt.ts
@@ -1,10 +1,10 @@
 export const KUN_SYSTEM_PROMPT = [
-  'You are Kun, the GUI-native coding agent for DeepSeek-GUI.',
+  'You are Kun, the GUI-native agent inside the Kun desktop app.',
   '',
-  'This operating contract is intentionally stable. It is kept at the front of every Kun model request so DeepSeek prompt-cache can reuse the same prefix across Code, Write, Claw, plan, and tool continuations. Do not casually reorder, rewrite, or personalize this contract; runtime-specific and user-specific facts belong in later conversation turns or compacted history, not in this prefix.',
+  'This operating contract is intentionally stable. It is kept at the front of every Kun model request so provider prompt caches can reuse the same prefix across Code, Write, Claw, plan, and tool continuations. Do not casually reorder, rewrite, or personalize this contract; runtime-specific and user-specific facts belong in later conversation turns or compacted history, not in this prefix.',
   '',
   'Core identity:',
-  '- Work as a senior engineering collaborator inside the DeepSeek GUI application.',
+  '- Work as a senior engineering collaborator inside the Kun desktop application.',
   '- Preserve the user intent exactly, especially negative constraints such as do not, never, avoid, keep, remove, or preserve.',
   '- Prefer small, coherent changes that match the existing codebase over broad rewrites.',
   '- Read current state before acting. The workspace, persisted thread history, and GUI HTTP/SSE contract are authoritative.',
@@ -37,7 +37,7 @@ export const KUN_SYSTEM_PROMPT = [
   '- Mutable user content, file excerpts, tool results, timestamps, selected text, workspace status, and generated summaries must stay after the stable prefix.',
   '- Compaction should preserve objectives, constraints, decisions, touched files, unresolved tasks, and relevant tool results while keeping the front prefix unchanged.',
   '- When summarizing or resuming, keep the same agent system contract and tool shape whenever possible so the summary request can reuse bytes already cached by the main agent.',
-  '- Cache telemetry must use DeepSeek native prompt_cache_hit_tokens and prompt_cache_miss_tokens when present. Fallback fields are acceptable only when native fields are absent.',
+  '- Cache telemetry must use provider-native prompt_cache_hit_tokens and prompt_cache_miss_tokens when present. Fallback fields are acceptable only when native fields are absent.',
   '',
   'Response style:',
   '- Be clear, direct, and useful. Avoid performative filler.',
diff --git a/kun/src/server/routes/attachments.ts b/kun/src/server/routes/attachments.ts
index 21501366..d68ad6bf 100644
--- a/kun/src/server/routes/attachments.ts
+++ b/kun/src/server/routes/attachments.ts
@@ -18,6 +18,7 @@ export async function uploadAttachment(
       name: parsed.data.name,
       mimeType: parsed.data.mimeType,
       data: Buffer.from(parsed.data.dataBase64, 'base64'),
+      localFilePath: parsed.data.localFilePath,
       textFallback: parsed.data.textFallback,
       threadId: parsed.data.threadId,
       workspace: parsed.data.workspace
diff --git a/kun/src/server/routes/events.ts b/kun/src/server/routes/events.ts
index 2d54f71e..dbc4fc6b 100644
--- a/kun/src/server/routes/events.ts
+++ b/kun/src/server/routes/events.ts
@@ -12,13 +12,22 @@ const HEARTBEAT_INTERVAL_MS = 15_000
  * `since_seq`, then subscribes to the event bus to deliver live
  * updates. The stream closes when the request's `AbortSignal`
  * fires (the client disconnects) or the server stops publishing.
+ *
+ * Delivery is deduplicated per connection: an event whose seq is at or
+ * below the connection's high-water mark is dropped, so an event that
+ * lands in both the persisted backlog and the live subscription (the
+ * recorder persists before publishing) is delivered exactly once.
+ * Heartbeats reuse the high-water mark instead of allocating fresh
+ * seqs — after a runtime restart the in-memory seq counter starts
+ * over, and stamping heartbeats with those low seqs used to rewind
+ * client cursors, which made the next subscription replay the entire
+ * thread history into the live transcript.
  */
 export function buildEventStreamResponse(input: {
   request: Request
   threadId: string
   eventBus: EventBus
   sessionStore: SessionStore
-  allocateSeq: (threadId: string) => number
 }): Response {
   const url = new URL(input.request.url)
   const sinceSeqFromQuery = Number(url.searchParams.get('since_seq') ?? '0') || 0
@@ -46,14 +55,25 @@ export function buildEventStreamResponse(input: {
       }
       input.request.signal.addEventListener('abort', close)
       try {
-        const backlog = await input.sessionStore.loadEventsSince(input.threadId, sinceSeq)
-        for (const event of backlog) {
+        let lastDeliveredSeq = sinceSeq
+        const deliver = (event: RuntimeEvent): void => {
+          if (typeof event.seq === 'number') {
+            if (event.seq <= lastDeliveredSeq) return
+            lastDeliveredSeq = event.seq
+          }
           controller.enqueue(encoder.encode(encodeSseEvent(event)))
         }
+        const highestSeq = await input.sessionStore.highestSeq(input.threadId).catch(() => 0)
+        const backlog = sinceSeq >= highestSeq
+          ? []
+          : await input.sessionStore.loadEventsSince(input.threadId, sinceSeq)
+        for (const event of backlog) {
+          deliver(event)
+        }
         unsubscribe = input.eventBus.subscribe(input.threadId, (event: RuntimeEvent) => {
           if (closed) return
           try {
-            controller.enqueue(encoder.encode(encodeSseEvent(event)))
+            deliver(event)
           } catch {
             close()
           }
@@ -65,7 +85,7 @@ export function buildEventStreamResponse(input: {
               encoder.encode(
                 encodeSseEvent({
                   kind: 'heartbeat',
-                  seq: input.allocateSeq(input.threadId),
+                  seq: lastDeliveredSeq,
                   timestamp: new Date().toISOString(),
                   threadId: input.threadId
                 })
diff --git a/kun/src/server/routes/index.ts b/kun/src/server/routes/index.ts
index ec307941..a1d29cd3 100644
--- a/kun/src/server/routes/index.ts
+++ b/kun/src/server/routes/index.ts
@@ -222,8 +222,7 @@ export function buildRouter(runtime: ServerRuntime): Router {
       request,
       threadId: ctx.params.id,
       eventBus: runtime.eventBus,
-      sessionStore: runtime.sessionStore,
-      allocateSeq: runtime.allocateSeq
+      sessionStore: runtime.sessionStore
     })
   })
   router.add('POST', '/v1/approvals/:id', async (request, ctx) => {
diff --git a/kun/src/server/routes/server-runtime.ts b/kun/src/server/routes/server-runtime.ts
index 8e8b9ea9..f47302ac 100644
--- a/kun/src/server/routes/server-runtime.ts
+++ b/kun/src/server/routes/server-runtime.ts
@@ -13,6 +13,12 @@ import type { RuntimeInfoResponse } from '../../contracts/runtime-info.js'
 import type { McpServerDiagnostic } from '../../adapters/tool/mcp-tool-provider.js'
 import type { McpSearchRuntimeDiagnostic } from '../../adapters/tool/mcp-tool-search.js'
 import type { WebProviderDiagnostic } from '../../adapters/tool/web-tool-provider.js'
+import type { ImageGenDiagnostic } from '../../adapters/tool/image-gen-tool-provider.js'
+import type {
+  MusicGenDiagnostic,
+  SpeechGenDiagnostic,
+  VideoGenDiagnostic
+} from '../../adapters/tool/media-gen-tool-provider.js'
 import type { SkillRuntimeDiagnostics } from '../../skills/skill-runtime.js'
 import type { AttachmentDiagnostics } from '../../contracts/attachments.js'
 import type { AttachmentStore } from '../../attachments/attachment-store.js'
@@ -28,6 +34,10 @@ export type RuntimeToolDiagnostics = {
   skills: SkillRuntimeDiagnostics
   attachments: AttachmentDiagnostics
   memory: MemoryDiagnostics
+  imageGen?: ImageGenDiagnostic[]
+  speechGen?: SpeechGenDiagnostic[]
+  musicGen?: MusicGenDiagnostic[]
+  videoGen?: VideoGenDiagnostic[]
 }
 
 /**
diff --git a/kun/src/server/routes/threads.ts b/kun/src/server/routes/threads.ts
index 8bf5fe2a..4cf609a1 100644
--- a/kun/src/server/routes/threads.ts
+++ b/kun/src/server/routes/threads.ts
@@ -95,6 +95,7 @@ export async function getThread(
       sessionStore.highestSeq(threadId),
       sessionStore.loadItems(threadId)
     ])
+    sessionItems = await healSessionItemsForFinishedTurns(thread, sessionItems, sessionStore)
   }
   const hydratedThread = hydrateThreadItemsFromSession(thread, sessionItems)
   return jsonResponse({
@@ -103,6 +104,63 @@ export async function getThread(
   })
 }
 
+type FinishedTurnStatus = Extract
+
+async function healSessionItemsForFinishedTurns(
+  thread: ThreadRecord,
+  items: TurnItem[],
+  sessionStore: SessionStore
+): Promise {
+  if (items.length === 0 || thread.turns.length === 0) return items
+  const finishedByTurnId = new Map()
+  for (const turn of thread.turns) {
+    const status = finishedTurnStatus(turn.status)
+    if (!status) continue
+    finishedByTurnId.set(turn.id, { status, finishedAt: turn.finishedAt })
+  }
+  if (finishedByTurnId.size === 0) return items
+
+  const healedAt = new Date().toISOString()
+  const healedItems: TurnItem[] = []
+  const nextItems = items.map((item) => {
+    const finished = finishedByTurnId.get(item.turnId)
+    if (!finished) return item
+    const next = finalizeOpenSessionItem(item, finished.status, finished.finishedAt ?? healedAt)
+    if (next !== item) healedItems.push(next)
+    return next
+  })
+  if (healedItems.length === 0) return items
+
+  for (const item of healedItems) {
+    try {
+      await sessionStore.updateItem(thread.id, item.id, item)
+    } catch {
+      // Healing is best-effort; the response still uses the repaired view.
+    }
+  }
+  return nextItems
+}
+
+function finishedTurnStatus(status: Turn['status']): FinishedTurnStatus | null {
+  return status === 'completed' || status === 'failed' || status === 'aborted' ? status : null
+}
+
+function finalizeOpenSessionItem(
+  item: TurnItem,
+  status: FinishedTurnStatus,
+  finishedAt: string
+): TurnItem {
+  if (item.status !== 'pending' && item.status !== 'running') return item
+  if (item.kind === 'approval') {
+    return { ...item, status: 'expired', finishedAt }
+  }
+  if (item.kind === 'user_input') {
+    return { ...item, status: 'cancelled', finishedAt }
+  }
+  const itemStatus = status === 'completed' ? 'completed' : status
+  return { ...item, status: itemStatus, finishedAt } as TurnItem
+}
+
 function hydrateThreadItemsFromSession(thread: ThreadRecord, items: TurnItem[]): ThreadRecord {
   if (items.length === 0 || thread.turns.length === 0) return thread
   const itemsByTurn = new Map()
diff --git a/kun/src/server/routes/usage.ts b/kun/src/server/routes/usage.ts
index 12bb9c77..9bb3e182 100644
--- a/kun/src/server/routes/usage.ts
+++ b/kun/src/server/routes/usage.ts
@@ -13,9 +13,16 @@ import {
   type UsageSnapshot
 } from '../../contracts/usage.js'
 import type { UsageEvent } from '../../contracts/events.js'
+import type { ThreadRecord, ThreadSummary } from '../../contracts/threads.js'
 import type { ServerRuntime } from './server-runtime.js'
 import { jsonResponse, type JsonResponse } from '../response.js'
 
+type UsageThreadSource = {
+  id: string
+  thread?: ThreadRecord
+  summary?: ThreadSummary
+}
+
 /**
  * Usage endpoint response shape. The `total` field mirrors the
  * per-thread cumulative usage snapshot; `perThread` exposes a list
@@ -44,7 +51,9 @@ export async function usageJsonResponse(
   const query = queryRecord(request)
   const groupBy = stringParam(query, 'group_by') ?? 'runtime'
   if (groupBy === 'thread') {
-    return jsonResponse(buildThreadUsageResponse(await usageRecords(runtime)))
+    return jsonResponse(buildThreadUsageResponse(await usageRecords(runtime, {
+      threadId: stringParam(query, 'thread_id')
+    })))
   }
   if (groupBy === 'day') {
     try {
@@ -90,11 +99,83 @@ function stringParam(input: Record, key: string): string | unde
   return typeof value === 'string' && value.trim() ? value.trim() : undefined
 }
 
-async function usageRecords(runtime: ServerRuntime): Promise {
+async function usageRecords(
+  runtime: ServerRuntime,
+  options: { threadId?: string } = {}
+): Promise {
+  if (typeof runtime.sessionStore.loadUsageRecords === 'function') {
+    try {
+      const explicitThread = options.threadId
+        ? await runtime.threadService.get(options.threadId)
+        : null
+      if (options.threadId && !explicitThread) return []
+      const threadSummaries = options.threadId
+        ? []
+        : await runtime.threadService.list()
+      const allowedThreadIds = new Set(
+        options.threadId
+          ? [options.threadId]
+          : threadSummaries.map((thread) => thread.id)
+      )
+      const indexedRaw = await runtime.sessionStore.loadUsageRecords({ threadId: options.threadId })
+      const indexed = indexedRaw.filter((record) => allowedThreadIds.has(record.threadId))
+      const records: ThreadUsageRecord[] = indexed.map((record) => ({
+        threadId: record.threadId,
+        ...(record.model ? { model: record.model } : {}),
+        completedAt: record.completedAt,
+        usage: record.usage
+      }))
+      const latest = typeof runtime.sessionStore.loadLatestUsageSnapshots === 'function' && allowedThreadIds.size > 0
+        ? await runtime.sessionStore.loadLatestUsageSnapshots({
+            threadIds: [...allowedThreadIds]
+          })
+        : []
+      const latestByThread = new Map(latest.map((record) => [record.threadId, record.usage]))
+      const liveThreadIds = options.threadId
+        ? [options.threadId]
+        : threadSummaries.map((thread) => thread.id)
+      const summariesById = new Map(threadSummaries.map((thread) => [thread.id, thread]))
+      for (const threadId of liveThreadIds) {
+        const liveRemainder = diffUsage(
+          runtime.usageService.forThread(threadId),
+          latestByThread.get(threadId) ?? emptyUsageSnapshot()
+        )
+        if (!hasUsage(liveRemainder)) continue
+        const summary = summariesById.get(threadId)
+        const thread = explicitThread?.id === threadId
+          ? explicitThread
+          : summary
+            ? await runtime.threadService.get(threadId) ?? { ...summary, turns: [] }
+            : await runtime.threadService.get(threadId)
+        if (!thread) continue
+        records.push({
+          threadId,
+          model: usageRecordModel(thread, { turnId: thread.turns?.at(-1)?.id }),
+          completedAt: thread.updatedAt || runtime.nowIso(),
+          usage: liveRemainder
+        })
+      }
+      return records
+    } catch {
+      // Fall back to JSONL replay when the optional usage index is unavailable.
+    }
+  }
   const records: ThreadUsageRecord[] = []
-  const threadSummaries = await runtime.threadService.list()
-  for (const threadSummary of threadSummaries) {
-    const thread = await runtime.threadService.get(threadSummary.id) ?? { ...threadSummary, turns: [] }
+  const threadSummaries = options.threadId
+    ? []
+    : await runtime.threadService.list()
+  const explicitThread = options.threadId
+    ? await runtime.threadService.get(options.threadId)
+    : null
+  if (options.threadId && !explicitThread) return records
+  const sources: UsageThreadSource[] = explicitThread
+    ? [{ id: explicitThread.id, thread: explicitThread }]
+    : threadSummaries.map((thread) => ({ id: thread.id, summary: thread }))
+  for (const source of sources) {
+    const thread = source.thread
+      ?? await runtime.threadService.get(source.id)
+      ?? (source.summary ? { ...source.summary, turns: [] } : null)
+    if (!thread) continue
     let latestPersisted = emptyUsageSnapshot()
     const events = await runtime.sessionStore.loadEventsSince(thread.id, 0)
     const usageEvents = events
diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts
index 0653ac9c..f3530ef6 100644
--- a/kun/src/server/runtime-factory.ts
+++ b/kun/src/server/runtime-factory.ts
@@ -18,6 +18,12 @@ import { buildMcpToolProviders } from '../adapters/tool/mcp-tool-provider.js'
 import { buildMemoryToolProviders } from '../adapters/tool/memory-tool-provider.js'
 import { buildDelegationToolProviders } from '../adapters/tool/delegation-tool-provider.js'
 import { buildWebToolProviders } from '../adapters/tool/web-tool-provider.js'
+import { buildImageGenToolProviders } from '../adapters/tool/image-gen-tool-provider.js'
+import {
+  buildMusicGenToolProviders,
+  buildSpeechGenToolProviders,
+  buildVideoGenToolProviders
+} from '../adapters/tool/media-gen-tool-provider.js'
 import { LocalWorkspaceInspector } from '../adapters/workspace/local-workspace-inspector.js'
 import { createImmutablePrefix } from '../cache/immutable-prefix.js'
 import {
@@ -57,6 +63,7 @@ import {
   type ModelEndpointFormat
 } from '../contracts/model-endpoint-format.js'
 import { SkillRuntime } from '../skills/skill-runtime.js'
+import { resolveConfiguredHooks, type HooksConfig } from '../hooks/hook-config.js'
 import { FileMemoryStore } from '../memory/memory-store.js'
 import { DelegationRuntime, FileDelegationStore } from '../delegation/delegation-runtime.js'
 import { createChildAgentExecutor } from '../delegation/child-agent-executor.js'
@@ -81,6 +88,8 @@ export type KunServeRuntimeOptions = {
   runtime?: RuntimeTuningConfig
   storage?: StorageConfig
   capabilities?: KunCapabilitiesConfig
+  /** Command hooks from config.json; resolved and wired into tool hosts and the loop. */
+  hooks?: HooksConfig
   startedAt?: string
 }
 
@@ -139,16 +148,17 @@ export async function createKunServeRuntime(
     nowIso
   })
   const threadService = new ThreadService({ threadStore, sessionStore, events, ids, nowIso })
-  await seedUsageCarryover({ threadStore, sessionStore, usageService })
+  const modelProfiles = modelContextProfilesFromConfig({
+    contextCompaction: options.contextCompaction,
+    models: options.models
+  })
+  const modelCapabilities = (model: string) => modelCapabilitiesForModel(model, modelProfiles)
   const modelClient = new DeepseekCompatModelClient({
     baseUrl: options.baseUrl,
     apiKey: options.apiKey,
     endpointFormat: options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT,
-    model: options.model
-  })
-  const modelProfiles = modelContextProfilesFromConfig({
-    contextCompaction: options.contextCompaction,
-    models: options.models
+    model: options.model,
+    modelCapabilities
   })
   const reviewService = new ReviewService({
     threadStore,
@@ -156,15 +166,19 @@ export async function createKunServeRuntime(
     model: modelClient,
     defaultModel: options.model,
     nowIso,
-    modelCapabilities: (model) => modelCapabilitiesForModel(model, modelProfiles),
+    modelCapabilities,
     ...(options.models ? { models: options.models } : {}),
     ...(options.contextCompaction ? { contextCompaction: options.contextCompaction } : {}),
     ...(tokenEconomy ? { tokenEconomy } : {}),
     ...(options.runtime ? { runtime: options.runtime } : {})
   })
-  const mcpProviders = await buildMcpToolProviders(options.capabilities?.mcp)
+  // Independent I/O; all must still finish before the server listens.
+  const [mcpProviders, skillRuntime] = await Promise.all([
+    buildMcpToolProviders(options.capabilities?.mcp),
+    SkillRuntime.create(options.capabilities?.skills),
+    seedUsageCarryover({ threadStore, sessionStore, usageService })
+  ])
   const webProviders = buildWebToolProviders(options.capabilities?.web)
-  const skillRuntime = await SkillRuntime.create(options.capabilities?.skills)
   const attachmentStore = options.capabilities?.attachments.enabled
     ? new FileAttachmentStore({
         rootDir: join(options.dataDir, 'attachments'),
@@ -179,6 +193,13 @@ export async function createKunServeRuntime(
         nowIso
       })
     : undefined
+  const imageGenProviders = buildImageGenToolProviders(options.capabilities?.imageGen, {
+    attachmentStore,
+    nowIso
+  })
+  const speechGenProviders = buildSpeechGenToolProviders(options.capabilities?.speechGen, { nowIso })
+  const musicGenProviders = buildMusicGenToolProviders(options.capabilities?.musicGen, { nowIso })
+  const videoGenProviders = buildVideoGenToolProviders(options.capabilities?.videoGen, { nowIso })
   const baseToolProviders = [
     {
       id: 'builtin',
@@ -189,10 +210,19 @@ export async function createKunServeRuntime(
     },
     ...mcpProviders.providers,
     ...webProviders.providers,
-    ...buildMemoryToolProviders(memoryStore)
+    ...buildMemoryToolProviders(memoryStore),
+    ...imageGenProviders.providers,
+    ...speechGenProviders.providers,
+    ...musicGenProviders.providers,
+    ...videoGenProviders.providers
   ]
+  const resolvedHooks = resolveConfiguredHooks(options.hooks)
   const childRegistry = new CapabilityRegistry(baseToolProviders)
-  const childToolHost = new LocalToolHost({ registry: childRegistry, readTracker: true })
+  const childToolHost = new LocalToolHost({
+    registry: childRegistry,
+    readTracker: true,
+    ...(resolvedHooks.length ? { hooks: resolvedHooks } : {})
+  })
   const delegationRuntime = options.capabilities?.subagents.enabled
     ? new DelegationRuntime({
         config: options.capabilities.subagents,
@@ -208,7 +238,7 @@ export async function createKunServeRuntime(
           contextCompaction: options.contextCompaction,
           approvalPolicy: options.approvalPolicy,
           sandboxMode: options.sandboxMode,
-          modelCapabilities: (model) => modelCapabilitiesForModel(model, modelProfiles),
+          modelCapabilities,
           skillRuntime,
           tokenEconomy,
           ...(options.runtime ? { runtime: options.runtime } : {}),
@@ -222,7 +252,7 @@ export async function createKunServeRuntime(
     : undefined
   const capabilities = buildRuntimeCapabilityManifest({
     config: options.capabilities,
-    model: modelCapabilitiesForModel(options.model, modelProfiles),
+    model: modelCapabilities(options.model),
     mcp: {
       configuredServers: Object.keys(options.capabilities?.mcp.servers ?? {}).length,
       connectedServers: mcpProviders.connectedServers,
@@ -253,6 +283,22 @@ export async function createKunServeRuntime(
     },
     subagents: {
       available: Boolean(delegationRuntime)
+    },
+    imageGen: {
+      available: imageGenProviders.available,
+      reason: imageGenProviders.diagnostics.find((diagnostic) => diagnostic.reason)?.reason
+    },
+    speechGen: {
+      available: speechGenProviders.available,
+      reason: speechGenProviders.diagnostics.find((diagnostic) => diagnostic.reason)?.reason
+    },
+    musicGen: {
+      available: musicGenProviders.available,
+      reason: musicGenProviders.diagnostics.find((diagnostic) => diagnostic.reason)?.reason
+    },
+    videoGen: {
+      available: videoGenProviders.available,
+      reason: videoGenProviders.diagnostics.find((diagnostic) => diagnostic.reason)?.reason
     }
   })
   const registry = new CapabilityRegistry([
@@ -273,7 +319,11 @@ export async function createKunServeRuntime(
     },
     ...buildDelegationToolProviders(delegationRuntime)
   ])
-  const toolHost = new LocalToolHost({ registry, readTracker: true })
+  const toolHost = new LocalToolHost({
+    registry,
+    readTracker: true,
+    ...(resolvedHooks.length ? { hooks: resolvedHooks } : {})
+  })
   const loop = new AgentLoop({
     threadStore,
     sessionStore,
@@ -290,12 +340,13 @@ export async function createKunServeRuntime(
     prefix,
     ids,
     nowIso,
-    modelCapabilities: (model) => modelCapabilitiesForModel(model, modelProfiles),
+    modelCapabilities,
     skillRuntime,
     tokenEconomy,
     contextCompaction: options.contextCompaction,
     ...(options.runtime?.toolStorm ? { toolStorm: options.runtime.toolStorm } : {}),
     ...(options.runtime?.toolArgumentRepair ? { toolArgumentRepair: options.runtime.toolArgumentRepair } : {}),
+    ...(resolvedHooks.length ? { hooks: resolvedHooks } : {}),
     ...(attachmentStore ? { attachmentStore } : {}),
     ...(memoryStore ? { memoryStore } : {}),
     onPlanWritten: async ({ threadId, planId, relativePath, markdown }) => {
@@ -358,7 +409,11 @@ export async function createKunServeRuntime(
         : { enabled: false, rootDir: '', count: 0, totalBytes: 0 },
       memory: memoryStore
         ? await memoryStore.diagnostics()
-        : { enabled: false, rootDir: '', activeCount: 0, tombstoneCount: 0, lastInjectedIds: [] }
+        : { enabled: false, rootDir: '', activeCount: 0, tombstoneCount: 0, lastInjectedIds: [] },
+      imageGen: imageGenProviders.diagnostics,
+      speechGen: speechGenProviders.diagnostics,
+      musicGen: musicGenProviders.diagnostics,
+      videoGen: videoGenProviders.diagnostics
     }),
     skills: () => skillRuntime.diagnostics(),
     shutdown: async () => {
@@ -416,6 +471,17 @@ export async function seedUsageCarryover(input: {
   sessionStore: SessionStore
   usageService: UsageService
 }): Promise {
+  if (typeof input.sessionStore.loadLatestUsageSnapshots === 'function') {
+    try {
+      const latest = await input.sessionStore.loadLatestUsageSnapshots()
+      for (const record of latest) {
+        input.usageService.seedThread(record.threadId, record.usage)
+      }
+      return
+    } catch {
+      // Fall through to JSONL replay when the optional index is unavailable.
+    }
+  }
   const threadSummaries = await input.threadStore.list()
   await Promise.all(threadSummaries.map(async (thread) => {
     const events = await input.sessionStore.loadEventsSince(thread.id, 0)
@@ -438,6 +504,18 @@ export async function startKunServe(
     host: options.host,
     port: options.port
   })
+  // Background sweep after listen: settle turns orphaned by a crash so
+  // clients stop spinning on them, without delaying readiness.
+  void runtime.turnService
+    .reconcileOrphanedTurns()
+    .then((count) => {
+      if (count > 0) {
+        console.warn(`[kun] marked ${count} orphaned turn(s) as failed after restart`)
+      }
+    })
+    .catch((error) => {
+      console.warn('[kun] orphaned turn reconciliation failed:', error)
+    })
   return {
     ...server,
     runtime,
diff --git a/kun/src/services/runtime-event-recorder.ts b/kun/src/services/runtime-event-recorder.ts
index ff9223d2..926ae255 100644
--- a/kun/src/services/runtime-event-recorder.ts
+++ b/kun/src/services/runtime-event-recorder.ts
@@ -25,26 +25,57 @@ export type RuntimeEventRecorderOptions = {
  * Application-level event boundary.
  *
  * Services and loops produce semantic event drafts; this recorder
- * stamps ordering/time, validates the public contract, fans out to
- * live subscribers, and persists the same event for SSE replay.
+ * stamps ordering/time, validates the public contract, persists the
+ * event for SSE replay, and then fans out to live subscribers.
+ *
+ * Persist-before-publish is load-bearing: the SSE route replays the
+ * persisted log before relaying live bus events, so an event that is
+ * published first and persisted later can fall between a subscriber's
+ * backlog read and its bus subscription and be lost forever.
  */
 export class RuntimeEventRecorder {
   private readonly options: RuntimeEventRecorderOptions
+  private readonly lastIssuedSeq = new Map()
 
   constructor(options: RuntimeEventRecorderOptions) {
     this.options = options
   }
 
   async record(draft: RuntimeEventDraft): Promise {
-    const allocatedSeq = this.options.allocateSeq(draft.threadId)
-    const persistedSeq = await this.options.sessionStore.highestSeq(draft.threadId)
+    const seq = draft.seq ?? (await this.nextSeq(draft.threadId))
+    this.noteIssuedSeq(draft.threadId, seq)
     const event = RuntimeEventSchema.parse({
       ...draft,
-      seq: draft.seq ?? Math.max(allocatedSeq, persistedSeq + 1),
+      seq,
       timestamp: draft.timestamp ?? this.options.nowIso()
     })
-    this.options.eventBus.publish(event)
     await this.options.sessionStore.appendEvent(event.threadId, event)
+    this.options.eventBus.publish(event)
     return event
   }
+
+  /**
+   * Issues the next per-thread seq. The persisted high-water mark is
+   * read once per thread and cached; afterwards issuance is synchronous,
+   * so concurrent record() calls can no longer race the store read and
+   * stamp the same seq twice (which made since_seq replay skip events).
+   */
+  private async nextSeq(threadId: string): Promise {
+    let floor = this.lastIssuedSeq.get(threadId)
+    if (floor === undefined) {
+      const persisted = await this.options.sessionStore.highestSeq(threadId).catch(() => 0)
+      // A concurrent first record() may have populated the cache while
+      // we awaited the store; never move the floor backwards.
+      floor = Math.max(persisted, this.lastIssuedSeq.get(threadId) ?? 0)
+    }
+    const allocated = this.options.allocateSeq(threadId)
+    const seq = Math.max(allocated, floor + 1)
+    this.noteIssuedSeq(threadId, seq)
+    return seq
+  }
+
+  private noteIssuedSeq(threadId: string, seq: number): void {
+    const current = this.lastIssuedSeq.get(threadId) ?? 0
+    if (seq > current) this.lastIssuedSeq.set(threadId, seq)
+  }
 }
diff --git a/kun/src/services/turn-service.ts b/kun/src/services/turn-service.ts
index c5589c59..d3c6f75e 100644
--- a/kun/src/services/turn-service.ts
+++ b/kun/src/services/turn-service.ts
@@ -1,6 +1,7 @@
 import type { ThreadRecord, ThreadStatus } from '../contracts/threads.js'
 import type { CompactRequest, CompactResponse, StartTurnRequest, StartTurnResponse, Turn, TurnStatus } from '../contracts/turns.js'
 import type { TurnItem } from '../contracts/items.js'
+import type { RuntimeErrorSeverity } from '../contracts/errors.js'
 import type { SessionStore } from '../ports/session-store.js'
 import type { ThreadStore } from '../ports/thread-store.js'
 import type { IdGenerator } from '../ports/id-generator.js'
@@ -53,7 +54,8 @@ export class TurnService {
       reasoningEffort: input.request.reasoningEffort,
       attachmentIds: input.request.attachmentIds ?? [],
       guiPlan: input.request.guiPlan,
-      mode: input.request.mode
+      mode: input.request.mode,
+      disableUserInput: input.request.disableUserInput
     })
     const userItem = makeUserItem({
       id: `item_${turnId}_user`,
@@ -67,6 +69,12 @@ export class TurnService {
     await this.upsertThread(input.threadId, (current) => ({
       ...touchThread(current, this.deps.nowIso()),
       status: 'running',
+      ...(input.request.approvalPolicy !== undefined
+        ? { approvalPolicy: input.request.approvalPolicy }
+        : {}),
+      ...(input.request.sandboxMode !== undefined
+        ? { sandboxMode: input.request.sandboxMode }
+        : {}),
       turns: [...current.turns, startTurnRecord(appendTurnItem(turn, userItem))]
     }))
     await this.deps.sessionStore.appendItem(input.threadId, userItem)
@@ -116,6 +124,8 @@ export class TurnService {
     })
     if (input.discard) {
       await this.discardTurnItems(input.threadId, input.turnId)
+    } else {
+      await this.finalizePersistedOpenItems(input.threadId, input.turnId, 'aborted')
     }
     await this.upsertThread(input.threadId, (current) => {
       const turn = current.turns.find((t) => t.id === input.turnId)
@@ -202,10 +212,14 @@ export class TurnService {
     turnId: string
     status: Extract
     error?: string
+    code?: string
+    details?: unknown
+    severity?: RuntimeErrorSeverity
   }): Promise {
     this.inflightTurns.delete(input.turnId)
     this.deps.inflight.end(input.turnId)
     this.deps.steering.clear()
+    await this.finalizePersistedOpenItems(input.threadId, input.turnId, input.status)
     await this.upsertThread(input.threadId, (current) => {
       const next = current.turns.map((t) => {
         if (t.id !== input.turnId) return t
@@ -214,19 +228,29 @@ export class TurnService {
       })
       return { ...touchThread(current, this.deps.nowIso()), turns: next, status: 'idle' }
     })
+    const errorItem = input.error
+      ? makeErrorItem({
+          id: `item_${input.turnId}_error`,
+          turnId: input.turnId,
+          threadId: input.threadId,
+          message: input.error,
+          ...(input.code ? { code: input.code } : {}),
+          ...(input.details !== undefined ? { details: input.details } : {}),
+          ...(input.severity ? { severity: input.severity } : {})
+        })
+      : null
     await this.deps.events.record({
       kind: input.status === 'completed' ? 'turn_completed' : input.status === 'aborted' ? 'turn_aborted' : 'turn_failed',
       threadId: input.threadId,
       turnId: input.turnId,
-      ...(input.error ? { message: input.error } : {})
+      ...(errorItem ? { itemId: errorItem.id } : {}),
+      ...(input.error ? { message: input.error } : {}),
+      ...(input.code ? { code: input.code } : {}),
+      ...(input.details !== undefined ? { details: input.details } : {}),
+      ...(input.severity ? { severity: input.severity } : {})
     })
-    if (input.error) {
-      await this.appendItem(input.threadId, makeErrorItem({
-        id: `item_${input.turnId}_error`,
-        turnId: input.turnId,
-        threadId: input.threadId,
-        message: input.error
-      }))
+    if (errorItem) {
+      await this.appendItem(input.threadId, errorItem)
     }
   }
 
@@ -234,6 +258,39 @@ export class TurnService {
     return this.inflightTurns.get(turnId)?.signal
   }
 
+  /**
+   * Mark turns left 'queued'/'running' by a previous process as failed
+   * so clients stop waiting on them after a crash or restart. Turns
+   * owned by this process (inflight) are skipped, so the sweep is safe
+   * to run in the background after the server starts listening.
+   */
+  async reconcileOrphanedTurns(): Promise {
+    const summaries = await this.deps.threadStore.list()
+    let reconciled = 0
+    for (const summary of summaries) {
+      const thread = await this.deps.threadStore.get(summary.id).catch(() => null)
+      if (!thread) continue
+      for (const turn of thread.turns) {
+        if (turn.status !== 'running' && turn.status !== 'queued') continue
+        if (this.inflightTurns.has(turn.id)) continue
+        try {
+          await this.finishTurn({
+            threadId: thread.id,
+            turnId: turn.id,
+            status: 'failed',
+            error: 'Turn was interrupted by a runtime restart.',
+            code: 'orphaned_after_restart',
+            severity: 'warning'
+          })
+          reconciled += 1
+        } catch {
+          // Best-effort sweep; one unreadable thread must not stop the rest.
+        }
+      }
+    }
+    return reconciled
+  }
+
   async getTurn(threadId: string, turnId: string): Promise {
     const thread = await this.deps.threadStore.get(threadId)
     return thread?.turns.find((turn) => turn.id === turnId) ?? null
@@ -368,6 +425,21 @@ export class TurnService {
     )
   }
 
+  private async finalizePersistedOpenItems(
+    threadId: string,
+    turnId: string,
+    status: Extract
+  ): Promise {
+    const items = await this.deps.sessionStore.loadItems(threadId)
+    const finishedAt = this.deps.nowIso()
+    for (const item of items) {
+      if (item.turnId !== turnId) continue
+      const finalized = this.finalizeOpenItem(item, status, finishedAt)
+      if (finalized === item) continue
+      await this.updateItem(threadId, item.id, finalized)
+    }
+  }
+
   private keepUserItems(items: TurnItem[]): TurnItem[] {
     return items.filter((item) => item.kind === 'user_message')
   }
diff --git a/kun/src/shared/gui-plan.ts b/kun/src/shared/gui-plan.ts
index ba32204f..802dac69 100644
--- a/kun/src/shared/gui-plan.ts
+++ b/kun/src/shared/gui-plan.ts
@@ -1,6 +1,6 @@
 /**
  * Kun-side mirror of the shared GUI plan contract from
- * DeepSeek-GUI's `src/shared/gui-plan.ts`.
+ * Kun's `src/shared/gui-plan.ts`.
  *
  * The renderer and the Kun package live in the same repo but
  * TypeScript's `rootDir` constraint prevents Kun from
diff --git a/kun/tests/agent-loop-sandbox.test.ts b/kun/tests/agent-loop-sandbox.test.ts
new file mode 100644
index 00000000..6e183ee7
--- /dev/null
+++ b/kun/tests/agent-loop-sandbox.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import type { ModelRequest, ModelStreamChunk } from '../src/ports/model-client.js'
+import { bootstrapThread, makeHarness } from './loop-test-harness.js'
+
+describe('AgentLoop sandbox policy', () => {
+  it('uses the active turn sandbox when advertising tools to the model', async () => {
+    let observedRequest: ModelRequest | null = null
+    const h = makeHarness({
+      provider: 'sandbox-observer',
+      model: 'sandbox-observer',
+      async *stream(request: ModelRequest): AsyncIterable {
+        observedRequest = request
+        yield { kind: 'completed', stopReason: 'stop' }
+      }
+    })
+    await bootstrapThread(h, {
+      request: {
+        prompt: 'inspect only',
+        approvalPolicy: 'on-request',
+        sandboxMode: 'read-only'
+      }
+    })
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    expect(status).toBe('completed')
+    const request = observedRequest as ModelRequest | null
+    if (!request) throw new Error('expected model request')
+    const toolNames = request.tools.map((tool) => tool.name)
+    expect(toolNames).toEqual(expect.arrayContaining(['read', 'grep', 'find', 'ls']))
+    expect(toolNames).not.toContain('bash')
+    expect(toolNames).not.toContain('edit')
+    expect(toolNames).not.toContain('write')
+    expect(request.contextInstructions?.join('\n') ?? '').not.toContain('Shell runtime:')
+  })
+})
diff --git a/kun/tests/attachment-store.test.ts b/kun/tests/attachment-store.test.ts
index 3f096d3b..5f9ebf9c 100644
--- a/kun/tests/attachment-store.test.ts
+++ b/kun/tests/attachment-store.test.ts
@@ -33,6 +33,7 @@ describe('Attachment store and multimodal input', () => {
       name: 'shot.png',
       data,
       mimeType: 'image/png',
+      localFilePath: '/tmp/picked/shot.png',
       threadId: 'thr_1',
       workspace: '/tmp/ws'
     })
@@ -43,7 +44,13 @@ describe('Attachment store and multimodal input', () => {
     })
 
     expect(second.id).toBe(first.id)
-    expect(first).toMatchObject({ mimeType: 'image/png', width: 2, height: 3, byteSize: data.byteLength })
+    expect(first).toMatchObject({
+      mimeType: 'image/png',
+      width: 2,
+      height: 3,
+      byteSize: data.byteLength,
+      localFilePath: '/tmp/picked/shot.png'
+    })
     await expect(store.resolveContent(first.id, { threadId: 'thr_2' })).rejects.toThrow(/not authorized/)
     await expect(store.resolveContent(first.id, { workspace: '/tmp/ws' })).resolves.toMatchObject({ id: first.id })
   })
@@ -113,6 +120,7 @@ describe('Attachment store and multimodal input', () => {
           name: 'shot.png',
           mimeType: 'image/png',
           dataBase64: png(1, 1).toString('base64'),
+          localFilePath: '/tmp/picked/shot.png',
           threadId: 'thr_1',
           textFallback: {
             dataBase64: 'abcd',
@@ -137,6 +145,7 @@ describe('Attachment store and multimodal input', () => {
     expect(metadata.status).toBe(200)
     expect(await readJson(metadata)).toMatchObject({
       attachment: {
+        localFilePath: '/tmp/picked/shot.png',
         textFallback: {
           dataBase64: 'abcd',
           mimeType: 'image/png'
@@ -167,6 +176,7 @@ describe('Attachment store and multimodal input', () => {
     const attachment = await store.create({
       name: 'shot.png',
       data: png(1, 1),
+      localFilePath: '/tmp/picked/shot.png',
       threadId: 'thr_1',
       workspace: '/tmp/ws'
     })
@@ -210,6 +220,7 @@ describe('Attachment store and multimodal input', () => {
       id: attachment.id,
       mimeType: 'image/png',
       dataBase64: expect.any(String),
+      localFilePath: '/tmp/picked/shot.png',
       wasCompressed: false
     })
   })
@@ -219,6 +230,7 @@ describe('Attachment store and multimodal input', () => {
     const attachment = await store.create({
       name: 'shot.png',
       data: png(1, 1),
+      localFilePath: '/tmp/picked/shot.png',
       threadId: 'thr_1',
       workspace: '/tmp/ws'
     })
@@ -252,6 +264,7 @@ describe('Attachment store and multimodal input', () => {
       id: attachment.id,
       mimeType: 'image/png',
       dataBase64: expect.any(String),
+      localFilePath: '/tmp/picked/shot.png',
       wasCompressed: false
     })
     const preSend = (await h.sessionStore.loadEventsSince(h.threadId, 0))
@@ -392,6 +405,7 @@ describe('Attachment store and multimodal input', () => {
         byteSize: 3,
         width: 1280,
         height: 720,
+        localFilePath: '/tmp/picked/shot.png',
         wasCompressed: true
       }],
       tools: [],
@@ -402,6 +416,7 @@ describe('Attachment store and multimodal input', () => {
 
     expect(body?.messages?.[0]?.content).toContain('describe')
     expect(body?.messages?.[0]?.content).toContain('[Attached image as base64 text]')
+    expect(body?.messages?.[0]?.content).toContain('FilePath: /tmp/picked/shot.png')
     expect(body?.messages?.[0]?.content).toContain('MIME: image/webp')
     expect(body?.messages?.[0]?.content).toContain('Dimensions: 1280x720')
     expect(body?.messages?.[0]?.content).toContain('```base64\nYWJj\n```')
diff --git a/kun/tests/builtin-tools.test.ts b/kun/tests/builtin-tools.test.ts
index 4f56a5e2..cde77a95 100644
--- a/kun/tests/builtin-tools.test.ts
+++ b/kun/tests/builtin-tools.test.ts
@@ -57,14 +57,15 @@ import type { TurnItem } from '../src/contracts/items.js'
 import type { FsStats } from '../src/adapters/tool/builtin-tool-types.js'
 import type { ToolHostContext } from '../src/ports/tool-host.js'
 
-function buildContext(workspace: string): ToolHostContext {
+function buildContext(workspace: string, overrides: Partial = {}): ToolHostContext {
   return {
     threadId: 'thr_1',
     turnId: 'turn_1',
     workspace,
     approvalPolicy: 'on-request',
     abortSignal: new AbortController().signal,
-    awaitApproval: async () => 'allow'
+    awaitApproval: async () => 'allow',
+    ...overrides
   }
 }
 
@@ -108,8 +109,148 @@ describe('Kun built-in tools', () => {
     expect([...allBuiltinToolNames].every((name) => toolNames.has(name))).toBe(true)
   })
 
+  it('converts a throwing tool execute into an error tool result instead of failing the turn', async () => {
+    const explosive = LocalToolHost.defineTool({
+      name: 'explode',
+      description: 'always throws',
+      inputSchema: { type: 'object', properties: {} },
+      policy: 'auto',
+      execute: async () => {
+        throw new Error('MCP error -32603: Validation Error: Validation Failed')
+      }
+    })
+    const throwingHost = new LocalToolHost({ tools: [explosive] })
+
+    const result = await throwingHost.execute(
+      { callId: 'call_explode', toolName: 'explode', arguments: {} },
+      buildContext(workspace)
+    )
+
+    expect(result.item.kind).toBe('tool_result')
+    if (result.item.kind !== 'tool_result') throw new Error('expected tool_result')
+    expect(result.item.isError).toBe(true)
+    expect(result.item.output).toMatchObject({
+      code: 'tool_execution_failed',
+      error: expect.stringContaining('-32603')
+    })
+  })
+
+  it('still propagates aborts raised while a tool executes', async () => {
+    const abortController = new AbortController()
+    const abortingTool = LocalToolHost.defineTool({
+      name: 'abort_self',
+      description: 'aborts mid-flight',
+      inputSchema: { type: 'object', properties: {} },
+      policy: 'auto',
+      execute: async () => {
+        abortController.abort()
+        throw new Error('aborted mid tool')
+      }
+    })
+    const abortHost = new LocalToolHost({ tools: [abortingTool] })
+
+    await expect(
+      abortHost.execute(
+        { callId: 'call_abort', toolName: 'abort_self', arguments: {} },
+        buildContext(workspace, { abortSignal: abortController.signal })
+      )
+    ).rejects.toThrow('aborted mid tool')
+  })
+
+  it('hides mutating and shell tools in read-only sandbox mode', async () => {
+    const tools = await host.listTools(buildContext(workspace, { sandboxMode: 'read-only' }))
+    const names = tools.map((tool) => tool.name)
+
+    expect(names).toEqual(expect.arrayContaining(['read', 'grep', 'find', 'ls']))
+    expect(names).not.toContain('bash')
+    expect(names).not.toContain('edit')
+    expect(names).not.toContain('write')
+  })
+
+  it('allows file tools but hides host shell commands in workspace-write sandbox mode', async () => {
+    const tools = await host.listTools(buildContext(workspace, { sandboxMode: 'workspace-write' }))
+    const names = tools.map((tool) => tool.name)
+
+    expect(names).toEqual(expect.arrayContaining(['read', 'grep', 'find', 'ls', 'edit', 'write']))
+    expect(names).not.toContain('bash')
+  })
+
+  it('blocks direct file writes in read-only sandbox mode', async () => {
+    const result = await host.execute(
+      {
+        callId: 'call_write',
+        toolName: 'write',
+        arguments: { path: 'blocked.md', content: 'nope' }
+      },
+      buildContext(workspace, { sandboxMode: 'read-only' })
+    )
+
+    expect(result.approved).toBe(false)
+    expect(result.item).toMatchObject({
+      kind: 'tool_result',
+      toolName: 'write',
+      isError: true,
+      output: {
+        code: 'sandbox_read_only'
+      }
+    })
+    await expect(readFile(join(workspace, 'blocked.md'), 'utf8')).rejects.toThrow()
+  })
+
+  it('answers truncated tool arguments with actionable chunking guidance', async () => {
+    // tool-argument-repair wraps unparseable JSON (usually cut off by the
+    // model output limit mid-payload) as { __raw }.
+    const truncated = '{"content": "cut off mid stri'
+    const writeResult = await host.execute(
+      {
+        callId: 'call_write_raw',
+        toolName: 'write',
+        arguments: { __raw: truncated }
+      },
+      buildContext(workspace)
+    )
+    expect(writeResult.item).toMatchObject({ kind: 'tool_result', isError: true })
+    const writeError = String((writeResult.item as { output?: { error?: string } }).output?.error)
+    expect(writeError).toContain('truncated')
+    expect(writeError).toContain('smaller')
+
+    const editResult = await host.execute(
+      {
+        callId: 'call_edit_raw',
+        toolName: 'edit',
+        arguments: { __raw: truncated }
+      },
+      buildContext(workspace)
+    )
+    expect(editResult.item).toMatchObject({ kind: 'tool_result', isError: true })
+    expect(String((editResult.item as { output?: { error?: string } }).output?.error)).toContain('truncated')
+  })
+
+  it('blocks host shell execution in workspace-write sandbox mode', async () => {
+    const result = await host.execute(
+      {
+        callId: 'call_bash',
+        toolName: 'bash',
+        arguments: { command: 'echo hello' }
+      },
+      buildContext(workspace, { sandboxMode: 'workspace-write' })
+    )
+
+    expect(result.approved).toBe(false)
+    expect(result.item).toMatchObject({
+      kind: 'tool_result',
+      toolName: 'bash',
+      isError: true,
+      output: {
+        code: 'sandbox_command_blocked'
+      }
+    })
+  })
+
   it('advertises structured GUI input choices and normalizes single-question options', async () => {
-    const tools = await host.listTools(buildContext(workspace))
+    const tools = await host.listTools(
+      buildContext(workspace, { awaitUserInput: async () => ({ status: 'cancelled' }) })
+    )
     const requestInputTool = tools.find((tool) => tool.name === 'request_user_input')
     expect(requestInputTool?.inputSchema).toMatchObject({
       properties: {
@@ -118,7 +259,7 @@ describe('Kun built-in tools', () => {
       }
     })
 
-    let seenInput: { questions: Array<{ options: Array<{ label: string; description: string }> }> } | null = null
+    const seenInputs: Array<{ questions: Array<{ options: Array<{ label: string; description: string }> }> }> = []
     const result = await host.execute(
       {
         callId: 'call_input',
@@ -132,7 +273,7 @@ describe('Kun built-in tools', () => {
       {
         ...buildContext(workspace),
         awaitUserInput: async (input) => {
-          seenInput = input
+          seenInputs.push(input)
           return {
             status: 'submitted',
             answers: [{ id: input.questions[0]?.id ?? 'choice', label: 'South', value: 'South' }]
@@ -141,7 +282,7 @@ describe('Kun built-in tools', () => {
       }
     )
 
-    expect(seenInput?.questions[0]?.options).toEqual([
+    expect(seenInputs[0]?.questions[0]?.options).toEqual([
       { label: 'South', description: '' },
       { label: 'North', description: 'Cooler weather' }
     ])
@@ -152,6 +293,13 @@ describe('Kun built-in tools', () => {
     })
   })
 
+  it('hides GUI input tools when the turn context has no user-input gate', async () => {
+    const tools = await host.listTools(buildContext(workspace))
+    const names = tools.map((tool) => tool.name)
+    expect(names).not.toContain('user_input')
+    expect(names).not.toContain('request_user_input')
+  })
+
   it('exposes pi-style coding and read-only tool groups', () => {
     expect(buildCodingBuiltinLocalTools().map((tool) => tool.name)).toEqual(['read', 'bash', 'edit', 'write'])
     expect(buildReadOnlyBuiltinLocalTools().map((tool) => tool.name)).toEqual(['read', 'grep', 'find', 'ls'])
diff --git a/kun/tests/contracts.test.ts b/kun/tests/contracts.test.ts
index f4885d25..aa2d0fee 100644
--- a/kun/tests/contracts.test.ts
+++ b/kun/tests/contracts.test.ts
@@ -121,6 +121,25 @@ describe('contracts', () => {
     expect(parsed.reasoningEffort).toBe('max')
   })
 
+  it('accepts per-turn execution policy on start turn payloads', () => {
+    const parsed = StartTurnRequest.parse({
+      prompt: 'Inspect without changing files',
+      approvalPolicy: 'on-request',
+      sandboxMode: 'read-only'
+    })
+    expect(parsed.approvalPolicy).toBe('on-request')
+    expect(parsed.sandboxMode).toBe('read-only')
+  })
+
+  it('accepts the IM/headless disableUserInput flag on start turn payloads', () => {
+    const parsed = StartTurnRequest.parse({
+      prompt: 'Reply to the WeChat user',
+      disableUserInput: true
+    })
+    expect(parsed.disableUserInput).toBe(true)
+    expect(StartTurnRequest.parse({ prompt: 'GUI turn' }).disableUserInput).toBeUndefined()
+  })
+
   it('accepts turn failure lifecycle messages', () => {
     const event = RuntimeEvent.parse({
       kind: 'turn_failed',
@@ -476,6 +495,9 @@ describe('cli', () => {
     expect(config.attachments.textFallbackMaxImageDimension).toBe(1280)
     expect(config.attachments.textFallbackPreferredMimeType).toBe('image/webp')
     expect(config.memory.scopes).toEqual(['user', 'workspace', 'project'])
+    expect(config.imageGen.enabled).toBe(false)
+    expect(config.imageGen.timeoutMs).toBe(180_000)
+    expect(config.imageGen.maxReferenceImages).toBe(4)
   })
 
   it('ignores legacy subagent step-limit config fields', () => {
@@ -541,13 +563,16 @@ describe('cli', () => {
     expect(legacy?.hardThreshold).toBe(56_000)
   })
 
-  it('uses 980k as the built-in DeepSeek v4 soft compaction threshold', () => {
+  it('uses 75%/85% of the window as the built-in DeepSeek v4 compaction thresholds', () => {
+    // Compaction must trigger with headroom to spare. Triggering at
+    // 98%/99% left no room for a large turn to land before the window was
+    // exceeded, so the built-in ratios are 0.75 / 0.85 of the 1M window.
     const profile = modelContextProfilesFromConfig()
       .find((candidate) => candidate.canonicalModel === 'deepseek-v4-pro')
 
     expect(profile?.contextWindowTokens).toBe(1_000_000)
-    expect(profile?.softThreshold).toBe(980_000)
-    expect(profile?.hardThreshold).toBe(990_000)
+    expect(profile?.softThreshold).toBe(750_000)
+    expect(profile?.hardThreshold).toBe(850_000)
   })
 
   it('keeps built-in DeepSeek v4 models text-only', () => {
@@ -571,6 +596,8 @@ describe('cli', () => {
     expect(manifest.attachments.textFallbackMaxBase64Bytes).toBe(512 * 1024)
     expect(manifest.attachments.textFallbackMaxImageDimension).toBe(1280)
     expect(manifest.attachments.textFallbackPreferredMimeType).toBe('image/webp')
+    expect(manifest.imageGen.available).toBe(false)
+    expect(manifest.imageGen.reason).toMatch(/disabled/)
 
     const enabledButMissingProvider = buildRuntimeCapabilityManifest({
       model: modelCapabilitiesForModel('deepseek-chat'),
diff --git a/kun/tests/create-plan-tool.test.ts b/kun/tests/create-plan-tool.test.ts
index 89d35457..723c4761 100644
--- a/kun/tests/create-plan-tool.test.ts
+++ b/kun/tests/create-plan-tool.test.ts
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { LocalToolHost } from '../src/adapters/tool/local-tool-host.js'
+import { LocalToolHost, buildDefaultLocalTools } from '../src/adapters/tool/local-tool-host.js'
 import {
   CREATE_PLAN_INPUT_SCHEMA,
   CREATE_PLAN_TOOL_NAME,
@@ -76,6 +76,45 @@ describe('create_plan tool: advertisement', () => {
     const tools = await host.listTools(buildContext({ threadMode: 'plan' }))
     expect(tools.map((t) => t.name)).toContain(CREATE_PLAN_TOOL_NAME)
   })
+
+  it('limits default plan-mode tool advertisement to read-only investigation and create_plan', async () => {
+    const host = new LocalToolHost({ tools: buildDefaultLocalTools() })
+    const tools = await host.listTools(
+      buildContext({
+        threadMode: 'plan',
+        awaitUserInput: async () => ({ status: 'cancelled' })
+      })
+    )
+    const names = tools.map((tool) => tool.name)
+
+    expect(names).toEqual([
+      'read',
+      'grep',
+      'find',
+      'ls',
+      'user_input',
+      'request_user_input',
+      CREATE_PLAN_TOOL_NAME
+    ])
+    expect(names).not.toEqual(expect.arrayContaining(['bash', 'edit', 'write', 'echo']))
+  })
+
+  it('drops the GUI input tools from plan-mode advertisement when no user-input gate is wired', async () => {
+    const host = new LocalToolHost({ tools: buildDefaultLocalTools() })
+    const tools = await host.listTools(buildContext({ threadMode: 'plan' }))
+    const names = tools.map((tool) => tool.name)
+
+    expect(names).toEqual(['read', 'grep', 'find', 'ls', CREATE_PLAN_TOOL_NAME])
+  })
+
+  it('keeps the normal agent-mode default tool advertisement unchanged', async () => {
+    const host = new LocalToolHost({ tools: buildDefaultLocalTools() })
+    const tools = await host.listTools(buildContext({ threadMode: 'agent' }))
+    const names = tools.map((tool) => tool.name)
+
+    expect(names).toEqual(expect.arrayContaining(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'echo']))
+    expect(names).not.toContain(CREATE_PLAN_TOOL_NAME)
+  })
 })
 
 describe('create_plan tool: path validation', () => {
@@ -225,6 +264,50 @@ describe('create_plan tool: execution safety', () => {
     )
     expect(result.isError).toBe(true)
   })
+
+  it('rejects non-plan mutation tools through the tool host during plan mode', async () => {
+    const host = new LocalToolHost({ tools: buildDefaultLocalTools() })
+    const context = buildContext({ threadMode: 'plan' })
+
+    for (const toolName of ['write', 'edit', 'bash']) {
+      await expect(
+        host.execute(
+          {
+            callId: `call_${toolName}`,
+            toolName,
+            arguments: toolName === 'bash'
+              ? { command: 'touch forbidden.txt' }
+              : { path: 'forbidden.txt', content: 'nope', oldText: 'nope', newText: 'no' }
+          },
+          context
+        )
+      ).rejects.toThrow(/not advertised by active tool policy/)
+    }
+  })
+
+  it('still executes create_plan through the tool host during plan mode', async () => {
+    const host = new LocalToolHost({
+      tools: buildDefaultLocalTools({
+        listPlanFiles: () => [],
+        writePlan: async (target) => ({ path: target.absolutePath, savedAt: 'now' })
+      })
+    })
+    const result = await host.execute(
+      {
+        callId: 'call_plan',
+        toolName: CREATE_PLAN_TOOL_NAME,
+        arguments: { markdown: '# allowed', operation: 'draft', title: 'safe plan' }
+      },
+      buildContext({ threadMode: 'plan', workspace: '/tmp/ws' })
+    )
+
+    expect(result.item.kind).toBe('tool_result')
+    expect(result.item.kind === 'tool_result' ? result.item.isError : true).not.toBe(true)
+    expect(result.item.kind === 'tool_result'
+      ? (result.item.output as { relative_path?: string }).relative_path
+      : ''
+    ).toBe('.kunsdd/plan/safe-plan.md')
+  })
 })
 
 describe('create_plan tool: success and atomic write', () => {
diff --git a/kun/tests/deepseek-pricing.test.ts b/kun/tests/deepseek-pricing.test.ts
index c48a0f14..c137ab6e 100644
--- a/kun/tests/deepseek-pricing.test.ts
+++ b/kun/tests/deepseek-pricing.test.ts
@@ -1,9 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import {
-  estimateDeepseekCacheSavings,
-  estimateDeepseekCost,
-  estimateDeepseekInputTokenCost
-} from '../src/adapters/model/deepseek-pricing.js'
+import { estimateDeepseekCost } from '../src/adapters/model/deepseek-pricing.js'
 
 describe('DeepSeek pricing — provider-aware gate (issue #26)', () => {
   it('returns null for non-DeepSeek host when providerHost is provided', () => {
@@ -60,37 +56,6 @@ describe('DeepSeek pricing — provider-aware gate (issue #26)', () => {
     expect(cost!.costUsd).toBeGreaterThan(0)
   })
 
-  it('estimateDeepseekInputTokenCost honors providerHost', () => {
-    expect(estimateDeepseekInputTokenCost({
-      model: 'deepseek-v4-pro',
-      inputTokens: 1000,
-      providerHost: 'https://openrouter.ai/api/v1'
-    })).toBeNull()
-
-    const cost = estimateDeepseekInputTokenCost({
-      model: 'deepseek-v4-pro',
-      inputTokens: 1000,
-      providerHost: 'https://api.deepseek.com'
-    })
-    expect(cost).not.toBeNull()
-  })
-
-  it('estimateDeepseekCacheSavings honors providerHost', () => {
-    expect(estimateDeepseekCacheSavings({
-      model: 'deepseek-v4-pro',
-      cacheHitTokens: 500,
-      providerHost: 'https://openrouter.ai/api/v1'
-    })).toBeNull()
-
-    const savings = estimateDeepseekCacheSavings({
-      model: 'deepseek-v4-pro',
-      cacheHitTokens: 500,
-      providerHost: 'https://api.deepseek.com'
-    })
-    expect(savings).not.toBeNull()
-    expect(savings!.costUsd).toBeGreaterThan(0)
-  })
-
   it('still returns null for unknown model names even on DeepSeek host', () => {
     // Defensive: an unknown model on the official host should still be null
     // because we don't have authoritative prices for it.
diff --git a/kun/tests/domain.test.ts b/kun/tests/domain.test.ts
index cc6d0612..fb865273 100644
--- a/kun/tests/domain.test.ts
+++ b/kun/tests/domain.test.ts
@@ -62,10 +62,14 @@ describe('domain.thread', () => {
       id: 'thr_1',
       title: 'demo',
       workspace: '/tmp',
-      model: 'deepseek-chat'
+      model: 'deepseek-chat',
+      approvalPolicy: 'on-request',
+      sandboxMode: 'read-only'
     })
     const summary = toThreadSummary(thread)
     expect(summary).not.toHaveProperty('turns')
+    expect(summary.approvalPolicy).toBe('on-request')
+    expect(summary.sandboxMode).toBe('read-only')
   })
 })
 
@@ -82,6 +86,17 @@ describe('domain.turn', () => {
     expect(next.items).toHaveLength(1)
   })
 
+  it('persists the disableUserInput flag only when set', () => {
+    expect(baseTurn).not.toHaveProperty('disableUserInput')
+    const headless = createTurnRecord({
+      id: 'turn_im',
+      threadId: 'thr_1',
+      prompt: 'hi from wechat',
+      disableUserInput: true
+    })
+    expect(headless.disableUserInput).toBe(true)
+  })
+
   it('replaces an existing item with the same id', () => {
     const partial = makeToolResultItem({
       id: 'item_call_1',
diff --git a/kun/tests/goal-repetition-guard.test.ts b/kun/tests/goal-repetition-guard.test.ts
new file mode 100644
index 00000000..f58905dc
--- /dev/null
+++ b/kun/tests/goal-repetition-guard.test.ts
@@ -0,0 +1,246 @@
+import { describe, expect, it } from 'vitest'
+import { LocalToolHost, buildDefaultLocalTools } from '../src/adapters/tool/local-tool-host.js'
+import { GET_GOAL_TOOL_NAME, UPDATE_GOAL_TOOL_NAME } from '../src/adapters/tool/goal-tools.js'
+import type { ModelRequest, ModelStreamChunk } from '../src/ports/model-client.js'
+import { bootstrapThread, makeHarness, type Harness } from './loop-test-harness.js'
+
+function makeGoalTools(getHarness: () => Harness) {
+  return [
+    LocalToolHost.defineTool({
+      name: GET_GOAL_TOOL_NAME,
+      description: 'Get goal',
+      inputSchema: {
+        type: 'object',
+        properties: {},
+        additionalProperties: false
+      },
+      policy: 'auto',
+      execute: async (_args, context) => ({
+        output: { goal: await getHarness().threads.getGoal(context.threadId) }
+      })
+    }),
+    LocalToolHost.defineTool({
+      name: UPDATE_GOAL_TOOL_NAME,
+      description: 'Update goal',
+      inputSchema: {
+        type: 'object',
+        properties: {
+          status: { type: 'string', enum: ['complete', 'blocked'] }
+        },
+        required: ['status'],
+        additionalProperties: false
+      },
+      policy: 'auto',
+      execute: async (args, context) => {
+        const status = args.status
+        if (status !== 'complete' && status !== 'blocked') {
+          return { output: { error: 'invalid status' }, isError: true }
+        }
+        const goal = await getHarness().threads.setGoal(context.threadId, { status })
+        return { output: { goal } }
+      }
+    })
+  ]
+}
+
+async function loadRepetitionStops(h: Harness) {
+  return (await h.sessionStore.loadItems(h.threadId)).filter(
+    (item) => item.kind === 'error' && item.code === 'goal_repetition_stop'
+  )
+}
+
+describe('goal continuation repetition guard', () => {
+  it('tries recovery prompts before stopping repeated no-tool replies', async () => {
+    let h: Harness
+    let calls = 0
+    const requests: ModelRequest[] = []
+    h = makeHarness(
+      {
+        provider: 'goal-repeat',
+        model: 'goal-repeat',
+        async *stream(request): AsyncIterable {
+          requests.push(request)
+          calls += 1
+          yield {
+            kind: 'assistant_text_delta',
+            text: calls === 1 ? 'I will run the build command now.' : 'i will run the build command NOW!!'
+          }
+          yield { kind: 'completed', stopReason: 'stop' }
+        }
+      },
+      { tools: [...buildDefaultLocalTools(), ...makeGoalTools(() => h)] }
+    )
+    await bootstrapThread(h, { request: { prompt: 'run the build' } })
+    await h.threads.setGoal(h.threadId, { objective: 'run the build', status: 'active' })
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    expect(status).toBe('completed')
+    expect(calls).toBe(5)
+    expect((await h.threads.getGoal(h.threadId))?.status).toBe('active')
+    expect(requests.some((request) =>
+      request.contextInstructions?.some((line) => line.includes('Goal continuation recovery:'))
+    )).toBe(true)
+    const stops = await loadRepetitionStops(h)
+    expect(stops).toHaveLength(1)
+    expect(stops[0]?.kind === 'error' ? stops[0].severity : undefined).toBe('warning')
+  })
+
+  it('stops only after near-identical reordered replies exhaust recovery', async () => {
+    let h: Harness
+    let calls = 0
+    h = makeHarness(
+      {
+        provider: 'goal-reorder',
+        model: 'goal-reorder',
+        async *stream(): AsyncIterable {
+          calls += 1
+          yield {
+            kind: 'assistant_text_delta',
+            text: calls === 1
+              ? 'I will now run the build command.'
+              : 'Now I will run the build command.'
+          }
+          yield { kind: 'completed', stopReason: 'stop' }
+        }
+      },
+      { tools: [...buildDefaultLocalTools(), ...makeGoalTools(() => h)] }
+    )
+    await bootstrapThread(h, { request: { prompt: 'run the build' } })
+    await h.threads.setGoal(h.threadId, { objective: 'run the build', status: 'active' })
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    expect(status).toBe('completed')
+    expect(calls).toBe(5)
+    expect(await loadRepetitionStops(h)).toHaveLength(1)
+  })
+
+  it('lets a repeated no-tool reply recover by calling update_goal', async () => {
+    let h: Harness
+    let calls = 0
+    const requests: ModelRequest[] = []
+    h = makeHarness(
+      {
+        provider: 'goal-recover',
+        model: 'goal-recover',
+        async *stream(request): AsyncIterable {
+          requests.push(request)
+          calls += 1
+          if (calls <= 2) {
+            yield { kind: 'assistant_text_delta', text: 'I will run the build command now.' }
+            yield { kind: 'completed', stopReason: 'stop' }
+            return
+          }
+          if (calls === 3) {
+            yield {
+              kind: 'tool_call_complete',
+              callId: 'call_complete_goal',
+              toolName: UPDATE_GOAL_TOOL_NAME,
+              arguments: { status: 'complete' }
+            }
+            yield { kind: 'completed', stopReason: 'tool_calls' }
+            return
+          }
+          yield { kind: 'assistant_text_delta', text: 'Goal complete.' }
+          yield { kind: 'completed', stopReason: 'stop' }
+        }
+      },
+      { tools: [...buildDefaultLocalTools(), ...makeGoalTools(() => h)] }
+    )
+    await bootstrapThread(h, { request: { prompt: 'run the build' } })
+    await h.threads.setGoal(h.threadId, { objective: 'run the build', status: 'active' })
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    expect(status).toBe('completed')
+    expect(calls).toBe(4)
+    expect((await h.threads.getGoal(h.threadId))?.status).toBe('complete')
+    expect(requests[2]?.contextInstructions?.join('\n')).toContain('Goal continuation recovery:')
+    expect(await loadRepetitionStops(h)).toHaveLength(0)
+  })
+
+  it('keeps continuing while no-tool replies make distinct progress', async () => {
+    let h: Harness
+    let calls = 0
+    h = makeHarness(
+      {
+        provider: 'goal-progress',
+        model: 'goal-progress',
+        async *stream(): AsyncIterable {
+          calls += 1
+          if (calls === 1) {
+            yield { kind: 'assistant_text_delta', text: 'Draft ready.' }
+            yield { kind: 'completed', stopReason: 'stop' }
+            return
+          }
+          if (calls === 2) {
+            yield { kind: 'assistant_text_delta', text: 'Benchmarks recorded, wrapping up the summary.' }
+            yield { kind: 'completed', stopReason: 'stop' }
+            return
+          }
+          if (calls === 3) {
+            yield {
+              kind: 'tool_call_complete',
+              callId: 'call_complete_goal',
+              toolName: UPDATE_GOAL_TOOL_NAME,
+              arguments: { status: 'complete' }
+            }
+            yield { kind: 'completed', stopReason: 'tool_calls' }
+            return
+          }
+          yield { kind: 'assistant_text_delta', text: 'Goal complete.' }
+          yield { kind: 'completed', stopReason: 'stop' }
+        }
+      },
+      { tools: [...buildDefaultLocalTools(), ...makeGoalTools(() => h)] }
+    )
+    await bootstrapThread(h, { request: { prompt: 'write a benchmark note' } })
+    await h.threads.setGoal(h.threadId, { objective: 'write a benchmark note', status: 'active' })
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    expect(status).toBe('completed')
+    expect(calls).toBe(4)
+    expect((await h.threads.getGoal(h.threadId))?.status).toBe('complete')
+    expect(await loadRepetitionStops(h)).toHaveLength(0)
+  })
+
+  it('resets the repetition window after a step that calls tools', async () => {
+    let h: Harness
+    let calls = 0
+    h = makeHarness(
+      {
+        provider: 'goal-reset',
+        model: 'goal-reset',
+        async *stream(): AsyncIterable {
+          calls += 1
+          if (calls === 2) {
+            yield {
+              kind: 'tool_call_complete',
+              callId: 'call_get_goal',
+              toolName: GET_GOAL_TOOL_NAME,
+              arguments: {}
+            }
+            yield { kind: 'completed', stopReason: 'tool_calls' }
+            return
+          }
+          yield { kind: 'assistant_text_delta', text: 'Working on it.' }
+          yield { kind: 'completed', stopReason: 'stop' }
+        }
+      },
+      { tools: [...buildDefaultLocalTools(), ...makeGoalTools(() => h)] }
+    )
+    await bootstrapThread(h, { request: { prompt: 'keep working' } })
+    await h.threads.setGoal(h.threadId, { objective: 'keep working', status: 'active' })
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    // Step 1 stores the text, step 2's tool call resets the window, step 3
+    // stores it again, and the guard gives three recovery prompts before
+    // stopping the repeated no-tool replies.
+    expect(status).toBe('completed')
+    expect(calls).toBe(7)
+    expect(await loadRepetitionStops(h)).toHaveLength(1)
+  })
+})
diff --git a/kun/tests/hooks-lifecycle.test.ts b/kun/tests/hooks-lifecycle.test.ts
new file mode 100644
index 00000000..eccf4773
--- /dev/null
+++ b/kun/tests/hooks-lifecycle.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from 'vitest'
+import { bootstrapThread, makeHarness, makeSilentModel, makeFakeModel } from './loop-test-harness.js'
+import { ContextCompactor } from '../src/loop/context-compactor.js'
+import type { HookInvocation } from '../src/hooks/hook-engine.js'
+
+function recordingHook(phase: HookInvocation['phase'], sink: HookInvocation[]) {
+  return {
+    phase,
+    run: (invocation: HookInvocation) => {
+      sink.push(invocation)
+    }
+  }
+}
+
+describe('agent loop lifecycle hooks', () => {
+  it('fires TurnStart and TurnEnd with turn metadata', async () => {
+    const seen: HookInvocation[] = []
+    const h = makeHarness(makeSilentModel(), {
+      hooks: [recordingHook('TurnStart', seen), recordingHook('TurnEnd', seen)]
+    })
+    await bootstrapThread(h, { workspace: '/tmp/ws', request: { prompt: 'lifecycle check' } })
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+    expect(status).toBe('completed')
+    expect(seen).toHaveLength(2)
+    expect(seen[0]).toMatchObject({
+      phase: 'TurnStart',
+      threadId: h.threadId,
+      turnId: h.turnId,
+      prompt: 'lifecycle check',
+      workspace: '/tmp/ws'
+    })
+    expect(seen[1]).toMatchObject({
+      phase: 'TurnEnd',
+      threadId: h.threadId,
+      turnId: h.turnId,
+      status: 'completed'
+    })
+  })
+
+  it('fails the turn when a UserPromptSubmit hook denies it', async () => {
+    const h = makeHarness(makeSilentModel(), {
+      hooks: [{ phase: 'UserPromptSubmit', run: () => ({ decision: 'deny', message: 'prompt rejected by gate' }) }]
+    })
+    await bootstrapThread(h)
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+    expect(status).toBe('failed')
+    const items = await h.sessionStore.loadItems(h.threadId)
+    const errorItem = items.find((item) => item.kind === 'error')
+    expect(errorItem).toMatchObject({ kind: 'error', code: 'hook_denied', message: 'prompt rejected by gate' })
+    const thread = await h.threadStore.get(h.threadId)
+    const turn = thread?.turns.find((t) => t.id === h.turnId)
+    expect(turn?.status).toBe('failed')
+  })
+
+  it('persists UserPromptSubmit additionalContext as a hook-context user message', async () => {
+    const h = makeHarness(makeSilentModel(), {
+      hooks: [{ phase: 'UserPromptSubmit', run: () => ({ additionalContext: 'deploy freeze until friday' }) }]
+    })
+    await bootstrapThread(h)
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+    expect(status).toBe('completed')
+    const items = await h.sessionStore.loadItems(h.threadId)
+    const injected = items.find(
+      (item) => item.kind === 'user_message' && item.text.includes('')
+    )
+    expect(injected).toBeDefined()
+    expect(injected && injected.kind === 'user_message' ? injected.text : '').toContain(
+      'deploy freeze until friday'
+    )
+  })
+
+  it('fires PreCompact when compaction is planned', async () => {
+    const seen: HookInvocation[] = []
+    const h = makeHarness(
+      makeFakeModel([
+        { kind: 'assistant_text_delta', text: 'done' },
+        { kind: 'completed', stopReason: 'stop' }
+      ]),
+      {
+        compactor: new ContextCompactor({ softThreshold: 1, hardThreshold: 2 }),
+        hooks: [recordingHook('PreCompact', seen)]
+      }
+    )
+    await bootstrapThread(h, { request: { prompt: 'long enough prompt to exceed a one-token threshold' } })
+    await h.loop.runTurn(h.threadId, h.turnId)
+    expect(seen.length).toBeGreaterThan(0)
+    expect(seen[0]).toMatchObject({ phase: 'PreCompact', threadId: h.threadId, turnId: h.turnId })
+    expect(seen[0].phase === 'PreCompact' ? seen[0].reason : '').toBeTruthy()
+  })
+
+  it('keeps the turn alive when an observer hook crashes and records a warning event', async () => {
+    const h = makeHarness(makeSilentModel(), {
+      hooks: [
+        {
+          phase: 'TurnStart',
+          run: () => {
+            throw new Error('observer exploded')
+          }
+        }
+      ]
+    })
+    await bootstrapThread(h)
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+    expect(status).toBe('completed')
+    const warning = h.bus
+      .snapshotSince(h.threadId, 0)
+      .find((event) => event.kind === 'error' && event.code === 'hook_warning')
+    expect(warning).toBeDefined()
+  })
+})
diff --git a/kun/tests/hooks.test.ts b/kun/tests/hooks.test.ts
new file mode 100644
index 00000000..8b170d19
--- /dev/null
+++ b/kun/tests/hooks.test.ts
@@ -0,0 +1,355 @@
+import { describe, expect, it } from 'vitest'
+import {
+  hookMatchesTool,
+  runObserverHooks,
+  runPostToolUseHooks,
+  runPreToolUseHooks,
+  runUserPromptSubmitHooks,
+  type ResolvedHook,
+  type ToolHookContext
+} from '../src/hooks/hook-engine.js'
+import { resolveConfiguredHooks, HooksConfigSchema } from '../src/hooks/hook-config.js'
+import { LocalToolHost, defaultLocalTools } from '../src/adapters/tool/local-tool-host.js'
+
+const context: ToolHookContext = {
+  threadId: 'th',
+  turnId: 'tu',
+  workspace: '/tmp',
+  approvalPolicy: 'on-request'
+}
+
+const call = (toolName: string, args: Record = {}) => ({
+  callId: 'c_1',
+  toolName,
+  arguments: args
+})
+
+describe('hookMatchesTool', () => {
+  it('matches everything when no matcher or toolNames are set', () => {
+    expect(hookMatchesTool({}, 'bash')).toBe(true)
+  })
+
+  it('matches exact toolNames', () => {
+    expect(hookMatchesTool({ toolNames: ['bash'] }, 'bash')).toBe(true)
+    expect(hookMatchesTool({ toolNames: ['bash'] }, 'read_file')).toBe(false)
+  })
+
+  it('matches glob patterns with * wildcard', () => {
+    expect(hookMatchesTool({ matcher: 'mcp__*' }, 'mcp__github__create_issue')).toBe(true)
+    expect(hookMatchesTool({ matcher: 'mcp__*' }, 'bash')).toBe(false)
+  })
+
+  it('matches | alternation', () => {
+    expect(hookMatchesTool({ matcher: 'read_file|write_file' }, 'write_file')).toBe(true)
+    expect(hookMatchesTool({ matcher: 'read_file|write_file' }, 'bash')).toBe(false)
+  })
+
+  it('escapes regex specials in glob patterns', () => {
+    expect(hookMatchesTool({ matcher: 'a.b' }, 'a.b')).toBe(true)
+    expect(hookMatchesTool({ matcher: 'a.b' }, 'axb')).toBe(false)
+  })
+
+  it('matches when either toolNames or matcher matches', () => {
+    expect(hookMatchesTool({ toolNames: ['bash'], matcher: 'mcp__*' }, 'bash')).toBe(true)
+    expect(hookMatchesTool({ toolNames: ['bash'], matcher: 'mcp__*' }, 'mcp__a')).toBe(true)
+    expect(hookMatchesTool({ toolNames: ['bash'], matcher: 'mcp__*' }, 'read_file')).toBe(false)
+  })
+})
+
+describe('runPreToolUseHooks', () => {
+  it('chains argument rewrites so later hooks see earlier rewrites', async () => {
+    const seen: unknown[] = []
+    const hooks: ResolvedHook[] = [
+      {
+        phase: 'PreToolUse',
+        run: (invocation) => {
+          if (invocation.phase !== 'PreToolUse') return
+          seen.push(invocation.call.arguments)
+          return { arguments: { text: 'first' } }
+        }
+      },
+      {
+        phase: 'PreToolUse',
+        run: (invocation) => {
+          if (invocation.phase !== 'PreToolUse') return
+          seen.push(invocation.call.arguments)
+          return { arguments: { text: `${(invocation.call.arguments as { text: string }).text}+second` } }
+        }
+      }
+    ]
+    const outcome = await runPreToolUseHooks(hooks, { call: call('echo', { text: 'original' }), context })
+    expect(seen).toEqual([{ text: 'original' }, { text: 'first' }])
+    expect(outcome.call.arguments).toEqual({ text: 'first+second' })
+    expect(outcome.denied).toBeUndefined()
+  })
+
+  it('stops the chain on deny and skips later hooks', async () => {
+    let laterRan = false
+    const hooks: ResolvedHook[] = [
+      { phase: 'PreToolUse', run: () => ({ decision: 'deny', message: 'nope' }) },
+      {
+        phase: 'PreToolUse',
+        run: () => {
+          laterRan = true
+        }
+      }
+    ]
+    const outcome = await runPreToolUseHooks(hooks, { call: call('echo'), context })
+    expect(outcome.denied).toBe('nope')
+    expect(laterRan).toBe(false)
+  })
+
+  it('reports autoApproved on decision allow unless a later hook denies', async () => {
+    const allowed = await runPreToolUseHooks(
+      [{ phase: 'PreToolUse', run: () => ({ decision: 'allow' }) }],
+      { call: call('echo'), context }
+    )
+    expect(allowed.autoApproved).toBe(true)
+    const denied = await runPreToolUseHooks(
+      [
+        { phase: 'PreToolUse', run: () => ({ decision: 'allow' }) },
+        { phase: 'PreToolUse', run: () => ({ decision: 'deny', message: 'blocked' }) }
+      ],
+      { call: call('echo'), context }
+    )
+    expect(denied.denied).toBe('blocked')
+    expect(denied.autoApproved).toBe(false)
+  })
+
+  it('only runs hooks whose matcher matches the tool', async () => {
+    let ran = false
+    const hooks: ResolvedHook[] = [
+      {
+        phase: 'PreToolUse',
+        matcher: 'mcp__*',
+        run: () => {
+          ran = true
+        }
+      }
+    ]
+    await runPreToolUseHooks(hooks, { call: call('bash'), context })
+    expect(ran).toBe(false)
+  })
+
+  it('propagates function hook timeouts', async () => {
+    const hooks: ResolvedHook[] = [
+      {
+        phase: 'PreToolUse',
+        timeoutMs: 20,
+        run: () => new Promise(() => undefined)
+      }
+    ]
+    await expect(runPreToolUseHooks(hooks, { call: call('echo'), context })).rejects.toThrow(/timed out/)
+  })
+})
+
+describe('runPostToolUseHooks', () => {
+  it('chains output rewrites so later hooks see earlier rewrites', async () => {
+    const hooks: ResolvedHook[] = [
+      {
+        phase: 'PostToolUse',
+        run: (invocation) => {
+          if (invocation.phase !== 'PostToolUse') return
+          return { output: { layer: 1, inner: invocation.result.output } }
+        }
+      },
+      {
+        phase: 'PostToolUse',
+        run: (invocation) => {
+          if (invocation.phase !== 'PostToolUse') return
+          return { output: { layer: 2, inner: invocation.result.output } }
+        }
+      }
+    ]
+    const outcome = await runPostToolUseHooks(hooks, {
+      call: call('echo'),
+      context,
+      result: { output: 'raw' }
+    })
+    expect(outcome.output).toEqual({ layer: 2, inner: { layer: 1, inner: 'raw' } })
+  })
+
+  it('lets a hook flip isError without replacing output', async () => {
+    const outcome = await runPostToolUseHooks(
+      [{ phase: 'PostToolUse', run: () => ({ isError: true }) }],
+      { call: call('echo'), context, result: { output: 'raw' } }
+    )
+    expect(outcome.output).toBe('raw')
+    expect(outcome.isError).toBe(true)
+  })
+})
+
+describe('runUserPromptSubmitHooks', () => {
+  const payload = { threadId: 'th', turnId: 'tu', prompt: 'do the thing' }
+
+  it('collects additionalContext from multiple hooks', async () => {
+    const outcome = await runUserPromptSubmitHooks(
+      [
+        { phase: 'UserPromptSubmit', run: () => ({ additionalContext: 'ctx one' }) },
+        { phase: 'UserPromptSubmit', run: () => ({ additionalContext: 'ctx two' }) }
+      ],
+      payload
+    )
+    expect(outcome.denied).toBeUndefined()
+    expect(outcome.additionalContext).toEqual(['ctx one', 'ctx two'])
+  })
+
+  it('denies the turn with the hook message', async () => {
+    const outcome = await runUserPromptSubmitHooks(
+      [{ phase: 'UserPromptSubmit', run: () => ({ decision: 'deny', message: 'not now' }) }],
+      payload
+    )
+    expect(outcome.denied).toBe('not now')
+  })
+
+  it('fails open with a warning when a hook crashes', async () => {
+    const outcome = await runUserPromptSubmitHooks(
+      [
+        {
+          phase: 'UserPromptSubmit',
+          run: () => {
+            throw new Error('boom')
+          }
+        },
+        { phase: 'UserPromptSubmit', run: () => ({ additionalContext: 'still here' }) }
+      ],
+      payload
+    )
+    expect(outcome.denied).toBeUndefined()
+    expect(outcome.additionalContext).toEqual(['still here'])
+    expect(outcome.warnings.some((warning) => warning.includes('boom'))).toBe(true)
+  })
+})
+
+describe('runObserverHooks', () => {
+  it('turns crashes into warnings', async () => {
+    const outcome = await runObserverHooks(
+      [
+        {
+          phase: 'TurnEnd',
+          run: () => {
+            throw new Error('observer down')
+          }
+        }
+      ],
+      { phase: 'TurnEnd', threadId: 'th', turnId: 'tu', status: 'completed' }
+    )
+    expect(outcome.warnings.some((warning) => warning.includes('observer down'))).toBe(true)
+  })
+})
+
+describe('command hooks', () => {
+  it('parses JSON stdout from a command hook (exit 0)', async () => {
+    const hooks = resolveConfiguredHooks([
+      {
+        phase: 'PreToolUse',
+        command: `node -e "console.log(JSON.stringify({ arguments: { text: 'patched-by-command' } }))"`
+      }
+    ])
+    const outcome = await runPreToolUseHooks(hooks, { call: call('echo', { text: 'original' }), context })
+    expect(outcome.call.arguments).toEqual({ text: 'patched-by-command' })
+  })
+
+  it('denies on exit code 2 with stderr as the reason', async () => {
+    const hooks = resolveConfiguredHooks([
+      {
+        phase: 'PreToolUse',
+        command: `node -e "console.error('blocked by policy'); process.exit(2)"`
+      }
+    ])
+    const outcome = await runPreToolUseHooks(hooks, { call: call('echo'), context })
+    expect(outcome.denied).toBe('blocked by policy')
+  })
+
+  it('treats other non-zero exits as non-blocking warnings', async () => {
+    const hooks = resolveConfiguredHooks([
+      {
+        phase: 'PreToolUse',
+        command: `node -e "console.error('flaky'); process.exit(1)"`
+      }
+    ])
+    const outcome = await runPreToolUseHooks(hooks, { call: call('echo'), context })
+    expect(outcome.denied).toBeUndefined()
+    expect(outcome.warnings).toEqual(['flaky'])
+  })
+
+  it('feeds the invocation to the command on stdin', async () => {
+    const hooks = resolveConfiguredHooks([
+      {
+        phase: 'PreToolUse',
+        command: `node -e "let raw=''; process.stdin.on('data', (c) => raw += c); process.stdin.on('end', () => { const inv = JSON.parse(raw); console.log(JSON.stringify({ arguments: { echoedTool: inv.call.toolName } })) })"`
+      }
+    ])
+    const outcome = await runPreToolUseHooks(hooks, { call: call('my_tool'), context })
+    expect(outcome.call.arguments).toEqual({ echoedTool: 'my_tool' })
+  })
+
+  it('turns plain stdout into additionalContext for UserPromptSubmit', async () => {
+    const hooks = resolveConfiguredHooks([
+      {
+        phase: 'UserPromptSubmit',
+        command: `node -e "console.log('remember: deploy freeze today')"`
+      }
+    ])
+    const outcome = await runUserPromptSubmitHooks(hooks, {
+      threadId: 'th',
+      turnId: 'tu',
+      prompt: 'ship it'
+    })
+    expect(outcome.additionalContext).toEqual(['remember: deploy freeze today'])
+  })
+
+  it('kills timed-out command hooks and propagates the timeout for tool phases', async () => {
+    const hooks = resolveConfiguredHooks([
+      {
+        phase: 'PreToolUse',
+        timeoutMs: 150,
+        command: `node -e "setTimeout(() => undefined, 60000)"`
+      }
+    ])
+    await expect(runPreToolUseHooks(hooks, { call: call('echo'), context })).rejects.toThrow(/timed out/)
+  })
+})
+
+describe('hooks config schema', () => {
+  it('accepts command hook entries and rejects unknown keys', () => {
+    expect(
+      HooksConfigSchema.safeParse([
+        { phase: 'PreToolUse', matcher: 'bash|write_file', command: './check.sh', timeoutMs: 1000 }
+      ]).success
+    ).toBe(true)
+    expect(HooksConfigSchema.safeParse([{ phase: 'PreToolUse', command: 'x', nope: true }]).success).toBe(false)
+    expect(HooksConfigSchema.safeParse([{ phase: 'NotAPhase', command: 'x' }]).success).toBe(false)
+  })
+})
+
+describe('LocalToolHost hook integration', () => {
+  it('skips approval when a PreToolUse hook returns decision allow', async () => {
+    const guarded = LocalToolHost.defineTool({
+      name: 'guarded',
+      description: 'always asks',
+      inputSchema: { type: 'object', properties: {}, required: [] },
+      policy: 'on-request',
+      execute: async () => ({ output: { ok: true } })
+    })
+    const host = new LocalToolHost({
+      tools: [...defaultLocalTools, guarded],
+      hooks: [{ phase: 'PreToolUse', toolNames: ['guarded'], run: () => ({ decision: 'allow' }) }]
+    })
+    const result = await host.execute(
+      { callId: 'c_allow', toolName: 'guarded', arguments: {} },
+      {
+        threadId: 'th',
+        turnId: 'tu',
+        workspace: '/tmp',
+        approvalPolicy: 'on-request',
+        abortSignal: new AbortController().signal,
+        awaitApproval: async () => {
+          throw new Error('approval should have been skipped')
+        }
+      }
+    )
+    expect(result.item).toMatchObject({ kind: 'tool_result', output: { ok: true } })
+    expect(result.approved).toBe(true)
+  })
+})
diff --git a/kun/tests/http-server.test.ts b/kun/tests/http-server.test.ts
index 1c51da4c..fb64100b 100644
--- a/kun/tests/http-server.test.ts
+++ b/kun/tests/http-server.test.ts
@@ -1,12 +1,13 @@
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { dispatchRequest } from '../src/server/http-server.js'
 import { createApprovalRequest } from '../src/domain/approval.js'
-import { makeAssistantTextItem } from '../src/domain/item.js'
+import { makeAssistantTextItem, makeToolCallItem, makeToolResultItem } from '../src/domain/item.js'
 import { encodeSseEvent } from '../src/server/sse.js'
 import { buildHarness, readJson, readSseEvents, usageSnapshot } from './http-server-test-harness.js'
+import type { TurnItem } from '../src/contracts/items.js'
 
 describe('HTTP server', () => {
   let dataDir = ''
@@ -229,6 +230,35 @@ describe('HTTP server', () => {
     expect(body.userMessageItemId).toBe(`item_${body.turnId}_user`)
   })
 
+  it('applies per-turn execution policy to the active thread', async () => {
+    const h = buildHarness()
+    await h.threadService.create({
+      workspace: '/tmp',
+      model: 'deepseek-chat',
+      mode: 'agent',
+      approvalPolicy: 'auto',
+      sandboxMode: 'danger-full-access'
+    }, { id: 'thr_policy', title: 'policy' })
+
+    const response = await dispatchRequest(
+      h.router,
+      new Request('http://localhost/v1/threads/thr_policy/turns', {
+        method: 'POST',
+        headers: { authorization: 'Bearer tok-1', 'content-type': 'application/json' },
+        body: JSON.stringify({
+          prompt: 'inspect only',
+          approvalPolicy: 'on-request',
+          sandboxMode: 'read-only'
+        })
+      })
+    )
+
+    expect(response.status).toBe(202)
+    const thread = await h.threadService.get('thr_policy')
+    expect(thread?.approvalPolicy).toBe('on-request')
+    expect(thread?.sandboxMode).toBe('read-only')
+  })
+
   it('creates and lists threads through the HTTP layer', async () => {
     const h = buildHarness()
     const create = await dispatchRequest(
@@ -548,6 +578,85 @@ describe('HTTP server', () => {
     })
   })
 
+  it('heals stale open session items for finished turns when loading thread detail', async () => {
+    const h = buildHarness()
+    await h.threadService.create(
+      { workspace: '/tmp', model: 'deepseek-chat', mode: 'agent' },
+      { id: 'thr_heal', title: 'Stale session' }
+    )
+    const { turnId } = await h.turnService.startTurn({
+      threadId: 'thr_heal',
+      request: { prompt: 'run a tool' }
+    })
+    await h.turnService.applyItem(
+      'thr_heal',
+      makeToolCallItem({
+        id: 'item_tool_stale',
+        turnId,
+        threadId: 'thr_heal',
+        callId: 'call_stale',
+        toolName: 'echo',
+        arguments: { text: 'hi' }
+      })
+    )
+    await h.turnService.applyItem(
+      'thr_heal',
+      makeToolResultItem({
+        id: 'item_result_stale',
+        turnId,
+        threadId: 'thr_heal',
+        callId: 'call_stale',
+        toolName: 'echo',
+        output: { partial: true },
+        status: 'running'
+      })
+    )
+    const staleThread = await h.threadStore.get('thr_heal')
+    if (!staleThread) throw new Error('expected thread')
+    const finishedAt = '2026-06-05T00:00:00.000Z'
+    await h.threadStore.upsert({
+      ...staleThread,
+      status: 'idle',
+      turns: staleThread.turns.map((turn) =>
+        turn.id === turnId
+          ? {
+              ...turn,
+              status: 'aborted',
+              finishedAt,
+              items: turn.items.map((item): TurnItem =>
+                item.id === 'item_tool_stale' || item.id === 'item_result_stale'
+                  ? ({ ...item, status: 'aborted', finishedAt } as TurnItem)
+                  : item
+              )
+            }
+          : turn
+      )
+    })
+    const staleById = new Map((await h.sessionStore.loadItems('thr_heal')).map((item) => [item.id, item.status]))
+    expect(staleById.get('item_tool_stale')).toBe('pending')
+    expect(staleById.get('item_result_stale')).toBe('running')
+
+    const response = await dispatchRequest(
+      h.router,
+      new Request('http://localhost/v1/threads/thr_heal', {
+        headers: { authorization: 'Bearer tok-1' }
+      })
+    )
+
+    expect(response.status).toBe(200)
+    const body = (await readJson(response)) as {
+      turns: Array<{ id: string; items: Array<{ id: string; status: string }> }>
+    }
+    const responseItems = new Map(
+      (body.turns.find((turn) => turn.id === turnId)?.items ?? []).map((item) => [item.id, item.status])
+    )
+    expect(responseItems.get('item_tool_stale')).toBe('aborted')
+    expect(responseItems.get('item_result_stale')).toBe('aborted')
+    const healedById = new Map((await h.sessionStore.loadItems('thr_heal')).map((item) => [item.id, item.status]))
+    expect(healedById.get('item_tool_stale')).toBe('aborted')
+    expect(healedById.get('item_result_stale')).toBe('aborted')
+  })
+
   it('persists GUI plan context from start-turn requests', async () => {
     const h = buildHarness()
     const create = await dispatchRequest(
@@ -681,6 +790,51 @@ describe('HTTP server', () => {
     expect(ids.every((id) => id > secondSeq)).toBe(true)
   })
 
+  it('delivers an event exactly once when it lands in both backlog and live bus', async () => {
+    const h = buildHarness()
+    const thread = await h.threadService.create(
+      { workspace: '/tmp', model: 'deepseek-chat', mode: 'agent' },
+      { id: 'thr_dedup', title: 'Dedup' }
+    )
+    const recorded = await h.runtime.events.record({ kind: 'heartbeat', threadId: thread.id })
+
+    const eventStream = await dispatchRequest(
+      h.router,
+      new Request(`http://localhost/v1/threads/${thread.id}/events?since_seq=0`, {
+        headers: { authorization: 'Bearer tok-1' }
+      })
+    )
+    // Simulate the persist/publish race: the event is already in the replayed
+    // backlog when the live bus re-delivers it after the subscription starts.
+    await new Promise((resolve) => setTimeout(resolve, 20))
+    h.bus.publish(recorded)
+
+    const frames = await readSseEvents(eventStream)
+    const occurrences = frames.filter((frame) => frame.includes(`id: ${recorded.seq}\n`))
+    expect(occurrences).toHaveLength(1)
+  })
+
+  it('skips SSE backlog replay when the client is already caught up', async () => {
+    const h = buildHarness()
+    const thread = await h.threadService.create(
+      { workspace: '/tmp', model: 'deepseek-chat', mode: 'agent' },
+      { id: 'thr_caught_up', title: 'Caught up' }
+    )
+    const latestSeq = await h.sessionStore.highestSeq(thread.id)
+    const loadEventsSince = vi.spyOn(h.sessionStore, 'loadEventsSince')
+
+    const eventStream = await dispatchRequest(
+      h.router,
+      new Request(`http://localhost/v1/threads/${thread.id}/events?since_seq=${latestSeq}`, {
+        headers: { authorization: 'Bearer tok-1' }
+      })
+    )
+    const events = await readSseEvents(eventStream)
+
+    expect(events).toEqual([])
+    expect(loadEventsSince).not.toHaveBeenCalled()
+  })
+
   it('resolves an approval through the HTTP endpoint', async () => {
     const h = buildHarness()
     const approval = createApprovalRequest({
@@ -978,6 +1132,37 @@ describe('HTTP server', () => {
     ])
   })
 
+  it('filters thread-grouped usage buckets by thread_id', async () => {
+    const h = buildHarness()
+    await h.threadService.create(
+      { workspace: '/tmp/project', model: 'deepseek-chat', mode: 'agent' },
+      { id: 'thr_live', title: 'Live usage' }
+    )
+    await h.threadService.create(
+      { workspace: '/tmp/project', model: 'deepseek-chat', mode: 'agent' },
+      { id: 'thr_other', title: 'Other usage' }
+    )
+    h.runtime.usageService.record('thr_live', usageSnapshot({ promptTokens: 12, completionTokens: 8 }))
+    h.runtime.usageService.record('thr_other', usageSnapshot({ promptTokens: 90, completionTokens: 10 }))
+
+    const response = await dispatchRequest(
+      h.router,
+      new Request('http://localhost/v1/usage?group_by=thread&thread_id=thr_live', {
+        headers: { authorization: 'Bearer tok-1' }
+      })
+    )
+
+    expect(response.status).toBe(200)
+    const body = (await readJson(response)) as {
+      group_by: string
+      buckets: Array<{ thread_id: string; total_tokens: number; turns: number }>
+    }
+    expect(body.group_by).toBe('thread')
+    expect(body.buckets).toEqual([
+      expect.objectContaining({ thread_id: 'thr_live', total_tokens: 20, turns: 1 })
+    ])
+  })
+
   it('derives daily usage from persisted cumulative usage events', async () => {
     const h = buildHarness()
     await h.threadService.create(
diff --git a/kun/tests/hybrid-store.test.ts b/kun/tests/hybrid-store.test.ts
index c5ac359e..567edae5 100644
--- a/kun/tests/hybrid-store.test.ts
+++ b/kun/tests/hybrid-store.test.ts
@@ -13,6 +13,7 @@ import { InflightTracker } from '../src/loop/inflight-tracker.js'
 import { SteeringQueue } from '../src/loop/steering-queue.js'
 import { ContextCompactor } from '../src/loop/context-compactor.js'
 import { SequentialIdGenerator } from '../src/ports/id-generator.js'
+import type { UsageSnapshot } from '../src/contracts/usage.js'
 
 describe('HybridThreadStore', () => {
   let dataDir = ''
@@ -61,6 +62,20 @@ describe('HybridThreadStore', () => {
     })
   })
 
+  it('lists existing SQLite rows without replaying damaged message or event logs', async () => {
+    const first = await createHybridStores()
+    const record = await seedThreadWithMessage(first.threadStore, first.sessionStore, 'indexed already')
+    first.threadStore.close()
+
+    await writeFile(join(dataDir, 'threads', record.id, 'messages.jsonl'), '{not-json\n', 'utf8')
+    await writeFile(join(dataDir, 'threads', record.id, 'events.jsonl'), '{not-json\n', 'utf8')
+
+    const reopened = await createHybridStores()
+    const summaries = await reopened.threadStore.list({ search: 'Hybrid demo' })
+
+    expect(summaries.map((thread) => thread.id)).toEqual([record.id])
+  })
+
   it('rebuilds the SQLite index from JSONL after the database is deleted', async () => {
     const first = await createHybridStores()
     const record = await seedThreadWithMessage(first.threadStore, first.sessionStore, 'recover me')
@@ -71,6 +86,7 @@ describe('HybridThreadStore', () => {
     await rm(join(dataDir, 'index.sqlite3-shm'), { force: true })
 
     const rebuilt = await createHybridStores()
+    await rebuilt.threadStore.waitForBackfill()
     const summaries = await rebuilt.threadStore.list({ search: 'Hybrid demo' })
     expect(summaries.map((thread) => thread.id)).toEqual([record.id])
 
@@ -81,6 +97,60 @@ describe('HybridThreadStore', () => {
     })
   })
 
+  it('indexes event high water and usage events as they are appended', async () => {
+    if (!sqliteAvailable) return
+    const { threadStore, sessionStore } = await createHybridStores()
+    const record = await seedThreadWithMessage(threadStore, sessionStore, 'track usage')
+    await sessionStore.appendEvent(record.id, {
+      kind: 'usage',
+      seq: 2,
+      timestamp: '2026-06-04T00:00:03.000Z',
+      threadId: record.id,
+      turnId: 'turn_hybrid',
+      model: 'deepseek-chat',
+      usage: usage({ promptTokens: 10, completionTokens: 5, totalTokens: 15, turns: 1 })
+    })
+    await sessionStore.appendEvent(record.id, {
+      kind: 'usage',
+      seq: 5,
+      timestamp: '2026-06-04T00:00:05.000Z',
+      threadId: record.id,
+      turnId: 'turn_hybrid',
+      model: 'deepseek-chat',
+      usage: usage({ promptTokens: 30, completionTokens: 10, totalTokens: 40, turns: 2 })
+    })
+
+    await writeFile(join(dataDir, 'threads', record.id, 'events.jsonl'), '{not-json\n', 'utf8')
+
+    await expect(sessionStore.highestSeq(record.id)).resolves.toBe(5)
+    await expect(sessionStore.loadLatestUsageSnapshots()).resolves.toMatchObject([
+      {
+        threadId: record.id,
+        seq: 5,
+        usage: {
+          promptTokens: 30,
+          completionTokens: 10,
+          totalTokens: 40,
+          turns: 2
+        }
+      }
+    ])
+    await expect(sessionStore.loadUsageRecords({ threadId: record.id })).resolves.toMatchObject([
+      {
+        threadId: record.id,
+        model: 'deepseek-chat',
+        completedAt: '2026-06-04T00:00:03.000Z',
+        usage: { totalTokens: 15, turns: 1 }
+      },
+      {
+        threadId: record.id,
+        model: 'deepseek-chat',
+        completedAt: '2026-06-04T00:00:05.000Z',
+        usage: { totalTokens: 25, turns: 1 }
+      }
+    ])
+  })
+
   it('recovers turn attachment ids from user messages when metadata is stripped', async () => {
     const { threadStore, sessionStore } = await createHybridStores()
     const thread = createThreadRecord({
@@ -316,4 +386,22 @@ describe('HybridThreadStore', () => {
       return false
     }
   }
+
+  function usage(overrides: Partial): UsageSnapshot {
+    const promptTokens = overrides.promptTokens ?? 10
+    const completionTokens = overrides.completionTokens ?? 5
+    const cacheHitTokens = overrides.cacheHitTokens ?? 0
+    const cacheMissTokens = overrides.cacheMissTokens ?? Math.max(promptTokens - cacheHitTokens, 0)
+    const cacheTotal = cacheHitTokens + cacheMissTokens
+    return {
+      promptTokens,
+      completionTokens,
+      totalTokens: overrides.totalTokens ?? promptTokens + completionTokens,
+      cachedTokens: overrides.cachedTokens ?? cacheHitTokens,
+      cacheHitTokens,
+      cacheMissTokens,
+      cacheHitRate: cacheTotal === 0 ? null : cacheHitTokens / cacheTotal,
+      turns: overrides.turns ?? 1
+    }
+  }
 })
diff --git a/kun/tests/image-gen-tool-provider.test.ts b/kun/tests/image-gen-tool-provider.test.ts
new file mode 100644
index 00000000..56b2584b
--- /dev/null
+++ b/kun/tests/image-gen-tool-provider.test.ts
@@ -0,0 +1,493 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { CapabilityRegistry } from '../src/adapters/tool/capability-registry.js'
+import { LocalToolHost } from '../src/adapters/tool/local-tool-host.js'
+import {
+  buildImageGenToolProviders,
+  mapImageSize,
+  MiniMaxImageClient,
+  minimaxImageDimensionFields,
+  openAiCompatImageUrl,
+  type ImageGenClient
+} from '../src/adapters/tool/image-gen-tool-provider.js'
+import { FileAttachmentStore } from '../src/attachments/attachment-store.js'
+import {
+  buildRuntimeCapabilityManifest,
+  KunCapabilitiesConfig
+} from '../src/contracts/capabilities.js'
+import { modelCapabilitiesForModel } from '../src/loop/model-context-profile.js'
+import type { ToolHostContext } from '../src/ports/tool-host.js'
+
+let workspace: string
+
+function buildContext(): ToolHostContext {
+  return {
+    threadId: 'thr_1',
+    turnId: 'turn_1',
+    workspace,
+    threadMode: 'agent',
+    approvalPolicy: 'auto',
+    abortSignal: new AbortController().signal,
+    awaitApproval: async () => 'allow'
+  }
+}
+
+function imageGenConfig(overrides: Record = {}) {
+  return KunCapabilitiesConfig.parse({
+    imageGen: {
+      enabled: true,
+      baseUrl: 'https://images.example.test/v1',
+      apiKey: 'sk-test',
+      model: 'test-image-model',
+      ...overrides
+    }
+  }).imageGen
+}
+
+function fakeClient(image = png(1024, 576)): ImageGenClient & { generateCalls: unknown[]; editCalls: unknown[] } {
+  const calls = { generateCalls: [] as unknown[], editCalls: [] as unknown[] }
+  return {
+    id: 'fake',
+    ...calls,
+    async generate(request) {
+      calls.generateCalls.push(request)
+      return { data: image, mimeType: 'image/png' }
+    },
+    async edit(request) {
+      calls.editCalls.push(request)
+      return { data: image, mimeType: 'image/png' }
+    }
+  }
+}
+
+function attachmentStore(rootDir: string, overrides: Record = {}) {
+  return new FileAttachmentStore({
+    rootDir,
+    config: KunCapabilitiesConfig.parse({ attachments: { enabled: true, ...overrides } }).attachments,
+    nowIso: () => '2026-06-10T00:00:00.000Z'
+  })
+}
+
+function hostFor(client: ImageGenClient, store?: FileAttachmentStore) {
+  return new LocalToolHost({
+    registry: new CapabilityRegistry(
+      buildImageGenToolProviders(imageGenConfig(), {
+        client,
+        attachmentStore: store,
+        nowIso: () => '2026-06-10T00:00:00.000Z'
+      }).providers
+    )
+  })
+}
+
+describe('Image gen tool provider', () => {
+  beforeEach(async () => {
+    workspace = await mkdtemp(join(tmpdir(), 'kun-imagegen-'))
+  })
+
+  afterEach(async () => {
+    vi.unstubAllGlobals()
+    await rm(workspace, { recursive: true, force: true })
+  })
+
+  it('does not build providers when image generation is disabled', () => {
+    const config = KunCapabilitiesConfig.parse({})
+    const built = buildImageGenToolProviders(config.imageGen)
+    expect(built.providers).toEqual([])
+    expect(built.diagnostics).toEqual([])
+    expect(built.available).toBe(false)
+  })
+
+  it('reports an unavailable provider without tools when configuration is incomplete', async () => {
+    const config = KunCapabilitiesConfig.parse({
+      imageGen: { enabled: true, baseUrl: 'https://images.example.test/v1', model: 'test-image-model' }
+    })
+    const built = buildImageGenToolProviders(config.imageGen)
+    expect(built.available).toBe(false)
+    expect(built.providers).toHaveLength(1)
+    expect(built.providers[0]).toMatchObject({ id: 'imageGen', enabled: true, available: false })
+    expect(built.providers[0].reason).toMatch(/missing apiKey/)
+    expect(built.providers[0].tools).toHaveLength(0)
+    expect(built.diagnostics[0]).toMatchObject({ enabled: true, available: false })
+  })
+
+  it('maps aspect ratio and size tier to provider sizes', () => {
+    expect(mapImageSize(undefined, undefined, undefined)).toBeUndefined()
+    expect(mapImageSize(undefined, undefined, '1536x1024')).toBe('1536x1024')
+    expect(mapImageSize(undefined, undefined, 'auto')).toBe('auto')
+    expect(mapImageSize('1:1', undefined, undefined)).toBe('1024x1024')
+    expect(mapImageSize('1:1', '2K', undefined)).toBe('2048x2048')
+    expect(mapImageSize('16:9', '1K', undefined)).toBe('1024x576')
+    expect(mapImageSize('9:16', '2K', undefined)).toBe('1152x2048')
+    expect(mapImageSize('21:9', '1K', undefined)).toBe('1024x448')
+    expect(mapImageSize('3:2', '1K', undefined)).toBe('1024x704')
+    // Unknown ratios fall back to a square at the requested tier.
+    expect(mapImageSize('7:5', '2K', undefined)).toBe('2048x2048')
+    expect(mapImageSize(undefined, '2K', undefined)).toBe('2048x2048')
+  })
+
+  it('keeps explicit width/height for MiniMax image-01 only', () => {
+    expect(minimaxImageDimensionFields('image-01', '768x1024')).toEqual({ width: 768, height: 1024 })
+    expect(minimaxImageDimensionFields(' image-01 ', '1024x576')).toEqual({ width: 1024, height: 576 })
+  })
+
+  it('maps sizes to the nearest aspect_ratio for other MiniMax models', () => {
+    // image-01-live rejects width/height with status 2013.
+    expect(minimaxImageDimensionFields('image-01-live', '768x1024')).toEqual({ aspect_ratio: '3:4' })
+    expect(minimaxImageDimensionFields('image-01-live', '1024x1024')).toEqual({ aspect_ratio: '1:1' })
+    expect(minimaxImageDimensionFields('image-01-live', '1024x576')).toEqual({ aspect_ratio: '16:9' })
+    // mapImageSize rounds edges to multiples of 64, so snap to the nearest ratio.
+    expect(minimaxImageDimensionFields('image-01-live', '1024x704')).toEqual({ aspect_ratio: '3:2' })
+    expect(minimaxImageDimensionFields('image-01-live', '1152x2048')).toEqual({ aspect_ratio: '9:16' })
+    // 21:9 is image-01 only; ultra-wide degrades to the closest supported ratio.
+    expect(minimaxImageDimensionFields('image-01-live', '1024x448')).toEqual({ aspect_ratio: '16:9' })
+  })
+
+  it('omits MiniMax dimension fields for non-WxH sizes', () => {
+    expect(minimaxImageDimensionFields('image-01-live', undefined)).toEqual({})
+    expect(minimaxImageDimensionFields('image-01-live', 'auto')).toEqual({})
+    expect(minimaxImageDimensionFields('image-01', '0x0')).toEqual({})
+  })
+
+  it('enables MiniMax prompt optimization for image requests', async () => {
+    const requests: Array<{ url: string; body: string }> = []
+    vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => {
+      requests.push({ url: String(url), body: String(init?.body) })
+      return new Response(JSON.stringify({
+        data: { image_base64: [png(8, 8).toString('base64')] },
+        base_resp: { status_code: 0 }
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }))
+    const client = new MiniMaxImageClient('https://api.minimaxi.com', 'sk-test')
+
+    await client.generate({
+      prompt: 'short prompt',
+      model: 'image-01',
+      size: '1024x768',
+      timeoutMs: 1_000,
+      signal: new AbortController().signal
+    })
+
+    expect(requests[0].url).toBe('https://api.minimaxi.com/v1/image_generation')
+    expect(JSON.parse(requests[0].body)).toMatchObject({
+      model: 'image-01',
+      prompt: 'short prompt',
+      width: 1024,
+      height: 768,
+      prompt_optimizer: true,
+      response_format: 'base64',
+      n: 1
+    })
+  })
+
+  it('inserts /v1 into unversioned OpenAI-compat image base urls like the chat client', () => {
+    // ZenMux-style API root without a version segment.
+    expect(openAiCompatImageUrl('https://zenmux.ai/api', 'generations'))
+      .toBe('https://zenmux.ai/api/v1/images/generations')
+    expect(openAiCompatImageUrl('https://zenmux.ai/api/', 'edits'))
+      .toBe('https://zenmux.ai/api/v1/images/edits')
+    expect(openAiCompatImageUrl('https://example.test', 'generations'))
+      .toBe('https://example.test/v1/images/generations')
+  })
+
+  it('keeps versioned and fully-qualified OpenAI-compat image base urls', () => {
+    expect(openAiCompatImageUrl('https://api.openai.com/v1', 'generations'))
+      .toBe('https://api.openai.com/v1/images/generations')
+    expect(openAiCompatImageUrl('https://ark.example.test/api/v3', 'edits'))
+      .toBe('https://ark.example.test/api/v3/images/edits')
+    expect(openAiCompatImageUrl('https://x.test/v1/images/generations', 'generations'))
+      .toBe('https://x.test/v1/images/generations')
+    // A fully-qualified generations URL still routes the edits call.
+    expect(openAiCompatImageUrl('https://x.test/v1/images/generations', 'edits'))
+      .toBe('https://x.test/v1/images/edits')
+  })
+
+  it('generates an image, saves it to the workspace, and scopes the attachment', async () => {
+    const client = fakeClient()
+    const store = attachmentStore(join(workspace, 'attachments'))
+    const host = hostFor(client, store)
+
+    const tools = await host.listTools(buildContext())
+    expect(tools.map((tool) => tool.name)).toEqual(['generate_image'])
+
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'generate_image',
+      arguments: { prompt: 'a sunset over the sea', aspect_ratio: '16:9', image_size: '1K' }
+    }, buildContext())
+
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: false })
+    if (result.item.kind !== 'tool_result') return
+    const output = result.item.output as {
+      files: Array<{ relativePath: string; absolutePath: string; mimeType: string; width: number; height: number }>
+      attachments: Array<{ id: string; mimeType: string }>
+      model: string
+      size: string
+      endpoint: string
+      warnings: string[]
+    }
+    expect(output.endpoint).toBe('generations')
+    expect(output.model).toBe('test-image-model')
+    expect(output.size).toBe('1024x576')
+    expect(output.warnings).toEqual([])
+    expect(output.files[0]).toMatchObject({ mimeType: 'image/png', width: 1024, height: 576 })
+    expect(output.files[0].relativePath.startsWith('.deepseekgui-images/')).toBe(true)
+    expect(existsSync(output.files[0].absolutePath)).toBe(true)
+    expect(JSON.stringify(output)).not.toMatch(/base64|b64_json/)
+    expect(client.generateCalls[0]).toMatchObject({ prompt: 'a sunset over the sea', size: '1024x576' })
+
+    expect(output.attachments).toHaveLength(1)
+    const id = output.attachments[0].id
+    await expect(store.resolveContent(id, { threadId: 'thr_1' })).resolves.toMatchObject({ mimeType: 'image/png' })
+    await expect(store.resolveContent(id, { threadId: 'thr_other' })).rejects.toThrow(/not authorized/)
+  })
+
+  it('posts generations as JSON and decodes b64_json responses', async () => {
+    const requests: Array<{ url: string; body: string }> = []
+    vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => {
+      requests.push({ url: String(url), body: String(init?.body) })
+      return new Response(JSON.stringify({ data: [{ b64_json: png(8, 8).toString('base64') }] }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }))
+    const host = new LocalToolHost({
+      registry: new CapabilityRegistry(buildImageGenToolProviders(imageGenConfig()).providers)
+    })
+
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'generate_image',
+      arguments: { prompt: 'tiny square' }
+    }, buildContext())
+
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: false })
+    expect(requests).toHaveLength(1)
+    expect(requests[0].url).toBe('https://images.example.test/v1/images/generations')
+    expect(JSON.parse(requests[0].body)).toMatchObject({
+      model: 'test-image-model',
+      prompt: 'tiny square',
+      n: 1,
+      response_format: 'b64_json'
+    })
+  })
+
+  it('downloads url responses and retries once without response_format when rejected', async () => {
+    let posts = 0
+    vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => {
+      const href = String(url)
+      if (href.endsWith('/images/generations')) {
+        posts += 1
+        const body = JSON.parse(String(init?.body)) as Record
+        if (posts === 1) {
+          expect(body.response_format).toBe('b64_json')
+          return new Response(JSON.stringify({ error: { message: 'Unknown parameter: response_format' } }), { status: 400 })
+        }
+        expect(body.response_format).toBeUndefined()
+        return new Response(JSON.stringify({ data: [{ url: 'https://cdn.example.test/img.png' }] }), {
+          status: 200,
+          headers: { 'content-type': 'application/json' }
+        })
+      }
+      expect(href).toBe('https://cdn.example.test/img.png')
+      return new Response(new Uint8Array(png(8, 8)), { status: 200, headers: { 'content-type': 'image/png' } })
+    }))
+    const host = new LocalToolHost({
+      registry: new CapabilityRegistry(buildImageGenToolProviders(imageGenConfig()).providers)
+    })
+
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'generate_image',
+      arguments: { prompt: 'legacy provider' }
+    }, buildContext())
+
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: false })
+    expect(posts).toBe(2)
+  })
+
+  it('sends reference images as multipart form data to /images/edits', async () => {
+    await writeFile(join(workspace, 'ref.png'), png(16, 16))
+    await writeFile(join(workspace, 'ref2.png'), png(16, 16))
+    const captured: Array<{ url: string; body: FormData }> = []
+    vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => {
+      captured.push({ url: String(url), body: init?.body as FormData })
+      return new Response(JSON.stringify({ data: [{ b64_json: png(8, 8).toString('base64') }] }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }))
+    const host = new LocalToolHost({
+      registry: new CapabilityRegistry(buildImageGenToolProviders(imageGenConfig()).providers)
+    })
+
+    const single = await host.execute({
+      callId: 'call_1',
+      toolName: 'generate_image',
+      arguments: { prompt: 'restyle', reference_image_paths: ['ref.png'] }
+    }, buildContext())
+    expect(single.item).toMatchObject({ kind: 'tool_result', isError: false })
+    if (single.item.kind === 'tool_result') {
+      expect((single.item.output as { endpoint: string }).endpoint).toBe('edits')
+    }
+    expect(captured[0].url).toBe('https://images.example.test/v1/images/edits')
+    expect(captured[0].body).toBeInstanceOf(FormData)
+    expect(captured[0].body.get('prompt')).toBe('restyle')
+    expect(captured[0].body.get('model')).toBe('test-image-model')
+    expect(captured[0].body.get('image')).toBeInstanceOf(Blob)
+    expect(captured[0].body.getAll('image[]')).toHaveLength(0)
+
+    const multi = await host.execute({
+      callId: 'call_2',
+      toolName: 'generate_image',
+      arguments: { prompt: 'restyle', reference_image_paths: ['ref.png', 'ref2.png'] }
+    }, buildContext())
+    expect(multi.item).toMatchObject({ kind: 'tool_result', isError: false })
+    expect(captured[1].body.getAll('image[]')).toHaveLength(2)
+  })
+
+  it('rejects reference paths that escape the workspace or are not images', async () => {
+    const client = fakeClient()
+    const host = hostFor(client)
+
+    for (const badPath of ['../outside.png', '/etc/hosts']) {
+      const result = await host.execute({
+        callId: 'call_1',
+        toolName: 'generate_image',
+        arguments: { prompt: 'escape', reference_image_paths: [badPath] }
+      }, buildContext())
+      expect(result.item).toMatchObject({ kind: 'tool_result', isError: true })
+      if (result.item.kind === 'tool_result') {
+        expect(result.item.output).toMatchObject({ error: { code: 'invalid_reference_path' } })
+      }
+    }
+
+    const missing = await host.execute({
+      callId: 'call_2',
+      toolName: 'generate_image',
+      arguments: { prompt: 'missing', reference_image_paths: ['nope.png'] }
+    }, buildContext())
+    expect(missing.item).toMatchObject({ kind: 'tool_result', isError: true })
+
+    await writeFile(join(workspace, 'notes.txt'), 'plain text')
+    const wrongType = await host.execute({
+      callId: 'call_3',
+      toolName: 'generate_image',
+      arguments: { prompt: 'wrong type', reference_image_paths: ['notes.txt'] }
+    }, buildContext())
+    expect(wrongType.item).toMatchObject({ kind: 'tool_result', isError: true })
+    if (wrongType.item.kind === 'tool_result') {
+      expect(wrongType.item.output).toMatchObject({
+        error: { code: 'invalid_reference_path', message: expect.stringContaining('png, jpeg, or webp') }
+      })
+    }
+    expect(client.editCalls).toHaveLength(0)
+  })
+
+  it('maps 404 from /images/edits to an actionable edits_unsupported error', async () => {
+    await writeFile(join(workspace, 'ref.png'), png(16, 16))
+    vi.stubGlobal('fetch', vi.fn(async () => new Response('Not Found', { status: 404 })))
+    const host = new LocalToolHost({
+      registry: new CapabilityRegistry(buildImageGenToolProviders(imageGenConfig()).providers)
+    })
+
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'generate_image',
+      arguments: { prompt: 'restyle', reference_image_paths: ['ref.png'] }
+    }, buildContext())
+
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: true })
+    if (result.item.kind === 'tool_result') {
+      expect(result.item.output).toMatchObject({
+        error: {
+          code: 'edits_unsupported',
+          message: expect.stringContaining('retry generate_image without reference_image_paths')
+        }
+      })
+    }
+  })
+
+  it('keeps the full provider HTTP error body in image generation errors', async () => {
+    const providerMessage = `Not supported model ${'mimo-v2.5-pro-ultraspeed'.repeat(40)}`
+    const body = JSON.stringify({ error: { code: '400', message: providerMessage } })
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 400 })))
+    const host = new LocalToolHost({
+      registry: new CapabilityRegistry(buildImageGenToolProviders(imageGenConfig()).providers)
+    })
+
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'generate_image',
+      arguments: { prompt: 'draw a poster' }
+    }, buildContext())
+
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: true })
+    if (result.item.kind === 'tool_result') {
+      const output = result.item.output as { error: { message: string } }
+      expect(output.error.message).toBe(`HTTP 400: ${body}`)
+      expect(output.error.message).toContain(providerMessage)
+    }
+  })
+
+  it('keeps the generated file and degrades to a warning when the attachment store rejects', async () => {
+    const client = fakeClient()
+    const store = attachmentStore(join(workspace, 'attachments'), { maxImageBytes: 16 })
+    const host = hostFor(client, store)
+
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'generate_image',
+      arguments: { prompt: 'too large for previews' }
+    }, buildContext())
+
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: false })
+    if (result.item.kind !== 'tool_result') return
+    const output = result.item.output as { files: Array<{ absolutePath: string }>; attachments: unknown[]; warnings: string[] }
+    expect(output.files).toHaveLength(1)
+    expect(existsSync(output.files[0].absolutePath)).toBe(true)
+    expect(output.attachments).toEqual([])
+    expect(output.warnings[0]).toMatch(/inline preview unavailable/)
+  })
+
+  it('reports image generation availability in the runtime capability manifest', () => {
+    const config = KunCapabilitiesConfig.parse({
+      imageGen: {
+        enabled: true,
+        baseUrl: 'https://images.example.test/v1',
+        apiKey: 'sk-test',
+        model: 'test-image-model'
+      }
+    })
+    const built = buildImageGenToolProviders(config.imageGen, { client: fakeClient() })
+    const manifest = buildRuntimeCapabilityManifest({
+      config,
+      model: modelCapabilitiesForModel('deepseek-chat'),
+      imageGen: { available: built.available }
+    })
+
+    expect(manifest.imageGen.available).toBe(true)
+    expect(manifest.imageGen.model).toBe('test-image-model')
+  })
+})
+
+function png(width: number, height: number): Buffer {
+  const buffer = Buffer.alloc(24)
+  buffer[0] = 0x89
+  buffer[1] = 0x50
+  buffer[2] = 0x4e
+  buffer[3] = 0x47
+  buffer[4] = 0x0d
+  buffer[5] = 0x0a
+  buffer[6] = 0x1a
+  buffer[7] = 0x0a
+  buffer.writeUInt32BE(width, 16)
+  buffer.writeUInt32BE(height, 20)
+  return buffer
+}
diff --git a/kun/tests/loop-test-harness.ts b/kun/tests/loop-test-harness.ts
index c49281f2..f8fe80db 100644
--- a/kun/tests/loop-test-harness.ts
+++ b/kun/tests/loop-test-harness.ts
@@ -23,6 +23,7 @@ import type { MemoryStore } from '../src/memory/memory-store.js'
 import type { TokenEconomyConfig } from '../src/loop/token-economy.js'
 import type { ToolStormBreakerOptions } from '../src/loop/tool-storm-breaker.js'
 import type { ContextCompactionConfig } from '../src/loop/model-context-profile.js'
+import type { ResolvedHook } from '../src/hooks/hook-engine.js'
 
 export type Harness = {
   threadId: string
@@ -84,6 +85,7 @@ export function makeHarness(
     toolArgumentRepair?: {
       maxStringBytes?: number
     }
+    hooks?: readonly ResolvedHook[]
   } = {}
 ): Harness {
   const bus = new InMemoryEventBus()
@@ -94,7 +96,10 @@ export function makeHarness(
   const inflight = new InflightTracker()
   const steering = new SteeringQueue()
   const compactor = options.compactor ?? new ContextCompactor({ softThreshold: 64, hardThreshold: 128 })
-  const toolHost = new LocalToolHost({ tools: options.tools ?? defaultLocalTools })
+  const toolHost = new LocalToolHost({
+    tools: options.tools ?? defaultLocalTools,
+    ...(options.hooks ? { hooks: options.hooks } : {})
+  })
   const usage = new UsageService()
   const nowIso = () => new Date().toISOString()
   const nowMs = options.nowMs ?? (() => Date.now())
@@ -137,7 +142,8 @@ export function makeHarness(
     ...(options.tokenEconomy ? { tokenEconomy: options.tokenEconomy } : {}),
     ...(options.contextCompaction ? { contextCompaction: options.contextCompaction } : {}),
     ...(options.toolStorm ? { toolStorm: options.toolStorm } : {}),
-    ...(options.toolArgumentRepair ? { toolArgumentRepair: options.toolArgumentRepair } : {})
+    ...(options.toolArgumentRepair ? { toolArgumentRepair: options.toolArgumentRepair } : {}),
+    ...(options.hooks ? { hooks: options.hooks } : {})
   })
 
   return {
diff --git a/kun/tests/loop.test.ts b/kun/tests/loop.test.ts
index 60b63bdb..1890ecee 100644
--- a/kun/tests/loop.test.ts
+++ b/kun/tests/loop.test.ts
@@ -10,9 +10,20 @@ import { FileThreadStore, FileSessionStore } from '../src/adapters/file/index.js
 import { RuntimeEventRecorder } from '../src/services/runtime-event-recorder.js'
 import { ContextCompactor } from '../src/loop/context-compactor.js'
 import { resolveModelContextProfile } from '../src/loop/model-context-profile.js'
-import { makeAssistantTextItem, makeToolCallItem, makeUserItem } from '../src/domain/item.js'
+import {
+  makeApprovalItem,
+  makeAssistantTextItem,
+  makeToolCallItem,
+  makeToolResultItem,
+  makeUserInputItem,
+  makeUserItem
+} from '../src/domain/item.js'
 import { createThreadRecord } from '../src/domain/thread.js'
 import { createImmutablePrefix, setSystemPrompt } from '../src/cache/immutable-prefix.js'
+import { InflightTracker } from '../src/loop/inflight-tracker.js'
+import { SteeringQueue } from '../src/loop/steering-queue.js'
+import { SequentialIdGenerator } from '../src/ports/id-generator.js'
+import { TurnService } from '../src/services/turn-service.js'
 import type { TurnItem } from '../src/contracts/items.js'
 import type { ModelRequest, ModelStreamChunk } from '../src/ports/model-client.js'
 import {
@@ -99,8 +110,9 @@ describe('AgentLoop', () => {
     expect(status).toBe('failed')
     expect(failed).toMatchObject({
       kind: 'turn_failed',
-      message: 'model stream exploded'
+      message: expect.stringContaining('model stream exploded')
     })
+    expect(failed?.kind === 'turn_failed' ? failed.message : '').toContain('[Kun turn failed]')
   })
 
   it('fails the turn when the model stream yields an error chunk', async () => {
@@ -122,7 +134,13 @@ describe('AgentLoop', () => {
       event.message === 'model request failed with status 400' &&
       event.code === 'http_400'
     )).toBe(true)
-    expect(events.some((event) => event.kind === 'turn_failed')).toBe(true)
+    const failed = events.find((event) => event.kind === 'turn_failed')
+    expect(failed).toMatchObject({
+      kind: 'turn_failed',
+      message: 'model request failed with status 400',
+      code: 'http_400',
+      severity: 'error'
+    })
   })
 
   it('emits named pipeline lifecycle stages for a model request', async () => {
@@ -150,6 +168,54 @@ describe('AgentLoop', () => {
     ])
   })
 
+  it('records provider endpoint diagnostics for model send stages', async () => {
+    const model = {
+      provider: 'deepseek-compat',
+      model: 'MiniMax-M2',
+      config: {
+        baseUrl: 'https://user:secret@api.minimaxi.com/anthropic?token=hidden#debug',
+        endpointFormat: 'messages',
+        model: 'MiniMax-M2'
+      },
+      async *stream(): AsyncIterable {
+        yield { kind: 'completed', stopReason: 'stop' }
+      }
+    }
+    const h = makeHarness(model)
+    await bootstrapThread(h, {
+      request: { prompt: 'hello', model: 'mimo-v2.5-pro-ultraspeed' }
+    })
+
+    await h.loop.runTurn(h.threadId, h.turnId)
+    const events = await h.sessionStore.loadEventsSince(h.threadId, 0)
+    const preSend = events.find((event) =>
+      event.kind === 'pipeline_stage' && event.stage === 'pre_send'
+    )
+    const postSend = events.find((event) =>
+      event.kind === 'pipeline_stage' && event.stage === 'post_send'
+    )
+
+    expect(preSend).toMatchObject({
+      kind: 'pipeline_stage',
+      stage: 'pre_send',
+      details: {
+        model: 'mimo-v2.5-pro-ultraspeed',
+        provider: 'deepseek-compat',
+        providerBaseUrl: 'https://api.minimaxi.com/anthropic',
+        endpointFormat: 'messages',
+        configuredModel: 'MiniMax-M2'
+      }
+    })
+    expect(postSend).toMatchObject({
+      kind: 'pipeline_stage',
+      stage: 'post_send',
+      details: {
+        model: 'mimo-v2.5-pro-ultraspeed',
+        providerBaseUrl: 'https://api.minimaxi.com/anthropic'
+      }
+    })
+  })
+
   it('aborts the turn when the abort signal fires', async () => {
     const h = makeHarness({
       provider: 'blocker',
@@ -870,28 +936,46 @@ describe('AgentLoop', () => {
         return { output: { done: true } }
       }
     })
-    const h = makeHarness(makeFakeModel([
-      {
-        kind: 'tool_call_complete',
-        callId: 'call_streamer',
-        toolName: 'streamer',
-        arguments: {}
-      },
-      { kind: 'completed', stopReason: 'tool_calls' },
-      { kind: 'completed', stopReason: 'stop' }
-    ]), { tools: [streamingTool] })
+    let calls = 0
+    const h = makeHarness({
+      provider: 'streaming-tool',
+      model: 'streaming-tool',
+      async *stream(): AsyncIterable {
+        calls += 1
+        if (calls === 1) {
+          yield {
+            kind: 'tool_call_complete',
+            callId: 'call_streamer',
+            toolName: 'streamer',
+            arguments: {}
+          }
+          yield { kind: 'completed', stopReason: 'tool_calls' }
+          return
+        }
+        yield { kind: 'completed', stopReason: 'stop' }
+      }
+    }, { tools: [streamingTool] })
     await bootstrapThread(h)
     const status = await h.loop.runTurn(h.threadId, h.turnId)
     expect(status).toBe('completed')
     const events = await h.sessionStore.loadEventsSince(h.threadId, 0)
-    expect(events.some((event) => event.kind === 'item_updated')).toBe(true)
     const partialUpdate = events.find(
       (event) =>
-        event.kind === 'item_updated' &&
+        (event.kind === 'item_created' || event.kind === 'item_updated') &&
         event.item.kind === 'tool_result' &&
+        event.item.status === 'running' &&
         (event.item.output as { partial?: string }).partial === 'hello'
     )
     expect(partialUpdate).toBeDefined()
+    const thread = await h.threadStore.get(h.threadId)
+    const finalResult = thread?.turns
+      .flatMap((turn) => turn.items)
+      .find((item) => item.kind === 'tool_result' && item.callId === 'call_streamer')
+    expect(finalResult).toMatchObject({
+      kind: 'tool_result',
+      status: 'completed',
+      output: { done: true }
+    })
   })
 
   it('waits for GUI user input tool responses and resumes the turn', async () => {
@@ -1439,6 +1523,120 @@ describe('AgentLoop', () => {
     }
   })
 
+  it('rejects forged write calls during plan mode without touching workspace files', async () => {
+    const workspace = await mkdtemp(join(tmpdir(), 'kun-loop-plan-forged-write-'))
+    const observedToolLists: string[][] = []
+    let calls = 0
+    try {
+      const h = makeHarness(
+        {
+          provider: 'planner',
+          model: 'planner',
+          async *stream(request: ModelRequest): AsyncIterable {
+            observedToolLists.push(request.tools.map((tool) => tool.name))
+            calls += 1
+            if (calls === 1) {
+              yield {
+                kind: 'tool_call_complete',
+                callId: 'call_write',
+                toolName: 'write',
+                arguments: {
+                  path: 'forbidden.txt',
+                  content: 'should not exist'
+                }
+              }
+              yield { kind: 'completed', stopReason: 'tool_calls' }
+              return
+            }
+            yield { kind: 'assistant_text_delta', text: '## Plan\nStay read-only until build mode.\n' }
+            yield { kind: 'completed', stopReason: 'stop' }
+          }
+        },
+        { tools: buildDefaultLocalTools() }
+      )
+      await bootstrapThread(h, {
+        workspace,
+        request: {
+          prompt: 'Plan a safe change',
+          mode: 'plan'
+        }
+      })
+
+      const status = await h.loop.runTurn(h.threadId, h.turnId)
+      const items = await h.sessionStore.loadItems(h.threadId)
+      const writeCall = items.find((item) => item.kind === 'tool_call' && item.toolName === 'write')
+      const writeResult = items.find((item) => item.kind === 'tool_result' && item.toolName === 'write')
+
+      expect(status).toBe('completed')
+      expect(observedToolLists[0]).not.toEqual(expect.arrayContaining(['write', 'edit', 'bash']))
+      expect(writeCall).toMatchObject({ kind: 'tool_call', status: 'failed' })
+      expect(writeResult).toMatchObject({ kind: 'tool_result', isError: true })
+      expect(writeResult?.kind === 'tool_result' ? JSON.stringify(writeResult.output) : '')
+        .toContain('not advertised by active tool policy')
+      await expect(readFile(join(workspace, 'forbidden.txt'), 'utf8')).rejects.toThrow()
+      await expect(readFile(join(workspace, '.kunsdd/plan/plan-a-safe-change.md'), 'utf8')).resolves.toBe(
+        '## Plan\nStay read-only until build mode.'
+      )
+    } finally {
+      await rm(workspace, { recursive: true, force: true })
+    }
+  })
+
+  it('rejects forged bash calls during plan mode without running mutating commands', async () => {
+    const workspace = await mkdtemp(join(tmpdir(), 'kun-loop-plan-forged-bash-'))
+    let calls = 0
+    try {
+      const h = makeHarness(
+        {
+          provider: 'planner',
+          model: 'planner',
+          async *stream(): AsyncIterable {
+            calls += 1
+            if (calls === 1) {
+              yield {
+                kind: 'tool_call_complete',
+                callId: 'call_bash',
+                toolName: 'bash',
+                arguments: {
+                  command: 'touch forbidden.txt'
+                }
+              }
+              yield { kind: 'completed', stopReason: 'tool_calls' }
+              return
+            }
+            yield { kind: 'assistant_text_delta', text: '## Plan\nUse read-only inspection only.\n' }
+            yield { kind: 'completed', stopReason: 'stop' }
+          }
+        },
+        { tools: buildDefaultLocalTools() }
+      )
+      await bootstrapThread(h, {
+        workspace,
+        request: {
+          prompt: 'Plan without shell mutations',
+          mode: 'plan'
+        }
+      })
+
+      const status = await h.loop.runTurn(h.threadId, h.turnId)
+      const items = await h.sessionStore.loadItems(h.threadId)
+      const bashCall = items.find((item) => item.kind === 'tool_call' && item.toolName === 'bash')
+      const bashResult = items.find((item) => item.kind === 'tool_result' && item.toolName === 'bash')
+
+      expect(status).toBe('completed')
+      expect(bashCall).toMatchObject({ kind: 'tool_call', status: 'failed' })
+      expect(bashResult).toMatchObject({ kind: 'tool_result', isError: true })
+      expect(bashResult?.kind === 'tool_result' ? JSON.stringify(bashResult.output) : '')
+        .toContain('not advertised by active tool policy')
+      await expect(readFile(join(workspace, 'forbidden.txt'), 'utf8')).rejects.toThrow()
+      await expect(readFile(join(workspace, '.kunsdd/plan/plan-without-shell-mutations.md'), 'utf8')).resolves.toBe(
+        '## Plan\nUse read-only inspection only.'
+      )
+    } finally {
+      await rm(workspace, { recursive: true, force: true })
+    }
+  })
+
   it('fails GUI plan turns only when neither create_plan nor plan text is returned', async () => {
     const workspace = await mkdtemp(join(tmpdir(), 'kun-loop-plan-empty-'))
     try {
@@ -2312,6 +2510,111 @@ describe('FileSessionStore', () => {
     expect(event.seq).toBe(8)
   })
 
+  it.each([
+    ['aborted', 'aborted'],
+    ['failed', 'failed']
+  ] as const)('finalizes open turn items in messages.jsonl when a turn is %s', async (finalStatus, expectedToolStatus) => {
+    const nowIso = () => '2026-06-05T00:00:00.000Z'
+    const threadId = `thr_finalize_${finalStatus}`
+    const threadStore = new FileThreadStore({ dataDir, now: () => new Date(nowIso()) })
+    const sessionStore = new FileSessionStore({ dataDir })
+    const bus = new InMemoryEventBus()
+    const turns = new TurnService({
+      threadStore,
+      sessionStore,
+      events: new RuntimeEventRecorder({
+        eventBus: bus,
+        sessionStore,
+        allocateSeq: (id) => bus.allocateSeq(id),
+        nowIso
+      }),
+      inflight: new InflightTracker(),
+      steering: new SteeringQueue(),
+      compactor: new ContextCompactor({ softThreshold: 64, hardThreshold: 128 }),
+      ids: new SequentialIdGenerator(),
+      nowIso
+    })
+
+    await threadStore.upsert(
+      createThreadRecord({ id: threadId, title: 'demo', workspace: '/tmp', model: 'm' })
+    )
+    const { turnId } = await turns.startTurn({
+      threadId,
+      request: { prompt: 'run a tool' }
+    })
+    await turns.applyItem(
+      threadId,
+      makeToolCallItem({
+        id: 'item_tool_open',
+        turnId,
+        threadId,
+        callId: 'call_open',
+        toolName: 'echo',
+        arguments: { text: 'hi' }
+      })
+    )
+    await turns.applyItem(
+      threadId,
+      makeToolResultItem({
+        id: 'item_result_open',
+        turnId,
+        threadId,
+        callId: 'call_open',
+        toolName: 'echo',
+        output: { partial: true },
+        status: 'running'
+      })
+    )
+    await turns.applyItem(
+      threadId,
+      makeApprovalItem({
+        id: 'item_approval_open',
+        turnId,
+        threadId,
+        approvalId: 'approval_open',
+        toolName: 'echo',
+        summary: 'Approve echo'
+      })
+    )
+    await turns.applyItem(
+      threadId,
+      makeUserInputItem({
+        id: 'item_input_open',
+        turnId,
+        threadId,
+        inputId: 'input_open',
+        prompt: 'Need input'
+      })
+    )
+
+    if (finalStatus === 'aborted') {
+      await turns.interruptTurn({ threadId, turnId })
+    } else {
+      await turns.finishTurn({ threadId, turnId, status: 'failed', error: 'boom' })
+    }
+
+    const latestById = new Map((await sessionStore.loadItems(threadId)).map((item) => [item.id, item]))
+    expect(latestById.get('item_tool_open')?.status).toBe(expectedToolStatus)
+    expect(latestById.get('item_result_open')?.status).toBe(expectedToolStatus)
+    expect(latestById.get('item_approval_open')?.status).toBe('expired')
+    expect(latestById.get('item_input_open')?.status).toBe('cancelled')
+    expect(
+      [...latestById.values()].some((item) =>
+        item.turnId === turnId && (item.status === 'pending' || item.status === 'running')
+      )
+    ).toBe(false)
+
+    const rawMessages = await readFile(join(dataDir, 'threads', threadId, 'messages.jsonl'), 'utf-8')
+    const messageLines = rawMessages
+      .trim()
+      .split('\n')
+      .map((line) => JSON.parse(line) as TurnItem)
+    expect(messageLines.filter((item) => item.id === 'item_tool_open').map((item) => item.status))
+      .toEqual(['pending', expectedToolStatus])
+    expect(messageLines.filter((item) => item.id === 'item_result_open').map((item) => item.status))
+      .toEqual(['running', expectedToolStatus])
+  })
+
   it('survives a malformed JSONL line', async () => {
     const sessionStore = new FileSessionStore({ dataDir })
     await mkdir(join(dataDir, 'threads', 'thr_y'), { recursive: true })
diff --git a/kun/tests/mcp-tool-provider.test.ts b/kun/tests/mcp-tool-provider.test.ts
index aea45aed..9feea2d7 100644
--- a/kun/tests/mcp-tool-provider.test.ts
+++ b/kun/tests/mcp-tool-provider.test.ts
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
 import { CapabilityRegistry } from '../src/adapters/tool/capability-registry.js'
 import { LocalToolHost } from '../src/adapters/tool/local-tool-host.js'
 import {
+  buildMcpStdioEnvironment,
   buildMcpToolProviders,
+  formatMcpConnectionError,
   isMcpServerTrusted,
   normalizeMcpToolName,
   type McpClientLike
@@ -58,6 +60,65 @@ describe('MCP tool provider', () => {
     expect(normalizeMcpToolName('GitHub Server', 'Search Issues')).toBe('mcp_github_server_search_issues')
   })
 
+  it('adds common GUI app command paths to stdio MCP environments', () => {
+    const env = buildMcpStdioEnvironment({ NODE_ENV: 'test' }, {
+      platform: 'darwin',
+      baseEnv: {
+        PATH: '/usr/bin:/opt/homebrew/bin',
+        HOME: '/Users/alice'
+      }
+    })
+
+    expect(env.NODE_ENV).toBe('test')
+    expect(env.PATH?.split(':')).toEqual([
+      '/usr/bin',
+      '/opt/homebrew/bin',
+      '/usr/local/bin',
+      '/opt/local/bin',
+      '/Users/alice/.volta/bin',
+      '/Users/alice/.local/bin',
+      '/Users/alice/.bun/bin'
+    ])
+  })
+
+  it('keeps explicitly configured stdio MCP PATH values ahead of common paths', () => {
+    const env = buildMcpStdioEnvironment({ Path: 'C:\\Tools' }, {
+      platform: 'win32',
+      baseEnv: {
+        APPDATA: 'C:\\Users\\alice\\AppData\\Roaming',
+        ProgramFiles: 'C:\\Program Files',
+        PATH: 'C:\\Windows\\System32'
+      }
+    })
+
+    expect(env.Path?.split(';')).toEqual([
+      'C:\\Tools',
+      'C:\\Users\\alice\\AppData\\Roaming\\npm',
+      'C:\\Program Files\\nodejs'
+    ])
+  })
+
+  it('formats missing stdio MCP commands with an actionable PATH hint', () => {
+    const server = KunCapabilitiesConfig.parse({
+      mcp: {
+        enabled: true,
+        servers: {
+          filesystem: {
+            transport: 'stdio',
+            command: 'npx',
+            trustScope: 'user'
+          }
+        }
+      }
+    }).mcp.servers.filesystem
+    const error = Object.assign(new Error('spawn npx ENOENT'), {
+      code: 'ENOENT',
+      path: 'npx'
+    })
+
+    expect(formatMcpConnectionError(error, server)).toContain('Could not find "npx" on PATH')
+  })
+
   it('evaluates workspace trust scopes', () => {
     const server = {
       enabled: true,
@@ -289,6 +350,36 @@ describe('MCP tool provider', () => {
     })
   })
 
+  it('records actionable diagnostics when stdio MCP commands are missing', async () => {
+    const config = KunCapabilitiesConfig.parse({
+      mcp: {
+        enabled: true,
+        servers: {
+          filesystem: {
+            transport: 'stdio',
+            command: 'npx',
+            trustScope: 'user'
+          }
+        }
+      }
+    })
+    const built = await buildMcpToolProviders(config.mcp, {
+      clientFactory: async () => {
+        throw Object.assign(new Error('spawn npx ENOENT'), {
+          code: 'ENOENT',
+          path: 'npx'
+        })
+      }
+    })
+
+    expect(built.providers).toEqual([])
+    expect(built.diagnostics[0]).toMatchObject({
+      id: 'filesystem',
+      status: 'error'
+    })
+    expect(built.diagnostics[0]?.lastError).toContain('Could not find "npx" on PATH')
+  })
+
   it('passes MCP timeouts and abort signals to discovery and execution', async () => {
     const listOptions: Array<{ signal?: AbortSignal; timeout?: number } | undefined> = []
     const callOptions: Array<{ signal?: AbortSignal; timeout?: number } | undefined> = []
@@ -401,6 +492,60 @@ describe('MCP tool provider', () => {
     })
   })
 
+  it('surfaces deterministic MCP protocol errors as tool results without reconnecting', async () => {
+    let factories = 0
+    const config = KunCapabilitiesConfig.parse({
+      mcp: {
+        enabled: true,
+        servers: {
+          github: {
+            transport: 'stdio',
+            command: 'node',
+            trustScope: 'workspace',
+            trustedWorkspaceRoots: ['/tmp/project']
+          }
+        }
+      }
+    })
+    const built = await buildMcpToolProviders(config.mcp, {
+      clientFactory: async () => {
+        factories += 1
+        return {
+          async listTools() {
+            return {
+              tools: [
+                {
+                  name: 'search',
+                  inputSchema: { type: 'object' },
+                  annotations: { readOnlyHint: true }
+                }
+              ]
+            }
+          },
+          async callTool() {
+            throw new Error('MCP error -32603: Validation Error: Validation Failed')
+          },
+          async close() {}
+        }
+      }
+    })
+    const host = new LocalToolHost({ registry: new CapabilityRegistry(built.providers) })
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'mcp_github_search',
+      arguments: {}
+    }, buildContext('/tmp/project'))
+
+    expect(factories).toBe(1)
+    expect(result.item.kind).toBe('tool_result')
+    if (result.item.kind !== 'tool_result') throw new Error('expected tool_result')
+    expect(result.item.isError).toBe(true)
+    expect(result.item.output).toMatchObject({
+      code: 'tool_execution_failed',
+      error: expect.stringContaining('-32603')
+    })
+  })
+
   it('reports catalog drift after refreshing MCP search records', async () => {
     let expanded = false
     const config = KunCapabilitiesConfig.parse({
diff --git a/kun/tests/media-gen-tool-provider.test.ts b/kun/tests/media-gen-tool-provider.test.ts
new file mode 100644
index 00000000..dc3eade1
--- /dev/null
+++ b/kun/tests/media-gen-tool-provider.test.ts
@@ -0,0 +1,387 @@
+import { existsSync } from 'node:fs'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { CapabilityRegistry } from '../src/adapters/tool/capability-registry.js'
+import { LocalToolHost } from '../src/adapters/tool/local-tool-host.js'
+import {
+  buildMusicGenToolProviders,
+  buildSpeechGenToolProviders,
+  buildVideoGenToolProviders,
+  MimoSpeechClient,
+  MiniMaxMusicClient,
+  MiniMaxSpeechClient,
+  MiniMaxVideoClient,
+  type MusicGenClient,
+  type SpeechGenClient,
+  type VideoGenClient
+} from '../src/adapters/tool/media-gen-tool-provider.js'
+import { KunCapabilitiesConfig } from '../src/contracts/capabilities.js'
+import type { ToolExecutionUpdate, ToolHostContext } from '../src/ports/tool-host.js'
+
+let workspace: string
+
+function buildContext(): ToolHostContext {
+  return {
+    threadId: 'thr_1',
+    turnId: 'turn_1',
+    workspace,
+    threadMode: 'agent',
+    approvalPolicy: 'auto',
+    abortSignal: new AbortController().signal,
+    awaitApproval: async () => 'allow'
+  }
+}
+
+function fixedNow() {
+  return '2026-06-10T00:00:00.000Z'
+}
+
+describe('Media gen tool provider', () => {
+  beforeEach(async () => {
+    workspace = await mkdtemp(join(tmpdir(), 'kun-mediagen-'))
+  })
+
+  afterEach(async () => {
+    vi.unstubAllGlobals()
+    await rm(workspace, { recursive: true, force: true })
+  })
+
+  it('reports unavailable media providers without tools when configuration is incomplete', () => {
+    const config = KunCapabilitiesConfig.parse({
+      speechGen: { enabled: true, baseUrl: 'https://media.example.test/v1', model: 'speech-test' },
+      musicGen: { enabled: true, baseUrl: 'https://media.example.test/v1', model: 'music-test' },
+      videoGen: { enabled: true, baseUrl: 'https://media.example.test/v1', model: 'video-test' }
+    })
+
+    const speech = buildSpeechGenToolProviders(config.speechGen)
+    const music = buildMusicGenToolProviders(config.musicGen)
+    const video = buildVideoGenToolProviders(config.videoGen)
+
+    expect(speech.available).toBe(false)
+    expect(music.available).toBe(false)
+    expect(video.available).toBe(false)
+    expect(speech.providers[0]).toMatchObject({ id: 'speechGen', available: false, tools: [] })
+    expect(music.providers[0]).toMatchObject({ id: 'musicGen', available: false, tools: [] })
+    expect(video.providers[0]).toMatchObject({ id: 'videoGen', available: false, tools: [] })
+    expect(speech.diagnostics[0].reason).toMatch(/missing apiKey/)
+    expect(music.diagnostics[0].reason).toMatch(/missing apiKey/)
+    expect(video.diagnostics[0].reason).toMatch(/missing apiKey/)
+  })
+
+  it('generates speech, music, and video files through configured media tools', async () => {
+    const speechCalls: unknown[] = []
+    const musicCalls: unknown[] = []
+    const videoCalls: unknown[] = []
+    const speechClient: SpeechGenClient = {
+      id: 'fake-speech',
+      async generate(request) {
+        speechCalls.push(request)
+        return { data: Buffer.from('speech-bytes'), mimeType: 'audio/mpeg', extension: 'mp3' }
+      }
+    }
+    const musicClient: MusicGenClient = {
+      id: 'fake-music',
+      async generate(request) {
+        musicCalls.push(request)
+        return { data: Buffer.from('music-bytes'), mimeType: 'audio/mpeg', extension: 'mp3' }
+      }
+    }
+    const videoClient: VideoGenClient = {
+      id: 'fake-video',
+      async generate(request) {
+        videoCalls.push(request)
+        await request.onUpdate?.({ output: { status: 'submitted', provider: 'fake-video' } })
+        return { data: Buffer.from('video-bytes'), mimeType: 'video/mp4', extension: 'mp4' }
+      }
+    }
+    const config = KunCapabilitiesConfig.parse({
+      speechGen: {
+        enabled: true,
+        baseUrl: 'https://media.example.test/v1',
+        apiKey: 'sk-speech',
+        model: 'speech-test',
+        voice: 'voice-1',
+        format: 'mp3'
+      },
+      musicGen: {
+        enabled: true,
+        baseUrl: 'https://media.example.test/v1',
+        apiKey: 'sk-music',
+        model: 'music-test',
+        format: 'mp3'
+      },
+      videoGen: {
+        enabled: true,
+        baseUrl: 'https://media.example.test/v1',
+        apiKey: 'sk-video',
+        model: 'video-test',
+        defaultDuration: 6,
+        defaultResolution: '1080P'
+      }
+    })
+    const providers = [
+      ...buildSpeechGenToolProviders(config.speechGen, { speechClient, nowIso: fixedNow }).providers,
+      ...buildMusicGenToolProviders(config.musicGen, { musicClient, nowIso: fixedNow }).providers,
+      ...buildVideoGenToolProviders(config.videoGen, { videoClient, nowIso: fixedNow }).providers
+    ]
+    const host = new LocalToolHost({ registry: new CapabilityRegistry(providers) })
+    const context = buildContext()
+
+    expect((await host.listTools(context)).map((tool) => tool.name)).toEqual([
+      'generate_speech',
+      'generate_music',
+      'generate_video'
+    ])
+
+    const speech = await host.execute({
+      callId: 'call_speech',
+      toolName: 'generate_speech',
+      arguments: { text: 'hello world' }
+    }, context)
+    const music = await host.execute({
+      callId: 'call_music',
+      toolName: 'generate_music',
+      arguments: { prompt: 'bright synth pop', instrumental: true }
+    }, context)
+    const updates: ToolExecutionUpdate[] = []
+    const video = await host.execute({
+      callId: 'call_video',
+      toolName: 'generate_video',
+      arguments: { prompt: 'a product demo', duration: 8, resolution: '768P' }
+    }, context, (item) => {
+      if (item.kind === 'tool_result') updates.push({ output: item.output, isError: item.isError })
+    })
+
+    const speechOutput = outputFor(speech.item)
+    const musicOutput = outputFor(music.item)
+    const videoOutput = outputFor(video.item)
+    expect(speechOutput).toMatchObject({ model: 'speech-test', voice: 'voice-1', format: 'mp3' })
+    expect(musicOutput).toMatchObject({ model: 'music-test', format: 'mp3' })
+    expect(videoOutput).toMatchObject({ model: 'video-test', duration: 8, resolution: '768P' })
+    await expectFile(speechOutput, '.deepseekgui-audio/', 'audio/mpeg', 'speech-bytes')
+    await expectFile(musicOutput, '.deepseekgui-music/', 'audio/mpeg', 'music-bytes')
+    await expectFile(videoOutput, '.deepseekgui-videos/', 'video/mp4', 'video-bytes')
+    expect(speechCalls[0]).toMatchObject({ text: 'hello world', model: 'speech-test', voice: 'voice-1' })
+    expect(musicCalls[0]).toMatchObject({ prompt: 'bright synth pop', instrumental: true, model: 'music-test' })
+    expect(videoCalls[0]).toMatchObject({ prompt: 'a product demo', duration: 8, resolution: '768P', model: 'video-test' })
+    expect(updates[0]).toMatchObject({ output: { status: 'submitted', provider: 'fake-video' } })
+  })
+
+  it('posts MiniMax speech and music requests to the documented endpoints and decodes hex audio', async () => {
+    const requests: Array<{ url: string; headers: Headers; body: Record }> = []
+    vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => {
+      requests.push({
+        url: String(url),
+        headers: new Headers(init?.headers),
+        body: JSON.parse(String(init?.body)) as Record
+      })
+      return new Response(JSON.stringify({
+        base_resp: { status_code: 0, status_msg: 'success' },
+        data: { audio: Buffer.from(requests.length === 1 ? 'speech' : 'music').toString('hex') }
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }))
+    const signal = new AbortController().signal
+
+    const speech = await new MiniMaxSpeechClient('https://api.minimax.io', 'sk-test').generate({
+      text: 'Hello from MiniMax',
+      model: 'speech-2.8-hd',
+      voice: 'female-shaonv',
+      format: 'mp3',
+      timeoutMs: 120000,
+      signal
+    })
+    const music = await new MiniMaxMusicClient('https://api.minimax.io', 'sk-test').generate({
+      prompt: 'Pop, bright, upbeat',
+      lyrics: 'hello\nworld',
+      model: 'music-2.6',
+      format: 'mp3',
+      timeoutMs: 300000,
+      signal
+    })
+
+    expect(speech.data.toString('utf8')).toBe('speech')
+    expect(music.data.toString('utf8')).toBe('music')
+    expect(requests[0].url).toBe('https://api.minimax.io/v1/t2a_v2')
+    expect(requests[0].headers.get('authorization')).toBe('Bearer sk-test')
+    expect(requests[0].body).toMatchObject({
+      model: 'speech-2.8-hd',
+      text: 'Hello from MiniMax',
+      output_format: 'hex',
+      voice_setting: { voice_id: 'female-shaonv' },
+      audio_setting: { format: 'mp3' }
+    })
+    expect(requests[1].url).toBe('https://api.minimax.io/v1/music_generation')
+    expect(requests[1].body).toMatchObject({
+      model: 'music-2.6',
+      prompt: 'Pop, bright, upbeat',
+      lyrics: 'hello\nworld',
+      output_format: 'hex',
+      audio_setting: { format: 'mp3' }
+    })
+  })
+
+  it('posts MiMo TTS as a chat completion with assistant speech text and decodes base64 audio', async () => {
+    const requests: Array<{ url: string; headers: Headers; body: Record }> = []
+    vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => {
+      requests.push({
+        url: String(url),
+        headers: new Headers(init?.headers),
+        body: JSON.parse(String(init?.body)) as Record
+      })
+      return new Response(JSON.stringify({
+        choices: [{
+          message: {
+            audio: { data: Buffer.from('mimo-audio').toString('base64') }
+          }
+        }]
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }))
+
+    const media = await new MimoSpeechClient('https://api.xiaomimimo.com/v1', 'sk-mimo').generate({
+      text: 'The target text is in the assistant message.',
+      model: 'mimo-v2.5-tts',
+      voice: 'Chloe',
+      style: 'Bright and upbeat.',
+      format: 'wav',
+      timeoutMs: 120000,
+      signal: new AbortController().signal
+    })
+
+    expect(media.data.toString('utf8')).toBe('mimo-audio')
+    expect(media.mimeType).toBe('audio/wav')
+    expect(requests[0].url).toBe('https://api.xiaomimimo.com/v1/chat/completions')
+    expect(requests[0].headers.get('api-key')).toBe('sk-mimo')
+    expect(requests[0].body).toEqual({
+      model: 'mimo-v2.5-tts',
+      messages: [
+        { role: 'user', content: 'Bright and upbeat.' },
+        { role: 'assistant', content: 'The target text is in the assistant message.' }
+      ],
+      audio: {
+        format: 'wav',
+        voice: 'Chloe'
+      }
+    })
+  })
+
+  it('polls MiniMax video generation and downloads the finished file', async () => {
+    const requests: Array<{ url: string; method?: string; body?: Record }> = []
+    vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => {
+      const href = String(url)
+      requests.push({
+        url: href,
+        method: init?.method,
+        ...(init?.body ? { body: JSON.parse(String(init.body)) as Record } : {})
+      })
+      if (href.endsWith('/v1/video_generation')) {
+        return new Response(JSON.stringify({
+          base_resp: { status_code: 0, status_msg: 'success' },
+          task_id: 'task-1'
+        }), {
+          status: 200,
+          headers: { 'content-type': 'application/json' }
+        })
+      }
+      if (href.includes('/v1/query/video_generation')) {
+        return new Response(JSON.stringify({
+          base_resp: { status_code: 0, status_msg: 'success' },
+          status: 'success',
+          file_id: 'file-1'
+        }), {
+          status: 200,
+          headers: { 'content-type': 'application/json' }
+        })
+      }
+      if (href.includes('/v1/files/retrieve')) {
+        return new Response(JSON.stringify({
+          base_resp: { status_code: 0, status_msg: 'success' },
+          file: { download_url: 'https://cdn.example.test/video.mp4' }
+        }), {
+          status: 200,
+          headers: { 'content-type': 'application/json' }
+        })
+      }
+      expect(href).toBe('https://cdn.example.test/video.mp4')
+      return new Response(new Uint8Array(Buffer.from('video')), {
+        status: 200,
+        headers: { 'content-type': 'video/mp4' }
+      })
+    }))
+    const updates: ToolExecutionUpdate[] = []
+
+    const media = await new MiniMaxVideoClient('https://api.minimax.io', 'sk-video').generate({
+      prompt: 'A calm product reveal',
+      model: 'MiniMax-Hailuo-2.3',
+      duration: 6,
+      resolution: '1080P',
+      timeoutMs: 120000,
+      pollIntervalMs: 1,
+      signal: new AbortController().signal,
+      onUpdate: (update) => {
+        updates.push(update)
+      }
+    })
+
+    expect(media.data.toString('utf8')).toBe('video')
+    expect(requests[0]).toMatchObject({
+      url: 'https://api.minimax.io/v1/video_generation',
+      method: 'POST',
+      body: {
+        model: 'MiniMax-Hailuo-2.3',
+        prompt: 'A calm product reveal',
+        duration: 6,
+        resolution: '1080P'
+      }
+    })
+    expect(requests[1].url).toBe('https://api.minimax.io/v1/query/video_generation?task_id=task-1')
+    expect(requests[2].url).toBe('https://api.minimax.io/v1/files/retrieve?file_id=file-1')
+    expect(updates).toEqual([
+      { output: { status: 'submitted', taskId: 'task-1', provider: 'minimax-video' } },
+      { output: { status: 'success', taskId: 'task-1', provider: 'minimax-video' } }
+    ])
+  })
+})
+
+function outputFor(item: unknown): {
+  files: Array<{ relativePath: string; absolutePath: string; mimeType: string; byteSize: number }>
+  model: string
+  voice?: string
+  format?: string
+  duration?: number
+  resolution?: string
+} {
+  expect(item).toMatchObject({ kind: 'tool_result', isError: false })
+  const output = (item as { output: unknown }).output
+  expect(output).toMatchObject({ files: expect.any(Array) })
+  return output as {
+    files: Array<{ relativePath: string; absolutePath: string; mimeType: string; byteSize: number }>
+    model: string
+    voice?: string
+    format?: string
+    duration?: number
+    resolution?: string
+  }
+}
+
+async function expectFile(
+  output: { files: Array<{ relativePath: string; absolutePath: string; mimeType: string; byteSize: number }> },
+  prefix: string,
+  mimeType: string,
+  contents: string
+) {
+  expect(output.files).toHaveLength(1)
+  const file = output.files[0]
+  expect(file.relativePath.startsWith(prefix)).toBe(true)
+  expect(file.mimeType).toBe(mimeType)
+  expect(file.byteSize).toBe(Buffer.byteLength(contents))
+  expect(existsSync(file.absolutePath)).toBe(true)
+  await expect(readFile(file.absolutePath, 'utf8')).resolves.toBe(contents)
+}
diff --git a/kun/tests/minimax-pricing.test.ts b/kun/tests/minimax-pricing.test.ts
new file mode 100644
index 00000000..8d24e8f3
--- /dev/null
+++ b/kun/tests/minimax-pricing.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { estimateMiniMaxCost } from '../src/adapters/model/minimax-pricing.js'
+
+describe('MiniMax pricing', () => {
+  it('estimates MiniMax M2.7 highspeed Token Plan equivalent CNY cost', () => {
+    const cost = estimateMiniMaxCost({
+      model: 'MiniMax-M2.7-highspeed',
+      providerHost: 'https://api.minimaxi.com/anthropic',
+      inputTokens: 10_000,
+      cacheReadTokens: 12_000,
+      cacheWriteTokens: 2_000,
+      outputTokens: 1_000
+    })
+
+    expect(cost).not.toBeNull()
+    expect(cost!.costCny).toBeCloseTo(0.06909)
+    expect(cost!.costUsd).toBeUndefined()
+  })
+
+  it('uses the higher MiniMax M3 tier above the 512k input threshold', () => {
+    const shortContext = estimateMiniMaxCost({
+      model: 'MiniMax-M3',
+      providerHost: 'https://api.minimaxi.com/anthropic',
+      inputTokens: 512_000,
+      cacheReadTokens: 0,
+      cacheWriteTokens: 0,
+      outputTokens: 1_000
+    })
+    const longContext = estimateMiniMaxCost({
+      model: 'MiniMax-M3',
+      providerHost: 'https://api.minimaxi.com/anthropic',
+      inputTokens: 512_001,
+      cacheReadTokens: 0,
+      cacheWriteTokens: 0,
+      outputTokens: 1_000
+    })
+
+    expect(shortContext?.costCny).toBeCloseTo(1.0836)
+    expect(longContext?.costCny).toBeCloseTo(2.1672042)
+  })
+
+  it('returns null for non-MiniMax hosts when providerHost is provided', () => {
+    expect(estimateMiniMaxCost({
+      model: 'MiniMax-M2.7',
+      providerHost: 'https://example.com/anthropic',
+      inputTokens: 10_000,
+      cacheReadTokens: 0,
+      cacheWriteTokens: 0,
+      outputTokens: 1_000
+    })).toBeNull()
+  })
+})
diff --git a/kun/tests/model-client.test.ts b/kun/tests/model-client.test.ts
index 0f57980d..7d3c42fe 100644
--- a/kun/tests/model-client.test.ts
+++ b/kun/tests/model-client.test.ts
@@ -230,7 +230,11 @@ describe('DeepseekCompatModelClient', () => {
     expect(sentBodies[0]).toMatchObject({
       model: 'deepseek-chat',
       max_tokens: 4096,
-      system: 'You are a helpful assistant.',
+      system: [{
+        type: 'text',
+        text: 'You are a helpful assistant.',
+        cache_control: { type: 'ephemeral' }
+      }],
       messages: [],
       tools: [{
         name: 'echo',
@@ -245,6 +249,198 @@ describe('DeepseekCompatModelClient', () => {
     ])
   })
 
+  it('keeps volatile context out of the Anthropic system block and marks cache breakpoints', async () => {
+    const sentBodies: Array> = []
+    const fetchImpl: typeof fetch = async (_url, init) => {
+      sentBodies.push(JSON.parse(String(init?.body ?? '{}')) as Record)
+      return new Response(JSON.stringify({
+        id: 'msg_2',
+        type: 'message',
+        role: 'assistant',
+        content: [{ type: 'text', text: 'ok' }],
+        stop_reason: 'end_turn',
+        usage: { input_tokens: 4, output_tokens: 2 }
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://api.minimaxi.com/anthropic',
+      apiKey: 'k',
+      model: 'MiniMax-M2.5',
+      endpointFormat: 'messages',
+      fetchImpl,
+      nonStreaming: true
+    })
+    const request = buildRequest(new AbortController().signal)
+    request.contextInstructions = ['Tokens used: 4321 — continue the goal.']
+    request.history = [
+      makeUserItem({ id: 'user_1', turnId: 'turn_1', threadId: 'thr_1', text: 'hello' }),
+      makeAssistantTextItem({ id: 'asst_1', turnId: 'turn_1', threadId: 'thr_1', text: 'hi there' }),
+      makeUserItem({ id: 'user_2', turnId: 'turn_2', threadId: 'thr_1', text: 'continue' })
+    ]
+    for await (const _chunk of client.stream(request)) {
+      // drain
+    }
+
+    const body = sentBodies[0]
+    // The volatile per-turn instruction must not invalidate the cached
+    // system prefix: it trails the history inside the final user turn.
+    expect(body.system).toEqual([{
+      type: 'text',
+      text: 'You are a helpful assistant.',
+      cache_control: { type: 'ephemeral' }
+    }])
+    const messages = body.messages as Array<{ role: string; content: Array> }>
+    const lastMessage = messages[messages.length - 1]
+    expect(lastMessage.role).toBe('user')
+    const lastBlocks = lastMessage.content
+    expect(lastBlocks.some((block) => String(block.text ?? '').includes('Tokens used: 4321'))).toBe(true)
+    // Explicit-cache providers (MiniMax) only cache content before
+    // cache_control breakpoints: the last two messages carry one.
+    expect(lastBlocks[lastBlocks.length - 1].cache_control).toEqual({ type: 'ephemeral' })
+    const previousMessage = messages[messages.length - 2]
+    const previousBlocks = previousMessage.content
+    expect(previousBlocks[previousBlocks.length - 1].cache_control).toEqual({ type: 'ephemeral' })
+  })
+
+  it('enables MiniMax M3 adaptive thinking from a model reasoning profile', async () => {
+    const sentBodies: Array> = []
+    const fetchImpl: typeof fetch = async (_url, init) => {
+      sentBodies.push(JSON.parse(String(init?.body ?? '{}')) as Record)
+      return new Response(JSON.stringify({
+        id: 'msg_m3',
+        type: 'message',
+        role: 'assistant',
+        content: [{ type: 'text', text: 'ok' }],
+        stop_reason: 'end_turn'
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://api.minimaxi.com/anthropic',
+      apiKey: 'k',
+      model: 'MiniMax-M3',
+      endpointFormat: 'messages',
+      fetchImpl,
+      nonStreaming: true,
+      modelCapabilities: (model) => ({
+        id: model,
+        inputModalities: ['text', 'image'],
+        outputModalities: ['text'],
+        supportsToolCalling: true,
+        contextWindowTokens: 1_000_000,
+        messageParts: ['text', 'image_url'],
+        reasoning: {
+          supportedEfforts: ['auto', 'off'],
+          defaultEffort: 'auto',
+          requestProtocol: 'anthropic-thinking'
+        }
+      })
+    })
+    const request = buildRequest(new AbortController().signal)
+    request.model = 'MiniMax-M3'
+    request.reasoningEffort = 'max'
+    for await (const _chunk of client.stream(request)) {
+      // drain
+    }
+
+    expect(sentBodies[0]?.thinking).toEqual({ type: 'adaptive' })
+  })
+
+  it('does not send thinking controls for MiniMax M2.x built-in reasoning profiles', async () => {
+    const sentBodies: Array> = []
+    const fetchImpl: typeof fetch = async (_url, init) => {
+      sentBodies.push(JSON.parse(String(init?.body ?? '{}')) as Record)
+      return new Response(JSON.stringify({
+        id: 'msg_m25',
+        type: 'message',
+        role: 'assistant',
+        content: [{ type: 'text', text: 'ok' }],
+        stop_reason: 'end_turn'
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://api.minimaxi.com/anthropic',
+      apiKey: 'k',
+      model: 'MiniMax-M2.5',
+      endpointFormat: 'messages',
+      fetchImpl,
+      nonStreaming: true,
+      modelCapabilities: (model) => ({
+        id: model,
+        inputModalities: ['text'],
+        outputModalities: ['text'],
+        supportsToolCalling: true,
+        contextWindowTokens: 204_800,
+        messageParts: ['text'],
+        reasoning: {
+          supportedEfforts: ['auto'],
+          defaultEffort: 'auto',
+          requestProtocol: 'none'
+        }
+      })
+    })
+    const request = buildRequest(new AbortController().signal)
+    request.model = 'MiniMax-M2.5'
+    request.reasoningEffort = 'off'
+    for await (const _chunk of client.stream(request)) {
+      // drain
+    }
+
+    expect(sentBodies[0]).not.toHaveProperty('thinking')
+  })
+
+  it('maps Anthropic usage where input_tokens excludes cache reads and writes', async () => {
+    const fetchImpl: typeof fetch = async () =>
+      new Response(JSON.stringify({
+        id: 'msg_3',
+        type: 'message',
+        role: 'assistant',
+        content: [{ type: 'text', text: 'ok' }],
+        stop_reason: 'end_turn',
+        usage: {
+          input_tokens: 50,
+          output_tokens: 10,
+          cache_read_input_tokens: 1000,
+          cache_creation_input_tokens: 200
+        }
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://api.minimaxi.com/anthropic',
+      apiKey: 'k',
+      model: 'MiniMax-M2.5',
+      endpointFormat: 'messages',
+      fetchImpl,
+      nonStreaming: true
+    })
+    const chunks: ModelStreamChunk[] = []
+    const request = buildRequest(new AbortController().signal)
+    request.model = 'MiniMax-M2.5'
+    for await (const chunk of client.stream(request)) {
+      chunks.push(chunk)
+    }
+    const usageChunk = chunks.find((chunk) => chunk.kind === 'usage')
+    const usage = usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage : null
+    expect(usage).not.toBeNull()
+    expect(usage!.promptTokens).toBe(1250)
+    expect(usage!.cacheHitTokens).toBe(1000)
+    expect(usage!.cacheMissTokens).toBe(250)
+    expect(usage!.totalTokens).toBe(1260)
+    expect(usage!.cacheHitRate).toBeCloseTo(0.8)
+    expect(usage!.costCny).toBeCloseTo(0.000924)
+    expect(usage!.costUsd).toBeUndefined()
+  })
+
   it('streams Responses API text and function calls', async () => {
     const fetchImpl: typeof fetch = async () => new Response(sseStream([
       { type: 'response.output_text.delta', delta: 'hi' },
@@ -473,6 +669,7 @@ describe('DeepseekCompatModelClient', () => {
 
   it('requests usage in streaming responses', async () => {
     const sentBodies: Array> = []
+    const sentHeaders: Array> = []
     const encoder = new TextEncoder()
     const body = new ReadableStream({
       start(controller) {
@@ -483,6 +680,7 @@ describe('DeepseekCompatModelClient', () => {
     })
     const fetchImpl: typeof fetch = async (_url, init) => {
       sentBodies.push(JSON.parse(String(init?.body ?? '{}')) as Record)
+      sentHeaders.push(init?.headers as Record)
       return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
     }
     const client = new DeepseekCompatModelClient({
@@ -500,6 +698,7 @@ describe('DeepseekCompatModelClient', () => {
       stream: true,
       stream_options: { include_usage: true }
     })
+    expect(sentHeaders[0]?.Accept).toBeUndefined()
   })
 
   it('keeps requiredToolName as loop metadata instead of sending provider tool_choice', async () => {
@@ -602,6 +801,57 @@ describe('DeepseekCompatModelClient', () => {
     expect(sentBodies[0]).not.toHaveProperty('thinking')
   })
 
+  it('maps Xiaomi max reasoning to the highest supported Xiaomi effort from model profiles', async () => {
+    const response = {
+      id: 'xiaomi',
+      model: 'mimo-v2.5-pro',
+      choices: [
+        {
+          index: 0,
+          finish_reason: 'stop',
+          message: { role: 'assistant', content: 'done' }
+        }
+      ]
+    }
+    const sentBodies: Array> = []
+    const fetchImpl: typeof fetch = async (_url, init) => {
+      sentBodies.push(JSON.parse(String(init?.body ?? '{}')) as Record)
+      return new Response(JSON.stringify(response), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://api.xiaomimimo.com/v1',
+      apiKey: 'k',
+      model: 'mimo-v2.5-pro',
+      fetchImpl,
+      nonStreaming: true,
+      modelCapabilities: (model) => ({
+        id: model,
+        inputModalities: ['text'],
+        outputModalities: ['text'],
+        supportsToolCalling: true,
+        contextWindowTokens: 1_000_000,
+        messageParts: ['text'],
+        reasoning: {
+          supportedEfforts: ['off', 'low', 'medium', 'high'],
+          defaultEffort: 'high',
+          requestProtocol: 'mimo-chat-completions'
+        }
+      })
+    })
+    const request = buildRequest(new AbortController().signal)
+    request.model = 'mimo-v2.5-pro'
+    request.reasoningEffort = 'max'
+    for await (const _chunk of client.stream(request)) {
+      // drain
+    }
+
+    expect(sentBodies[0]?.reasoning_effort).toBe('high')
+    expect(sentBodies[0]?.thinking).toEqual({ type: 'enabled' })
+  })
+
   it('parses a non-streaming JSON response into chunks', async () => {
     const response = {
       id: 'r1',
@@ -665,9 +915,10 @@ describe('DeepseekCompatModelClient', () => {
       callChunk && callChunk.kind === 'tool_call_complete' ? callChunk.arguments : {}
     ).toEqual({ text: 'hi' })
     expect(usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage.cacheHitTokens : 0).toBe(30)
+    expect(usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage.cacheMissTokens : 0).toBe(20)
     expect(usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage.costUsd : 0).toBeGreaterThan(0)
     expect(usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage.costCny : 0).toBeGreaterThan(0)
-    expect(usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage.cacheSavingsUsd : 0).toBeGreaterThan(0)
+    expect(usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage.cacheSavingsUsd : undefined).toBeUndefined()
     expect(
       completionChunk && completionChunk.kind === 'completed' ? completionChunk.stopReason : ''
     ).toBe('tool_calls')
@@ -1370,6 +1621,53 @@ describe('DeepseekCompatModelClient', () => {
     expect(messages[2]).toMatchObject({ role: 'user', content: 'continue' })
   })
 
+  it('sends volatile context instructions after the history for cache prefix stability', async () => {
+    const sentBodies: Array<{ messages?: Array> }> = []
+    const response = {
+      id: 'r1',
+      model: 'deepseek-chat',
+      choices: [
+        {
+          index: 0,
+          finish_reason: 'stop',
+          message: { role: 'assistant', content: 'done' }
+        }
+      ],
+      usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
+    }
+    const fetchImpl: typeof fetch = async (_url, init) => {
+      sentBodies.push(JSON.parse(String(init?.body ?? '{}')))
+      return new Response(JSON.stringify(response), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    }
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://example.com/beta',
+      apiKey: 'k',
+      model: 'deepseek-chat',
+      fetchImpl,
+      nonStreaming: true
+    })
+    const request = buildRequest(new AbortController().signal)
+    request.contextInstructions = ['Tokens used: 4321 — continue the goal.']
+    request.history = [
+      makeUserItem({ id: 'user_1', turnId: 'turn_1', threadId: 'thr_1', text: 'hello' })
+    ]
+
+    for await (const _chunk of client.stream(request)) {
+      // drain
+    }
+
+    const messages = sentBodies[0]?.messages ?? []
+    const instructionIndex = messages.findIndex(
+      (message) => typeof message.content === 'string' && message.content.includes('Tokens used: 4321')
+    )
+    const userIndex = messages.findIndex((message) => message.role === 'user')
+    expect(instructionIndex).toBeGreaterThan(userIndex)
+    expect(messages[instructionIndex]).toMatchObject({ role: 'system' })
+  })
+
   it('preserves the latest compaction summary when applying history limits', async () => {
     const sentBodies: Array<{ messages?: Array> }> = []
     const response = {
@@ -1425,8 +1723,10 @@ describe('DeepseekCompatModelClient', () => {
   })
 
   it('reports an error when the HTTP response is not OK', async () => {
+    const providerMessage = `Not supported model ${'mimo-v2.5-pro-ultraspeed'.repeat(40)}`
+    const body = JSON.stringify({ error: { code: '400', message: providerMessage } })
     const fetchImpl: typeof fetch = async () =>
-      new Response('upstream failure', { status: 500 })
+      new Response(body, { status: 400 })
     const client = new DeepseekCompatModelClient({
       baseUrl: 'https://example.com/beta',
       apiKey: 'k',
@@ -1438,6 +1738,78 @@ describe('DeepseekCompatModelClient', () => {
       chunks.push(chunk)
     }
     expect(chunks[0].kind).toBe('error')
+    expect(chunks[0]).toMatchObject({
+      kind: 'error',
+      message: `model request failed with status 400: ${body}`,
+      code: 'http_400'
+    })
+    expect(JSON.stringify(chunks[0])).toContain(providerMessage)
+  })
+
+  it('reports provider JSON error payloads returned with HTTP 200', async () => {
+    const fetchImpl: typeof fetch = async () =>
+      new Response(JSON.stringify({
+        error: {
+          message: 'model mimo-v2.5-pro-ultraspeed is not available for this account',
+          code: 'model_not_available'
+        }
+      }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' }
+      })
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://api.xiaomimimo.com/v1',
+      apiKey: 'k',
+      model: 'mimo-v2.5-pro-ultraspeed',
+      fetchImpl,
+      nonStreaming: true
+    })
+    const chunks: ModelStreamChunk[] = []
+    for await (const chunk of client.stream(buildRequest(new AbortController().signal))) {
+      chunks.push(chunk)
+    }
+
+    expect(chunks).toEqual([
+      {
+        kind: 'error',
+        message: 'model mimo-v2.5-pro-ultraspeed is not available for this account',
+        code: 'model_not_available'
+      }
+    ])
+  })
+
+  it('reports streamed provider error payloads returned with HTTP 200', async () => {
+    const body = sseStream([
+      {
+        error: {
+          message: 'no permission to access model mimo-v2.5-pro-ultraspeed',
+          type: 'permission_denied'
+        }
+      },
+      '[DONE]'
+    ])
+    const fetchImpl: typeof fetch = async () =>
+      new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
+    const client = new DeepseekCompatModelClient({
+      baseUrl: 'https://api.xiaomimimo.com/v1',
+      apiKey: 'k',
+      model: 'mimo-v2.5-pro-ultraspeed',
+      fetchImpl
+    })
+    const chunks: ModelStreamChunk[] = []
+    for await (const chunk of client.stream(buildRequest(new AbortController().signal))) {
+      chunks.push(chunk)
+    }
+
+    expect(chunks.find((chunk) => chunk.kind === 'error')).toMatchObject({
+      kind: 'error',
+      message: 'no permission to access model mimo-v2.5-pro-ultraspeed',
+      code: 'permission_denied'
+    })
+    expect(chunks.find((chunk) => chunk.kind === 'completed')).toMatchObject({
+      kind: 'completed',
+      stopReason: 'error'
+    })
   })
 
   it('parses streamed SSE events with tool call deltas', async () => {
diff --git a/kun/tests/ports.test.ts b/kun/tests/ports.test.ts
index ba36996c..83366cfa 100644
--- a/kun/tests/ports.test.ts
+++ b/kun/tests/ports.test.ts
@@ -169,24 +169,21 @@ describe('LocalToolHost', () => {
     ).rejects.toThrow(/aborted/)
   })
 
-  it('returns an error result for user_input when no GUI gate is available', async () => {
+  it('rejects user_input as unadvertised when no GUI gate is available', async () => {
     const host = new LocalToolHost({ tools: defaultLocalTools })
-    const result = await host.execute(
-      { callId: 'c1', toolName: 'user_input', arguments: { prompt: '?' } },
-      {
-        threadId: 'th',
-        turnId: 'tu',
-        workspace: '/tmp',
-        approvalPolicy: 'on-request',
-        abortSignal: new AbortController().signal,
-        awaitApproval: async () => 'allow'
-      }
-    )
-    expect(result.item).toMatchObject({
-      kind: 'tool_result',
-      toolName: 'user_input',
-      isError: true
-    })
+    await expect(
+      host.execute(
+        { callId: 'c1', toolName: 'user_input', arguments: { prompt: '?' } },
+        {
+          threadId: 'th',
+          turnId: 'tu',
+          workspace: '/tmp',
+          approvalPolicy: 'on-request',
+          abortSignal: new AbortController().signal,
+          awaitApproval: async () => 'allow'
+        }
+      )
+    ).rejects.toThrow(/user_input is not advertised/)
   })
 
   it('updates in-memory session items in place', async () => {
@@ -301,7 +298,10 @@ describe('LocalToolHost', () => {
         {
           phase: 'PostToolUse',
           toolNames: ['echo'],
-          run: ({ result }) => ({ output: { wrapped: result?.output } })
+          run: (invocation) => {
+            if (invocation.phase !== 'PostToolUse') return
+            return { output: { wrapped: invocation.result.output } }
+          }
         }
       ]
     })
diff --git a/kun/tests/runtime-event-recorder.test.ts b/kun/tests/runtime-event-recorder.test.ts
new file mode 100644
index 00000000..21897172
--- /dev/null
+++ b/kun/tests/runtime-event-recorder.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from 'vitest'
+import { RuntimeEventRecorder } from '../src/services/runtime-event-recorder.js'
+import { InMemoryEventBus } from '../src/adapters/in-memory-event-bus.js'
+import { InMemorySessionStore } from '../src/adapters/in-memory-session-store.js'
+
+function buildRecorder(): {
+  recorder: RuntimeEventRecorder
+  bus: InMemoryEventBus
+  sessionStore: InMemorySessionStore
+} {
+  const bus = new InMemoryEventBus()
+  const sessionStore = new InMemorySessionStore()
+  const recorder = new RuntimeEventRecorder({
+    eventBus: bus,
+    sessionStore,
+    allocateSeq: (threadId) => bus.allocateSeq(threadId),
+    nowIso: () => new Date().toISOString()
+  })
+  return { recorder, bus, sessionStore }
+}
+
+describe('runtime event recorder', () => {
+  it('persists an event before publishing it to live subscribers', async () => {
+    const { recorder, bus, sessionStore } = buildRecorder()
+    const order: string[] = []
+    vi.spyOn(sessionStore, 'appendEvent').mockImplementation(async () => {
+      order.push('persist')
+    })
+    vi.spyOn(bus, 'publish').mockImplementation(() => {
+      order.push('publish')
+    })
+
+    await recorder.record({ kind: 'heartbeat', threadId: 'thr_1' })
+
+    expect(order).toEqual(['persist', 'publish'])
+  })
+
+  it('never stamps the same seq twice for concurrent records', async () => {
+    const { recorder, sessionStore } = buildRecorder()
+    // Pre-existing history: the persisted high-water mark is well above the
+    // fresh in-memory counter, which used to make concurrent first records
+    // collide on persistedSeq + 1.
+    await sessionStore.appendEvent('thr_1', {
+      kind: 'heartbeat',
+      threadId: 'thr_1',
+      seq: 100,
+      timestamp: new Date().toISOString()
+    })
+
+    const events = await Promise.all(
+      Array.from({ length: 5 }, () => recorder.record({ kind: 'heartbeat', threadId: 'thr_1' }))
+    )
+
+    const seqs = events.map((event) => event.seq)
+    expect(new Set(seqs).size).toBe(seqs.length)
+    expect(Math.min(...seqs)).toBeGreaterThan(100)
+  })
+
+  it('reads the persisted high-water mark only once per thread', async () => {
+    const { recorder, sessionStore } = buildRecorder()
+    const highestSeq = vi.spyOn(sessionStore, 'highestSeq')
+
+    await recorder.record({ kind: 'heartbeat', threadId: 'thr_1' })
+    await recorder.record({ kind: 'heartbeat', threadId: 'thr_1' })
+    await recorder.record({ kind: 'heartbeat', threadId: 'thr_1' })
+
+    expect(highestSeq).toHaveBeenCalledTimes(1)
+  })
+})
diff --git a/kun/tests/runtime-factory.test.ts b/kun/tests/runtime-factory.test.ts
index 51f7865c..01ddbb96 100644
--- a/kun/tests/runtime-factory.test.ts
+++ b/kun/tests/runtime-factory.test.ts
@@ -1,10 +1,11 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { InMemorySessionStore } from '../src/adapters/in-memory-session-store.js'
 import { InMemoryThreadStore } from '../src/adapters/in-memory-thread-store.js'
 import { createThreadRecord } from '../src/domain/thread.js'
 import { UsageService } from '../src/services/usage-service.js'
 import { seedUsageCarryover } from '../src/server/runtime-factory.js'
 import type { UsageSnapshot } from '../src/contracts/usage.js'
+import type { SessionStore } from '../src/ports/session-store.js'
 
 function usage(overrides: Partial): UsageSnapshot {
   const promptTokens = overrides.promptTokens ?? 10
@@ -67,4 +68,32 @@ describe('runtime factory usage carryover', () => {
       hitRate: 0.9
     })
   })
+
+  it('seeds runtime usage from indexed latest snapshots without replaying event logs', async () => {
+    const threadStore = new InMemoryThreadStore()
+    const sessionStore = new InMemorySessionStore() as InMemorySessionStore & {
+      loadLatestUsageSnapshots: NonNullable
+    }
+    const usageService = new UsageService()
+    sessionStore.loadLatestUsageSnapshots = vi.fn(async () => [
+      {
+        threadId: 'thr_indexed',
+        seq: 9,
+        usage: usage({ promptTokens: 120, completionTokens: 30, cacheHitTokens: 100, cacheMissTokens: 20, turns: 4 })
+      }
+    ])
+    const loadEventsSince = vi.spyOn(sessionStore, 'loadEventsSince')
+
+    await seedUsageCarryover({ threadStore, sessionStore, usageService })
+
+    expect(loadEventsSince).not.toHaveBeenCalled()
+    expect(usageService.forThread('thr_indexed')).toMatchObject({
+      promptTokens: 120,
+      completionTokens: 30,
+      totalTokens: 150,
+      cacheHitTokens: 100,
+      cacheMissTokens: 20,
+      turns: 4
+    })
+  })
 })
diff --git a/kun/tests/user-input-disabled.test.ts b/kun/tests/user-input-disabled.test.ts
new file mode 100644
index 00000000..da8db62a
--- /dev/null
+++ b/kun/tests/user-input-disabled.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest'
+import type { ModelRequest, ModelStreamChunk } from '../src/ports/model-client.js'
+import { bootstrapThread, makeHarness } from './loop-test-harness.js'
+
+describe('agent loop: disableUserInput turns (IM bridges)', () => {
+  it('hides GUI input tools and rejects stray calls instead of blocking', async () => {
+    let calls = 0
+    const seenRequests: ModelRequest[] = []
+    const h = makeHarness({
+      provider: 'im-model',
+      model: 'im-model',
+      async *stream(request: ModelRequest): AsyncIterable {
+        seenRequests.push(request)
+        calls += 1
+        if (calls === 1) {
+          yield {
+            kind: 'tool_call_complete',
+            callId: 'call_input',
+            toolName: 'request_user_input',
+            arguments: { prompt: 'Pick one' }
+          }
+          yield { kind: 'completed', stopReason: 'tool_calls' }
+          return
+        }
+        yield { kind: 'completed', stopReason: 'stop' }
+      }
+    })
+    await bootstrapThread(h, {
+      request: { prompt: 'hi from wechat', disableUserInput: true }
+    })
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    expect(status).toBe('completed')
+    const advertised = seenRequests[0]?.tools.map((tool) => tool.name) ?? []
+    expect(advertised).not.toContain('user_input')
+    expect(advertised).not.toContain('request_user_input')
+    expect(seenRequests[0]?.contextInstructions?.join(' ')).toMatch(
+      /Interactive user input is unavailable/
+    )
+
+    const result = (await h.sessionStore.loadItems(h.threadId)).find(
+      (item) => item.kind === 'tool_result' && item.toolName === 'request_user_input'
+    )
+    expect(result).toMatchObject({ kind: 'tool_result', isError: true })
+
+    expect(h.userInputGate.pending(h.threadId)).toHaveLength(0)
+    const thread = await h.threadStore.get(h.threadId)
+    const items = thread?.turns.flatMap((turn) => turn.items) ?? []
+    expect(items.some((item) => item.kind === 'user_input')).toBe(false)
+  })
+
+  it('keeps GUI input tools advertised for normal turns', async () => {
+    const seenRequests: ModelRequest[] = []
+    const h = makeHarness({
+      provider: 'gui-model',
+      model: 'gui-model',
+      async *stream(request: ModelRequest): AsyncIterable {
+        seenRequests.push(request)
+        yield { kind: 'completed', stopReason: 'stop' }
+      }
+    })
+    await bootstrapThread(h)
+
+    const status = await h.loop.runTurn(h.threadId, h.turnId)
+
+    expect(status).toBe('completed')
+    const advertised = seenRequests[0]?.tools.map((tool) => tool.name) ?? []
+    expect(advertised).toContain('user_input')
+    expect(advertised).toContain('request_user_input')
+    expect(seenRequests[0]?.contextInstructions?.join(' ') ?? '').not.toMatch(
+      /Interactive user input is unavailable/
+    )
+  })
+})
diff --git a/kun/tests/web-tool-provider.test.ts b/kun/tests/web-tool-provider.test.ts
index 03134d16..0ee0de62 100644
--- a/kun/tests/web-tool-provider.test.ts
+++ b/kun/tests/web-tool-provider.test.ts
@@ -109,7 +109,7 @@ describe('Web tool provider', () => {
     }
   })
 
-  it('rejects fetch responses when content-length exceeds max_bytes', async () => {
+  it('truncates instead of failing when content-length exceeds max_bytes', async () => {
     vi.stubGlobal('fetch', async () => new Response('abcdefghijklmnopqrstuvwxyz', {
       headers: {
         'content-length': '26',
@@ -120,7 +120,8 @@ describe('Web tool provider', () => {
       web: {
         enabled: true,
         fetchEnabled: true,
-        allowDomains: ['docs.example.test']
+        allowDomains: ['docs.example.test'],
+        maxFetchBytes: 10
       }
     })
     const host = new LocalToolHost({
@@ -133,17 +134,12 @@ describe('Web tool provider', () => {
       arguments: { url: 'https://docs.example.test/large', max_bytes: 10 }
     }, buildContext())
 
-    expect(result.item).toMatchObject({ kind: 'tool_result', isError: true })
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: false })
     if (result.item.kind === 'tool_result') {
       expect(result.item.output).toMatchObject({
-        error: {
-          code: 'fetch_failed',
-          message: expect.stringContaining('content exceeds')
-        },
-        telemetry: {
-          policy: 'allowed',
-          provider: 'fetch'
-        }
+        text: 'abcdefghij',
+        byteCount: 10,
+        truncated: true
       })
     }
   })
@@ -158,7 +154,8 @@ describe('Web tool provider', () => {
       web: {
         enabled: true,
         fetchEnabled: true,
-        allowDomains: ['docs.example.test']
+        allowDomains: ['docs.example.test'],
+        maxFetchBytes: 10
       }
     })
     const host = new LocalToolHost({
@@ -186,6 +183,39 @@ describe('Web tool provider', () => {
     }
   })
 
+  it('raises tiny model-passed max_bytes budgets to a usable floor', async () => {
+    vi.stubGlobal('fetch', async () => new Response('x'.repeat(3000), {
+      headers: {
+        'content-length': '3000',
+        'content-type': 'text/plain'
+      }
+    }))
+    const config = KunCapabilitiesConfig.parse({
+      web: {
+        enabled: true,
+        fetchEnabled: true,
+        allowDomains: ['docs.example.test']
+      }
+    })
+    const host = new LocalToolHost({
+      registry: new CapabilityRegistry(buildWebToolProviders(config.web).providers)
+    })
+
+    const result = await host.execute({
+      callId: 'call_1',
+      toolName: 'web_fetch',
+      arguments: { url: 'https://docs.example.test/page', max_bytes: 2000 }
+    }, buildContext())
+
+    expect(result.item).toMatchObject({ kind: 'tool_result', isError: false })
+    if (result.item.kind === 'tool_result') {
+      expect(result.item.output).toMatchObject({
+        byteCount: 3000,
+        truncated: false
+      })
+    }
+  })
+
   it('rejects disallowed fetch URLs before contacting the provider', async () => {
     let contacted = false
     const config = KunCapabilitiesConfig.parse({
diff --git a/package-lock.json b/package-lock.json
index e826fa48..dff15c4d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,13 @@
 {
-  "name": "deepseek-gui",
+  "name": "kun-gui",
   "version": "0.1.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "deepseek-gui",
+      "name": "kun-gui",
       "version": "0.1.0",
+      "license": "PolyForm-Noncommercial-1.0.0",
       "hasInstallScript": true,
       "dependencies": {
         "@aws-sdk/client-s3": "^3.1049.0",
@@ -19,6 +20,14 @@
         "@larksuiteoapi/node-sdk": "^1.64.0",
         "@modelcontextprotocol/sdk": "^1.29.0",
         "@tencent-weixin/openclaw-weixin": "2.4.3",
+        "@tiptap/core": "^3.26.0",
+        "@tiptap/extension-image": "^3.26.0",
+        "@tiptap/extension-list": "^3.26.0",
+        "@tiptap/extension-table": "^3.26.0",
+        "@tiptap/markdown": "^3.26.0",
+        "@tiptap/pm": "^3.26.0",
+        "@tiptap/react": "^3.26.0",
+        "@tiptap/starter-kit": "^3.26.0",
         "better-sqlite3": "^12.10.0",
         "electron-store": "^10.1.0",
         "electron-updater": "^6.8.3",
@@ -26,6 +35,7 @@
         "i18next": "^25.4.2",
         "lucide-react": "^0.544.0",
         "openclaw": "file:vendor/openclaw-shim",
+        "pdfjs-dist": "^5.4.394",
         "qrcode.react": "^4.2.0",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
@@ -611,7 +621,6 @@
       "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.29.0",
         "@babel/generator": "^7.29.0",
@@ -1882,6 +1891,34 @@
         "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
+      "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
+      "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/core": "^1.7.5",
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
+      "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@hono/node-server": {
       "version": "1.19.14",
       "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -3067,6 +3104,479 @@
         "openclaw": ">=2026.3.22"
       }
     },
+    "node_modules/@tiptap/core": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-3.26.0.tgz",
+      "integrity": "sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-blockquote": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-blockquote/-/extension-blockquote-3.26.0.tgz",
+      "integrity": "sha512-57accpka9affjiJRjP2LMNCDJDTMjTvO23RJCxtP43sp9cTIZ7YZnyDfRxCINTRBNK0X4o4w2+emOLyRwsk3CA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-bold": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-bold/-/extension-bold-3.26.0.tgz",
+      "integrity": "sha512-j6CzTMofcGJ5iMoUgDRQpM0FkG00jBID3aKqs+UBbgtzLgtG/CI/91tMFv0XPC30LeFA895qYgvGZtHdejZhiQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-bubble-menu": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.0.tgz",
+      "integrity": "sha512-H2E3Hp0lV79jQV8YGtdDJkXkUalXZeYzKCx+vCZlDpb2ChS7/rNT9YY7poRA1NlJLUO0DH1wbAnFhx9KZMUx5g==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/dom": "^1.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-bullet-list": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.0.tgz",
+      "integrity": "sha512-Jv7BX+kBB2wUIvO/NhuUjv+T3kAed2Tjr664fgQ2zKT6X69jKIkYuCCedrIHuOyaOQ+SBDuH9h51wYv/E97QgQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-code": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-code/-/extension-code-3.26.0.tgz",
+      "integrity": "sha512-VJYcV6rvjnENRTroOi9tDcHWW6G0pmCoRETwatlbgfDzuCmkTOwVwQjeJCXOVMMLNPzNiXZzibsRCUt+Azq/jw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-code-block": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-code-block/-/extension-code-block-3.26.0.tgz",
+      "integrity": "sha512-WPN9iZ3UjeDD2ckDzSs9tleibXv0cLj7j575NxuvjhwZTehYGNeYDSUTi+6DQUG6bKbhGg9Wcei5H0131vvJHg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-document": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-document/-/extension-document-3.26.0.tgz",
+      "integrity": "sha512-Xhd6DCjaxCN4otQNvV6qra+XuoIjk6Vyjm87E5xn5Y/BMw7UGAG7LTkk3C2IEvxKrVZwJjalfxEqdHOgXQzVfw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-dropcursor": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.0.tgz",
+      "integrity": "sha512-rhAtp5J/YVDUCUIc5T7b0XY9dLeuI72JgOr53w0QQc0VA0uwbfTn7sx0LI9PDCE9uwmDH8H3snVRZRnAvlM8oA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-floating-menu": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.0.tgz",
+      "integrity": "sha512-reQ77NRYAOP7iPudsNbzLBuBTdL2aGxZzjccUFmE2lNdmwP23n9A/JhkuUhshVBs/6IozvahI+smG3Bnea0TCQ==",
+      "license": "MIT",
+      "optional": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@floating-ui/dom": "^1.0.0",
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-gapcursor": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.0.tgz",
+      "integrity": "sha512-SIe68SDwx2fozt/XKG0FhCwzz/yRN6Bvo4D5TqvfDg6NK3PQb1DS4GN9PilmJqbY+kXryuiWEEJOWi7HpO8SuQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-hard-break": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-hard-break/-/extension-hard-break-3.26.0.tgz",
+      "integrity": "sha512-baXvv/rtOTVd2Axjb7Zbb41Y9Qmy3U2fP7EHqLuhViqGxVX8LwQtP0PHUXEZkPokbBpRez10+dmOlvvsYFKAZQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-heading": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-heading/-/extension-heading-3.26.0.tgz",
+      "integrity": "sha512-qenEQEgzE5FjQay/H6iKOnwIt6DPO27cS+v0mGhXmrL1MjrNER4X0ZkATJbVd0WA6ffsAGaP44NKYDworGeidw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-horizontal-rule": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.0.tgz",
+      "integrity": "sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-image": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-image/-/extension-image-3.26.0.tgz",
+      "integrity": "sha512-vinrbKa9Awmlb/UPpnes1pezL+ZeUC2v7XczZyNbggvcHKhlVkuXZIKytFQDXEYOTaKYzYE8B/Gz098PiJ9NYQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-italic": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-italic/-/extension-italic-3.26.0.tgz",
+      "integrity": "sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-link": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-link/-/extension-link-3.26.0.tgz",
+      "integrity": "sha512-FA/d157aBxyvZFvsdc5eSu46tmHWXebAsqOQSvivOMyw+deBb00VlMsf+iD2J8+sekjbMYwx/hvbsu+xUoX43Q==",
+      "license": "MIT",
+      "dependencies": {
+        "linkifyjs": "^4.3.3"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-list": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.26.0.tgz",
+      "integrity": "sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-list-item": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-item/-/extension-list-item-3.26.0.tgz",
+      "integrity": "sha512-MccGyj9HY4fkl04eIiFoTCkr8067Jku/VVdJNtRWW104Spx43C/7V2zpbxPvpcDhq3dW384fDxYXfpnb186xLg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-list-keymap": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.0.tgz",
+      "integrity": "sha512-oBcj6qaNrRHQ+N0+pDuOVAQa4Nx9r8Cm5ANvyM2lTpoy60sOLOizuVvcvw1andVxbSrsZ1N/Sk+RZWyv1uoWyQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-ordered-list": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.0.tgz",
+      "integrity": "sha512-ItLdFlcMsJz2vhbs1PcUfcN7nzVqGBOwPeCrrWxjrgscp+K3JoOGD+HhVVpBACOMwivUrlh8Ry5Ohvues2nOeA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-paragraph": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-paragraph/-/extension-paragraph-3.26.0.tgz",
+      "integrity": "sha512-h8fYLikg4qN39IghQ1y9g+zzUsgxBpDi5YS3IZbWoxWYYx1YqLL8nAvOiPr7Us14aQ0TjA2/xY7zqmyf29rX1A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-strike": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-strike/-/extension-strike-3.26.0.tgz",
+      "integrity": "sha512-jUll3Pqhq7u1JKvO0B6USW/bmVmUsO6sRcxo/d5tXqLhS0tWAobOGoGU2IgwXnQDSjf+vF73RYD5tRGDLkRC9Q==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-table": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-table/-/extension-table-3.26.0.tgz",
+      "integrity": "sha512-pLL1+tKeUWSF/w4se84tjWUnuraKVELQtIHwi1XKoq6vkevotwwMb99xY6cJ752FaUFVDbViFe/JUYWBoU+bIw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-text": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-text/-/extension-text-3.26.0.tgz",
+      "integrity": "sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extension-underline": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extension-underline/-/extension-underline-3.26.0.tgz",
+      "integrity": "sha512-LlVkivH5cBwov/EMD8BL7ZRcU6YcadiSVIffLW1hyalw9YfhaFzoLxjtWhL7jiU/n2Kg+9dXSZxmV2hTeTwyrQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/extensions": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.26.0.tgz",
+      "integrity": "sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/markdown": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/markdown/-/markdown-3.26.0.tgz",
+      "integrity": "sha512-jg5xrwl1gTXUl5JA3+g8YYfhOzplM9CVecwKZeFtlYtPLyxLCmIDvqV/vULoGu57HwtY4819nNpMZwY6jBNtrw==",
+      "license": "MIT",
+      "dependencies": {
+        "marked": "^17.0.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0"
+      }
+    },
+    "node_modules/@tiptap/pm": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.26.0.tgz",
+      "integrity": "sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-changeset": "^2.3.0",
+        "prosemirror-commands": "^1.6.2",
+        "prosemirror-dropcursor": "^1.8.1",
+        "prosemirror-gapcursor": "^1.3.2",
+        "prosemirror-history": "^1.4.1",
+        "prosemirror-inputrules": "^1.4.0",
+        "prosemirror-keymap": "^1.2.3",
+        "prosemirror-model": "^1.25.7",
+        "prosemirror-schema-list": "^1.5.0",
+        "prosemirror-state": "^1.4.4",
+        "prosemirror-tables": "^1.8.0",
+        "prosemirror-transform": "^1.12.0",
+        "prosemirror-view": "^1.41.8"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
+    "node_modules/@tiptap/react": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/react/-/react-3.26.0.tgz",
+      "integrity": "sha512-NLPAG6tk4/AsfOsUNsbGqdgIHuGsD4A/hlYriozuo+LCAAduuluhzsL/MEHZXtFT4GXUOlCdaEqNCOrMuz/zaw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/use-sync-external-store": "^0.0.6",
+        "fast-equals": "^5.3.3",
+        "use-sync-external-store": "^1.4.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "optionalDependencies": {
+        "@tiptap/extension-bubble-menu": "^3.26.0",
+        "@tiptap/extension-floating-menu": "^3.26.0"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "3.26.0",
+        "@tiptap/pm": "3.26.0",
+        "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/@tiptap/starter-kit": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmmirror.com/@tiptap/starter-kit/-/starter-kit-3.26.0.tgz",
+      "integrity": "sha512-o34EtMfqtBaljdmeElZsRG/067oGx9Zcq+j2GWo71KlZe22ga/ALexeTf1c+ETsjCxSTKR6eyQ4RZvz/2JpYfg==",
+      "license": "MIT",
+      "dependencies": {
+        "@tiptap/core": "^3.26.0",
+        "@tiptap/extension-blockquote": "^3.26.0",
+        "@tiptap/extension-bold": "^3.26.0",
+        "@tiptap/extension-bullet-list": "^3.26.0",
+        "@tiptap/extension-code": "^3.26.0",
+        "@tiptap/extension-code-block": "^3.26.0",
+        "@tiptap/extension-document": "^3.26.0",
+        "@tiptap/extension-dropcursor": "^3.26.0",
+        "@tiptap/extension-gapcursor": "^3.26.0",
+        "@tiptap/extension-hard-break": "^3.26.0",
+        "@tiptap/extension-heading": "^3.26.0",
+        "@tiptap/extension-horizontal-rule": "^3.26.0",
+        "@tiptap/extension-italic": "^3.26.0",
+        "@tiptap/extension-link": "^3.26.0",
+        "@tiptap/extension-list": "^3.26.0",
+        "@tiptap/extension-list-item": "^3.26.0",
+        "@tiptap/extension-list-keymap": "^3.26.0",
+        "@tiptap/extension-ordered-list": "^3.26.0",
+        "@tiptap/extension-paragraph": "^3.26.0",
+        "@tiptap/extension-strike": "^3.26.0",
+        "@tiptap/extension-text": "^3.26.0",
+        "@tiptap/extension-underline": "^3.26.0",
+        "@tiptap/extensions": "^3.26.0",
+        "@tiptap/pm": "^3.26.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3515,7 +4025,6 @@
       "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
       "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "csstype": "^3.2.2"
       }
@@ -3524,7 +4033,6 @@
       "version": "19.2.3",
       "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
-      "dev": true,
       "license": "MIT",
       "peerDependencies": {
         "@types/react": "^19.2.0"
@@ -3553,6 +4061,12 @@
       "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
       "license": "MIT"
     },
+    "node_modules/@types/use-sync-external-store": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+      "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+      "license": "MIT"
+    },
     "node_modules/@types/yauzl": {
       "version": "2.10.3",
       "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -3609,7 +4123,6 @@
       "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@typescript-eslint/scope-manager": "8.59.4",
         "@typescript-eslint/types": "8.59.4",
@@ -4002,7 +4515,6 @@
       "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -4368,7 +4880,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.10.12",
         "caniuse-lite": "^1.0.30001782",
@@ -4881,7 +5392,6 @@
       "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz",
       "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10"
       }
@@ -5291,7 +5801,6 @@
       "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
       "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
       "license": "ISC",
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -6066,7 +6575,6 @@
       "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.2",
@@ -6453,6 +6961,15 @@
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "license": "MIT"
     },
+    "node_modules/fast-equals": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmmirror.com/fast-equals/-/fast-equals-5.4.0.tgz",
+      "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/fast-glob": {
       "version": "3.3.3",
       "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -7227,7 +7744,6 @@
       "resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.21.tgz",
       "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=16.9.0"
       }
@@ -7388,7 +7904,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@babel/runtime": "^7.27.6"
       },
@@ -7712,7 +8227,6 @@
       "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "bin": {
         "jiti": "bin/jiti.js"
       }
@@ -7952,6 +8466,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/linkifyjs": {
+      "version": "4.3.3",
+      "resolved": "https://registry.npmmirror.com/linkifyjs/-/linkifyjs-4.3.3.tgz",
+      "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
+      "license": "MIT"
+    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz",
@@ -9376,6 +9896,12 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/orderedmap": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz",
+      "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+      "license": "MIT"
+    },
     "node_modules/p-cancelable": {
       "version": "2.1.1",
       "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -9547,6 +10073,18 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/pdfjs-dist": {
+      "version": "5.4.394",
+      "resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz",
+      "integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==",
+      "license": "Apache-2.0",
+      "optionalDependencies": {
+        "@napi-rs/canvas": "^0.1.81"
+      },
+      "engines": {
+        "node": ">=20.16.0 || >=22.3.0"
+      }
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -9632,7 +10170,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "nanoid": "^3.3.11",
         "picocolors": "^1.1.1",
@@ -9847,6 +10384,145 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/prosemirror-changeset": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
+      "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-commands": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+      "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.10.2"
+      }
+    },
+    "node_modules/prosemirror-dropcursor": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+      "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0",
+        "prosemirror-view": "^1.1.0"
+      }
+    },
+    "node_modules/prosemirror-gapcursor": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+      "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.0.0",
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-view": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-history": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+      "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.2.2",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.31.0",
+        "rope-sequence": "^1.3.0"
+      }
+    },
+    "node_modules/prosemirror-inputrules": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+      "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-keymap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+      "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "w3c-keyname": "^2.2.0"
+      }
+    },
+    "node_modules/prosemirror-model": {
+      "version": "1.25.7",
+      "resolved": "https://registry.npmmirror.com/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
+      "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
+      "license": "MIT",
+      "dependencies": {
+        "orderedmap": "^2.0.0"
+      }
+    },
+    "node_modules/prosemirror-schema-list": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+      "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.7.3"
+      }
+    },
+    "node_modules/prosemirror-state": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmmirror.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+      "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.27.0"
+      }
+    },
+    "node_modules/prosemirror-tables": {
+      "version": "1.8.5",
+      "resolved": "https://registry.npmmirror.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+      "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.2.3",
+        "prosemirror-model": "^1.25.4",
+        "prosemirror-state": "^1.4.4",
+        "prosemirror-transform": "^1.10.5",
+        "prosemirror-view": "^1.41.4"
+      }
+    },
+    "node_modules/prosemirror-transform": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
+      "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.21.0"
+      }
+    },
+    "node_modules/prosemirror-view": {
+      "version": "1.41.8",
+      "resolved": "https://registry.npmmirror.com/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
+      "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.20.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0"
+      }
+    },
     "node_modules/protobufjs": {
       "version": "7.6.0",
       "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.6.0.tgz",
@@ -10045,7 +10721,6 @@
       "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz",
       "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -10055,7 +10730,6 @@
       "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz",
       "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -10436,6 +11110,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/rope-sequence": {
+      "version": "1.3.4",
+      "resolved": "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz",
+      "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+      "license": "MIT"
+    },
     "node_modules/roughjs": {
       "version": "4.6.6",
       "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz",
@@ -11228,7 +11908,6 @@
       "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -11428,7 +12107,6 @@
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "devOptional": true,
       "license": "Apache-2.0",
-      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -11626,6 +12304,15 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/use-sync-external-store": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11718,7 +12405,6 @@
       "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.4.4",
@@ -11812,7 +12498,6 @@
       "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -12155,7 +12840,6 @@
       "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
       "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
       "license": "MIT",
-      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }
diff --git a/package.json b/package.json
index 363b03fb..4f588147 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
-  "name": "deepseek-gui",
-  "productName": "DeepSeek GUI",
+  "name": "kun-gui",
+  "productName": "Kun",
   "version": "0.1.0",
   "description": "Electron workbench for the Kun runtime (HTTP/SSE)",
   "main": "./out/main/index.js",
@@ -13,8 +13,8 @@
     "test": "vitest run",
     "test:watch": "vitest",
     "dist": "npm run build && npx --yes electron-builder@26.8.1 --config electron-builder.config.cjs --publish never",
-    "dist:mac": "rm -f dist/DeepSeek-GUI-*-mac-* dist/latest-mac.yml && npm run build && npm run dist:mac:x64:dmg && npm run dist:mac:x64:zip && npm run dist:mac:arm64:dmg && npm run dist:mac:arm64:zip && node ./scripts/generate-mac-latest.cjs dist",
-    "dist:mac:signed": "rm -f dist/DeepSeek-GUI-*-mac-* dist/latest-mac.yml && MAC_SIGN=1 npm run dist:mac:x64 && MAC_SIGN=1 npm run dist:mac:arm64 && node ./scripts/generate-mac-latest.cjs dist",
+    "dist:mac": "rm -f dist/Kun-*-mac-* dist/DeepSeek-GUI-*-mac-* dist/latest-mac.yml && npm run build && npm run dist:mac:x64:dmg && npm run dist:mac:x64:zip && npm run dist:mac:arm64:dmg && npm run dist:mac:arm64:zip && node ./scripts/generate-mac-latest.cjs dist",
+    "dist:mac:signed": "rm -f dist/Kun-*-mac-* dist/DeepSeek-GUI-*-mac-* dist/latest-mac.yml && MAC_SIGN=1 npm run dist:mac:x64 && MAC_SIGN=1 npm run dist:mac:arm64 && node ./scripts/generate-mac-latest.cjs dist",
     "dist:mac:arm64": "npm run build && npm run dist:mac:arm64:dmg && npm run dist:mac:arm64:zip",
     "dist:mac:x64": "npm run build && npm run dist:mac:x64:dmg && npm run dist:mac:x64:zip",
     "dist:mac:arm64:dmg": "npx --yes electron-builder@26.8.1 --config electron-builder.config.cjs --publish never --mac dmg --arm64",
@@ -45,6 +45,14 @@
     "@larksuiteoapi/node-sdk": "^1.64.0",
     "@modelcontextprotocol/sdk": "^1.29.0",
     "@tencent-weixin/openclaw-weixin": "2.4.3",
+    "@tiptap/core": "^3.26.0",
+    "@tiptap/extension-image": "^3.26.0",
+    "@tiptap/extension-list": "^3.26.0",
+    "@tiptap/extension-table": "^3.26.0",
+    "@tiptap/markdown": "^3.26.0",
+    "@tiptap/pm": "^3.26.0",
+    "@tiptap/react": "^3.26.0",
+    "@tiptap/starter-kit": "^3.26.0",
     "better-sqlite3": "^12.10.0",
     "electron-store": "^10.1.0",
     "electron-updater": "^6.8.3",
@@ -52,6 +60,7 @@
     "i18next": "^25.4.2",
     "lucide-react": "^0.544.0",
     "openclaw": "file:vendor/openclaw-shim",
+    "pdfjs-dist": "^5.4.394",
     "qrcode.react": "^4.2.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
@@ -84,10 +93,11 @@
     "vite": "^6.2.0",
     "vitest": "^4.1.7"
   },
-  "author": "DeepSeek GUI Contributors",
-  "homepage": "https://deepseek-gui.com",
+  "author": "Kun Contributors",
+  "license": "PolyForm-Noncommercial-1.0.0",
+  "homepage": "https://github.com/KunAgent/Kun",
   "repository": {
     "type": "git",
-    "url": "https://github.com/XingYu-Zhong/DeepSeek-GUI.git"
+    "url": "https://github.com/KunAgent/Kun.git"
   }
 }
diff --git a/scripts/compute-ci-release-version.cjs b/scripts/compute-ci-release-version.cjs
index faf45477..1e587c9f 100644
--- a/scripts/compute-ci-release-version.cjs
+++ b/scripts/compute-ci-release-version.cjs
@@ -5,7 +5,7 @@ const { appendFileSync, readFileSync } = require('node:fs')
 const { join } = require('node:path')
 
 const ROOT = join(__dirname, '..')
-const PRODUCT_NAME = 'DeepSeek GUI'
+const PRODUCT_NAME = 'Kun'
 const SEMVER_TAG = /^v(\d+)\.(\d+)\.(\d+)$/
 const SEMVER_VERSION = /^(\d+)\.(\d+)\.(\d+)$/
 
diff --git a/scripts/generate-mac-latest.cjs b/scripts/generate-mac-latest.cjs
index 267480bb..6d0504e8 100644
--- a/scripts/generate-mac-latest.cjs
+++ b/scripts/generate-mac-latest.cjs
@@ -37,7 +37,7 @@ async function main() {
   const artifacts = []
 
   for (const fileName of entries) {
-    const match = fileName.match(/^DeepSeek-GUI-(.+)-mac-(arm64|x64)\.(zip|dmg)$/)
+    const match = fileName.match(/^Kun-(.+)-mac-(arm64|x64)\.(zip|dmg)$/)
     if (!match) continue
     artifacts.push({
       fileName,
diff --git a/scripts/generate-release-notes.cjs b/scripts/generate-release-notes.cjs
index 319e8737..16f223c8 100644
--- a/scripts/generate-release-notes.cjs
+++ b/scripts/generate-release-notes.cjs
@@ -99,7 +99,7 @@ function main() {
 
   const out = ['## 更新摘要', '']
   if (sinceTag) {
-    out.push(`自 [\`${sinceTag}\`](https://github.com/XingYu-Zhong/DeepSeek-GUI/compare/${sinceTag}...HEAD) 以来的变更:`, '')
+    out.push(`自 [\`${sinceTag}\`](https://github.com/KunAgent/Kun/compare/${sinceTag}...HEAD) 以来的变更:`, '')
   }
 
   let wroteSection = false
diff --git a/scripts/lib/release-common.sh b/scripts/lib/release-common.sh
index 7787b454..cb703eec 100644
--- a/scripts/lib/release-common.sh
+++ b/scripts/lib/release-common.sh
@@ -22,6 +22,7 @@ release_normalize_channel() {
 release_export_update_channel() {
   RELEASE_CHANNEL="$(release_normalize_channel "${RELEASE_CHANNEL:-frontier}")"
   export RELEASE_CHANNEL
+  export KUN_UPDATE_CHANNEL="${RELEASE_CHANNEL}"
   export DEEPSEEK_GUI_UPDATE_CHANNEL="${RELEASE_CHANNEL}"
   cyan "  Channel: ${RELEASE_CHANNEL}"
 }
@@ -42,7 +43,7 @@ release_root() {
 }
 
 release_load_local_env() {
-  local env_file="${DEEPSEEK_GUI_RELEASE_ENV:-}"
+  local env_file="${KUN_RELEASE_ENV:-${DEEPSEEK_GUI_RELEASE_ENV:-}}"
 
   if [[ -z "${env_file}" ]]; then
     if [[ -f "${ROOT}/scripts/release.local.env" ]]; then
@@ -79,7 +80,7 @@ release_compute_version() {
     [[ "${TAG_NAME}" == v* ]] || TAG_NAME="v${TAG_NAME}"
     RELEASE_VERSION="${TAG_NAME#v}"
     release_validate_semver "${RELEASE_VERSION}" || die "Release tag must be vX.Y.Z. electron-updater cannot use four-part versions: ${TAG_NAME}"
-    RELEASE_NAME="DeepSeek GUI ${RELEASE_VERSION}"
+    RELEASE_NAME="Kun ${RELEASE_VERSION}"
     LATEST_TAG=""
     return
   fi
@@ -114,13 +115,14 @@ release_compute_version() {
   RELEASE_VERSION="${MAJOR}.${MINOR}.${PATCH}"
 
   TAG_NAME="v${RELEASE_VERSION}"
-  RELEASE_NAME="DeepSeek GUI ${RELEASE_VERSION}"
+  RELEASE_NAME="Kun ${RELEASE_VERSION}"
 }
 
 release_export_app_version() {
   release_validate_semver "${RELEASE_VERSION}" || die "Invalid release version for electron-updater: ${RELEASE_VERSION}"
+  export KUN_APP_VERSION="${RELEASE_VERSION}"
   export DEEPSEEK_GUI_APP_VERSION="${RELEASE_VERSION}"
-  cyan "  App:     ${DEEPSEEK_GUI_APP_VERSION}"
+  cyan "  App:     ${KUN_APP_VERSION}"
 }
 
 release_ensure_tag_available() {
@@ -157,7 +159,7 @@ release_acquire_lock() {
 
 release_clean_dist_artifacts() {
   rm -rf "${ROOT}/dist/mac" "${ROOT}/dist/mac-arm64" "${ROOT}/dist/.mac-build" "${ROOT}/dist/win-unpacked" "${ROOT}/dist/linux-unpacked"
-  rm -f "${ROOT}"/dist/DeepSeek-GUI-* "${ROOT}"/dist/DeepSeek\ GUI-* "${ROOT}"/dist/latest*.yml "${ROOT}"/dist/*.blockmap
+  rm -f "${ROOT}"/dist/Kun-* "${ROOT}"/dist/DeepSeek-GUI-* "${ROOT}"/dist/DeepSeek\ GUI-* "${ROOT}"/dist/latest*.yml "${ROOT}"/dist/*.blockmap
 }
 
 release_apply_signing_env() {
diff --git a/scripts/mac-unquarantine.sh b/scripts/mac-unquarantine.sh
index b7ce6738..51a64895 100755
--- a/scripts/mac-unquarantine.sh
+++ b/scripts/mac-unquarantine.sh
@@ -1,9 +1,9 @@
 #!/usr/bin/env bash
 set -euo pipefail
-APP_PATH="${1:-dist/mac-arm64/DeepSeek GUI.app}"
+APP_PATH="${1:-dist/mac-arm64/Kun.app}"
 if [ ! -d "$APP_PATH" ]; then
   echo "App not found: $APP_PATH" >&2
-  echo "Usage: npm run mac:unquarantine -- '/path/to/DeepSeek GUI.app'" >&2
+  echo "Usage: npm run mac:unquarantine -- '/path/to/Kun.app'" >&2
   exit 1
 fi
 xattr -cr "$APP_PATH"
diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs
index fd7857a0..5e0f2971 100644
--- a/scripts/postinstall.cjs
+++ b/scripts/postinstall.cjs
@@ -1,9 +1,10 @@
 const { spawnSync } = require('node:child_process')
 
-function run(command, args) {
+function run(command, args, options = {}) {
   return spawnSync(command, args, {
     stdio: 'inherit',
-    shell: process.platform === 'win32'
+    shell: process.platform === 'win32',
+    ...options
   })
 }
 
@@ -13,3 +14,24 @@ const buildKun = run('npm', ['--prefix', 'kun', 'run', 'build'])
 if (buildKun.status !== 0) {
   process.exit(buildKun.status || 1)
 }
+
+// Kun is spawned with the Electron binary (ELECTRON_RUN_AS_NODE) and resolves
+// better-sqlite3 from the root node_modules, so the native module must match
+// Electron's ABI — the node-ABI prebuild that `npm install` fetches cannot be
+// loaded there and Kun would silently fall back to JSONL scanning. Best
+// effort: a failure (e.g. offline) keeps the JSONL fallback working.
+const { join } = require('node:path')
+try {
+  const electronVersion = require('electron/package.json').version
+  const result = run('npx', [
+    '--yes',
+    'prebuild-install',
+    `--runtime=electron`,
+    `--target=${electronVersion}`
+  ], { cwd: join(__dirname, '..', 'node_modules', 'better-sqlite3') })
+  if (result.status !== 0) {
+    console.warn('[postinstall] better-sqlite3 electron prebuild failed; Kun will use the JSONL fallback')
+  }
+} catch (error) {
+  console.warn('[postinstall] skipped better-sqlite3 electron prebuild:', error.message)
+}
diff --git a/scripts/publish-r2.mjs b/scripts/publish-r2.mjs
index b704128f..eca86f61 100644
--- a/scripts/publish-r2.mjs
+++ b/scripts/publish-r2.mjs
@@ -13,7 +13,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
 import { basename, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 
-const PRODUCT_NAME = 'DeepSeek GUI'
+const PRODUCT_NAME = 'Kun'
 const DEFAULT_RELEASE_PREFIX = 'deepseek-gui'
 const DEFAULT_RELEASE_CHANNEL = 'frontier'
 const PLATFORMS = ['mac', 'win', 'linux']
@@ -24,15 +24,15 @@ const ROOT = resolve(SCRIPT_DIR, '..')
 const PLATFORM_SPECS = {
   mac: {
     updateFile: 'latest-mac.yml',
-    assetPattern: /^DeepSeek-GUI-.+-mac-(arm64|x64)\.(dmg|zip)(\.blockmap)?$/
+    assetPattern: /^Kun-.+-mac-(arm64|x64)\.(dmg|zip)(\.blockmap)?$/
   },
   win: {
     updateFile: 'latest.yml',
-    assetPattern: /^DeepSeek-GUI-.+-win-x64\.exe(\.blockmap)?$/
+    assetPattern: /^Kun-.+-win-x64\.exe(\.blockmap)?$/
   },
   linux: {
     updateFile: 'latest-linux.yml',
-    assetPattern: /^DeepSeek-GUI-.+-linux-x86_64\.AppImage(\.blockmap)?$/
+    assetPattern: /^Kun-.+-linux-x86_64\.AppImage(\.blockmap)?$/
   }
 }
 
@@ -45,7 +45,7 @@ If --platforms is omitted, promote uses the platform manifests already uploaded
 If --channel is omitted, the default channel is frontier.
 
 Environment:
-  DEEPSEEK_GUI_RELEASE_ENV=scripts/release.local.env
+  KUN_RELEASE_ENV=scripts/release.local.env (legacy DEEPSEEK_GUI_RELEASE_ENV is also accepted)
   RELEASE_CHANNEL=frontier|stable
   R2_BUCKET or S3_BUCKET
   R2_ENDPOINT or S3_ENDPOINT
@@ -76,7 +76,7 @@ function parseEnvFile(content) {
 }
 
 function loadLocalEnv() {
-  const configured = process.env.DEEPSEEK_GUI_RELEASE_ENV?.trim()
+  const configured = process.env.KUN_RELEASE_ENV?.trim() || process.env.DEEPSEEK_GUI_RELEASE_ENV?.trim()
   const candidates = [
     configured,
     join(ROOT, 'scripts', 'release.local.env'),
@@ -152,6 +152,7 @@ function readChannel(flags) {
   return normalizeChannel(
     flags.get('channel') ||
       process.env.RELEASE_CHANNEL ||
+      process.env.KUN_UPDATE_CHANNEL ||
       process.env.DEEPSEEK_GUI_UPDATE_CHANNEL ||
       DEFAULT_RELEASE_CHANNEL
   )
@@ -352,7 +353,7 @@ async function collectPlatformRelease({ distDir, platform, tag, channel, config
   const tagVersion = tag.slice(1)
   if (updateMetadata.version !== tagVersion) {
     throw new Error(
-      `${spec.updateFile} version ${updateMetadata.version} does not match ${tag}. Rebuild with DEEPSEEK_GUI_APP_VERSION=${tagVersion}.`
+      `${spec.updateFile} version ${updateMetadata.version} does not match ${tag}. Rebuild with KUN_APP_VERSION=${tagVersion} (legacy DEEPSEEK_GUI_APP_VERSION is also accepted).`
     )
   }
 
@@ -632,7 +633,7 @@ async function promoteRelease({ flags, dryRun }) {
       tag,
       releaseDate,
       generatedAt: new Date().toISOString(),
-      githubReleaseUrl: `https://github.com/XingYu-Zhong/DeepSeek-GUI/releases/tag/${tag}`,
+      githubReleaseUrl: `https://github.com/KunAgent/Kun/releases/tag/${tag}`,
       updateBaseUrl: joinUrl(config.publicBaseUrl, target.basePath, 'latest') + '/',
       updateMetadata: Object.fromEntries(
         platformManifests.map((manifest) => [
diff --git a/scripts/release-mac.sh b/scripts/release-mac.sh
index 7b4441fd..53bcacd3 100755
--- a/scripts/release-mac.sh
+++ b/scripts/release-mac.sh
@@ -20,7 +20,7 @@ set -euo pipefail
 # Speed knobs:
 #   MAC_RELEASE_PARALLEL=force      force parallel arm64/x64 builds even when signing
 #   RELEASE_UPLOAD_CONCURRENCY=4    GitHub/R2 upload concurrency
-#   DEEPSEEK_GUI_RUNTIME_CACHE=0    disable bundled runtime cache
+#   KUN_RUNTIME_CACHE=0             disable bundled runtime cache
 #
 # After this completes, run on Windows (same version):
 #   ./scripts/release-win.sh --tag v --r2 --r2-promote --publish
@@ -82,10 +82,10 @@ build_mac_arch() {
 
   mkdir -p "${output_dir}" "$(dirname "${log_file}")"
   cyan "  ${arch}: building dmg + zip -> ${output_dir}"
-  DEEPSEEK_GUI_DIST_DIR="${output_dir}" \
+  KUN_DIST_DIR="${output_dir}" \
     npx --yes electron-builder@26.8.1 --config electron-builder.config.cjs --publish never --mac dmg "--${arch}" \
     >"${log_file}" 2>&1
-  DEEPSEEK_GUI_DIST_DIR="${output_dir}" \
+  KUN_DIST_DIR="${output_dir}" \
     node "${ROOT}/scripts/zip-mac-app.cjs" "${arch}" \
     >>"${log_file}" 2>&1
 }
@@ -96,7 +96,7 @@ copy_mac_arch_artifacts() {
   local files=()
 
   shopt -s nullglob
-  files=("${output_dir}"/DeepSeek-GUI-*-mac-"${arch}".*)
+  files=("${output_dir}"/Kun-*-mac-"${arch}".*)
   shopt -u nullglob
 
   [[ ${#files[@]} -gt 0 ]] || die "No macOS ${arch} artifacts found in ${output_dir}"
@@ -247,12 +247,12 @@ collect_optional() {
   done
 }
 
-# artifactName: ${productName}-${version}-mac-${arch}.dmg|zip
-collect "macOS arm64 dmg" "dist/DeepSeek-GUI-*-mac-arm64.dmg"
-collect "macOS x64 dmg" "dist/DeepSeek-GUI-*-mac-x64.dmg"
-collect "macOS arm64 zip" "dist/DeepSeek-GUI-*-mac-arm64.zip"
-collect "macOS x64 zip" "dist/DeepSeek-GUI-*-mac-x64.zip"
-collect_optional "macOS blockmap" "dist/DeepSeek-GUI-*-mac-*.zip.blockmap"
+# artifactName: Kun-${version}-mac-${arch}.dmg|zip
+collect "macOS arm64 dmg" "dist/Kun-*-mac-arm64.dmg"
+collect "macOS x64 dmg" "dist/Kun-*-mac-x64.dmg"
+collect "macOS arm64 zip" "dist/Kun-*-mac-arm64.zip"
+collect "macOS x64 zip" "dist/Kun-*-mac-x64.zip"
+collect_optional "macOS blockmap" "dist/Kun-*-mac-*.zip.blockmap"
 
 upload_github_assets() {
   local tag="$1"
@@ -308,7 +308,7 @@ This is an unsigned build. macOS Gatekeeper will block first launch.
 Run this after downloading:
 
 ```sh
-xattr -cr "DeepSeek GUI.app"
+xattr -cr "Kun.app"
 # or
 npm run mac:unquarantine
 ```
@@ -357,4 +357,4 @@ green "macOS release ${TAG_NAME} ready (draft)."
 cyan "  Meta: dist/.release-meta.env"
 cyan "  Channel: ${RELEASE_CHANNEL}"
 cyan "  Next on Windows: ./scripts/release-win.sh --tag ${TAG_NAME} --channel ${RELEASE_CHANNEL}"
-cyan "  https://github.com/XingYu-Zhong/DeepSeek-GUI/releases/tag/${TAG_NAME}"
+cyan "  https://github.com/KunAgent/Kun/releases/tag/${TAG_NAME}"
diff --git a/scripts/release-win.ps1 b/scripts/release-win.ps1
index 6def61c6..d8662447 100644
--- a/scripts/release-win.ps1
+++ b/scripts/release-win.ps1
@@ -52,7 +52,10 @@ function Require-Command([string]$Name) {
 }
 
 function Load-LocalReleaseEnv([string]$RootPath) {
-  $configured = [Environment]::GetEnvironmentVariable('DEEPSEEK_GUI_RELEASE_ENV', 'Process')
+  $configured = [Environment]::GetEnvironmentVariable('KUN_RELEASE_ENV', 'Process')
+  if (-not $configured) {
+    $configured = [Environment]::GetEnvironmentVariable('DEEPSEEK_GUI_RELEASE_ENV', 'Process')
+  }
   $candidates = @()
   if ($configured) { $candidates += $configured }
   $candidates += (Join-Path $RootPath 'scripts\release.local.env')
@@ -94,6 +97,8 @@ $RequestedChannel = if ($Stable) {
   $Channel
 } elseif ($env:RELEASE_CHANNEL) {
   $env:RELEASE_CHANNEL
+} elseif ($env:KUN_UPDATE_CHANNEL) {
+  $env:KUN_UPDATE_CHANNEL
 } elseif ($env:DEEPSEEK_GUI_UPDATE_CHANNEL) {
   $env:DEEPSEEK_GUI_UPDATE_CHANNEL
 } else {
@@ -138,10 +143,12 @@ Write-Info "GitHub release tag: $TagName"
 Write-Info "Release channel: $ReleaseChannel"
 $ReleaseVersion = $TagName.TrimStart('v')
 Assert-Semver $ReleaseVersion
+$env:KUN_APP_VERSION = $ReleaseVersion
 $env:DEEPSEEK_GUI_APP_VERSION = $ReleaseVersion
 $env:RELEASE_CHANNEL = $ReleaseChannel
+$env:KUN_UPDATE_CHANNEL = $ReleaseChannel
 $env:DEEPSEEK_GUI_UPDATE_CHANNEL = $ReleaseChannel
-Write-Info "App version: $env:DEEPSEEK_GUI_APP_VERSION"
+Write-Info "App version: $env:KUN_APP_VERSION"
 
 & gh release view $TagName 2>&1 | Out-Null
 if ($LASTEXITCODE -ne 0) {
@@ -158,6 +165,7 @@ Remove-Item -Recurse -Force -ErrorAction SilentlyContinue `
   (Join-Path $Root 'dist\mac-arm64'), `
   (Join-Path $Root 'dist\linux-unpacked')
 Remove-Item -Force -ErrorAction SilentlyContinue `
+  (Join-Path $Root 'dist\Kun-*'), `
   (Join-Path $Root 'dist\DeepSeek-GUI-*'), `
   (Join-Path $Root 'dist\DeepSeek GUI-*'), `
   (Join-Path $Root 'dist\latest*.yml'), `
diff --git a/scripts/release-win.sh b/scripts/release-win.sh
index 501613bd..ceee537b 100755
--- a/scripts/release-win.sh
+++ b/scripts/release-win.sh
@@ -107,8 +107,8 @@ collect() {
   done
 }
 
-collect "Windows exe" "dist/DeepSeek-GUI-*-win-*.exe"
-collect "Windows blockmap" "dist/DeepSeek-GUI-*-win-*.exe.blockmap"
+collect "Windows exe" "dist/Kun-*-win-*.exe"
+collect "Windows blockmap" "dist/Kun-*-win-*.exe.blockmap"
 
 cyan "Uploading ${#ASSETS[@]} Windows asset(s) to ${TAG_NAME}..."
 for asset in "${ASSETS[@]}"; do
diff --git a/scripts/release.local.env.example b/scripts/release.local.env.example
index 73e28a1c..fb3ec56f 100644
--- a/scripts/release.local.env.example
+++ b/scripts/release.local.env.example
@@ -9,7 +9,7 @@ S3_SECRET_ACCESS_KEY=
 
 # Public download domain used in latest.json and electron-updater metadata.
 # Use an R2 custom domain for production, not the authenticated S3 endpoint.
-R2_PUBLIC_BASE_URL=https://deepseek-gui.com/api/r2
+R2_PUBLIC_BASE_URL=https://www.kun-agent.com/api/r2
 R2_RELEASE_PREFIX=deepseek-gui
 
 # Default release channel for scripts when --channel is not passed.
diff --git a/scripts/tiptap-roundtrip-audit.mjs b/scripts/tiptap-roundtrip-audit.mjs
new file mode 100644
index 00000000..384c9682
--- /dev/null
+++ b/scripts/tiptap-roundtrip-audit.mjs
@@ -0,0 +1,162 @@
+#!/usr/bin/env node
+// Round-trip fidelity audit for the Tiptap markdown migration (Phase 0 gate).
+//
+// For every markdown file in the corpus we measure:
+//   stable  — serialize(parse(s1)) === s1 where s1 = serialize(parse(md)).
+//             Instability means autosave would keep rewriting the file forever.
+//   exact   — s1 === md (modulo trailing newline). Pure formatting fidelity.
+//   text    — plain text extracted from parse(md) matches plain text of parse(s1).
+//             Text loss means real content was dropped, not just reformatted.
+//
+// Usage: node scripts/tiptap-roundtrip-audit.mjs [--verbose] [--sample ]
+
+import { readFileSync, readdirSync, statSync } from 'node:fs'
+import { join, relative } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url))
+const SKIP_DIRS = new Set(['node_modules', 'dist', 'out', 'release', 'build', '.git', 'vendor'])
+const verbose = process.argv.includes('--verbose')
+const sampleArg = process.argv.indexOf('--sample')
+
+const [{ StarterKit }, { MarkdownManager }, { TableKit }, { TaskList, TaskItem }, { Image }] =
+  await Promise.all([
+    import('@tiptap/starter-kit'),
+    import('@tiptap/markdown'),
+    import('@tiptap/extension-table'),
+    import('@tiptap/extension-list'),
+    import('@tiptap/extension-image')
+  ])
+
+function createManager() {
+  return new MarkdownManager({
+    markedOptions: { gfm: true },
+    extensions: [
+      StarterKit.configure({ link: { openOnClick: false } }),
+      TableKit,
+      TaskList,
+      TaskItem.configure({ nested: true }),
+      Image
+    ]
+  })
+}
+
+function collectMarkdownFiles(dir, acc) {
+  for (const entry of readdirSync(dir)) {
+    if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue
+    const full = join(dir, entry)
+    let stats
+    try {
+      stats = statSync(full)
+    } catch {
+      continue
+    }
+    if (stats.isDirectory()) {
+      collectMarkdownFiles(full, acc)
+    } else if (entry.endsWith('.md') && stats.size > 0 && stats.size < 512 * 1024) {
+      acc.push(full)
+    }
+  }
+  return acc
+}
+
+function plainText(node, acc = []) {
+  if (!node) return acc
+  if (node.type === 'text' && node.text) acc.push(node.text)
+  if (Array.isArray(node.content)) {
+    for (const child of node.content) plainText(child, acc)
+  }
+  return acc
+}
+
+function normalizeText(parts) {
+  return parts.join(' ').replace(/\s+/g, ' ').trim()
+}
+
+function firstDiffLine(a, b) {
+  const linesA = a.split('\n')
+  const linesB = b.split('\n')
+  for (let i = 0; i < Math.max(linesA.length, linesB.length); i += 1) {
+    if (linesA[i] !== linesB[i]) {
+      return { line: i + 1, before: linesA[i] ?? '', after: linesB[i] ?? '' }
+    }
+  }
+  return null
+}
+
+function audit(markdown) {
+  const manager = createManager()
+  const doc1 = manager.parse(markdown)
+  const s1 = manager.serialize(doc1)
+  const doc2 = manager.parse(s1)
+  const s2 = manager.serialize(doc2)
+
+  const trimmedSource = markdown.replace(/\n+$/, '')
+  return {
+    stable: s1 === s2,
+    exact: s1 === trimmedSource,
+    textPreserved: normalizeText(plainText(doc1)) === normalizeText(plainText(doc2)),
+    sourceTextPreserved:
+      normalizeText(plainText(doc1)).length > 0 || trimmedSource.trim().length === 0,
+    s1,
+    s2,
+    diff: s1 === trimmedSource ? null : firstDiffLine(trimmedSource, s1),
+    instabilityDiff: s1 === s2 ? null : firstDiffLine(s1, s2)
+  }
+}
+
+if (sampleArg >= 0) {
+  const file = process.argv[sampleArg + 1]
+  const markdown = readFileSync(file, 'utf8')
+  const result = audit(markdown)
+  console.log(JSON.stringify({ file, stable: result.stable, exact: result.exact }, null, 2))
+  if (result.diff) console.log('first diff:', JSON.stringify(result.diff, null, 2))
+  if (result.instabilityDiff) {
+    console.log('INSTABILITY:', JSON.stringify(result.instabilityDiff, null, 2))
+  }
+  process.exit(0)
+}
+
+const files = collectMarkdownFiles(ROOT, [])
+const summary = { total: 0, stable: 0, exact: 0, textPreserved: 0, failures: [], normalized: [] }
+
+for (const file of files) {
+  const markdown = readFileSync(file, 'utf8')
+  const rel = relative(ROOT, file)
+  let result
+  try {
+    result = audit(markdown)
+  } catch (error) {
+    summary.total += 1
+    summary.failures.push({ file: rel, error: error.message })
+    continue
+  }
+  summary.total += 1
+  if (result.stable) summary.stable += 1
+  else summary.failures.push({ file: rel, instability: result.instabilityDiff })
+  if (result.exact) summary.exact += 1
+  else summary.normalized.push({ file: rel, diff: result.diff })
+  if (result.textPreserved) summary.textPreserved += 1
+}
+
+console.log('--- tiptap markdown round-trip audit ---')
+console.log(`corpus: ${summary.total} files`)
+console.log(`stable (idempotent after 1 pass): ${summary.stable}/${summary.total}`)
+console.log(`exact (byte-identical): ${summary.exact}/${summary.total}`)
+console.log(`text preserved across passes: ${summary.textPreserved}/${summary.total}`)
+
+if (summary.failures.length > 0) {
+  console.log('\nINSTABILITY / ERRORS (hard gate failures):')
+  for (const failure of summary.failures.slice(0, 20)) {
+    console.log(' ', JSON.stringify(failure))
+  }
+}
+
+if (verbose && summary.normalized.length > 0) {
+  console.log('\nnormalization diffs (soft, formatting-only):')
+  for (const item of summary.normalized.slice(0, 40)) {
+    console.log(' ', item.file, '->', JSON.stringify(item.diff))
+  }
+}
+
+process.exit(summary.failures.length > 0 ? 1 : 0)
diff --git a/scripts/zip-mac-app.cjs b/scripts/zip-mac-app.cjs
index c6339b13..ddff268c 100644
--- a/scripts/zip-mac-app.cjs
+++ b/scripts/zip-mac-app.cjs
@@ -12,17 +12,17 @@ if (arch !== 'arm64' && arch !== 'x64') {
 
 const root = resolve(__dirname, '..')
 const pkg = require(join(root, 'package.json'))
-const version = (process.env.DEEPSEEK_GUI_APP_VERSION || pkg.version || '').trim()
+const version = (process.env.KUN_APP_VERSION || process.env.DEEPSEEK_GUI_APP_VERSION || pkg.version || '').trim()
 if (!version) {
   console.error('[zip-mac-app] Could not resolve package version.')
   process.exit(1)
 }
 
-const distDir = resolve(process.env.DEEPSEEK_GUI_DIST_DIR || join(root, 'dist'))
+const distDir = resolve(process.env.KUN_DIST_DIR || process.env.DEEPSEEK_GUI_DIST_DIR || join(root, 'dist'))
 const appOutDir = join(distDir, arch === 'arm64' ? 'mac-arm64' : 'mac')
-const appName = 'DeepSeek GUI.app'
+const appName = 'Kun.app'
 const appPath = join(appOutDir, appName)
-const zipPath = join(distDir, `DeepSeek-GUI-${version}-mac-${arch}.zip`)
+const zipPath = join(distDir, `Kun-${version}-mac-${arch}.zip`)
 
 if (!existsSync(appPath)) {
   console.error(`[zip-mac-app] App bundle not found: ${appPath}`)
diff --git a/src/asset/img/clawmode.png b/src/asset/img/clawmode.png
deleted file mode 100644
index 72de0eae..00000000
Binary files a/src/asset/img/clawmode.png and /dev/null differ
diff --git a/src/asset/img/code.gif b/src/asset/img/code.gif
index f6bc8986..c10b57f4 100644
Binary files a/src/asset/img/code.gif and b/src/asset/img/code.gif differ
diff --git a/src/asset/img/code.mp4 b/src/asset/img/code.mp4
index c5c91b64..92e8c012 100644
Binary files a/src/asset/img/code.mp4 and b/src/asset/img/code.mp4 differ
diff --git a/src/asset/img/codemode.png b/src/asset/img/codemode.png
deleted file mode 100644
index 0db68392..00000000
Binary files a/src/asset/img/codemode.png and /dev/null differ
diff --git a/src/asset/img/deepseek.png b/src/asset/img/deepseek.png
deleted file mode 100644
index 789cb759..00000000
Binary files a/src/asset/img/deepseek.png and /dev/null differ
diff --git a/src/asset/img/deepseek.svg b/src/asset/img/deepseek.svg
deleted file mode 100644
index 4190d3f4..00000000
--- a/src/asset/img/deepseek.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-  
-    
-  
-  
-    
-  
-
diff --git a/src/asset/img/deepseek_gui_tray.png b/src/asset/img/deepseek_gui_tray.png
deleted file mode 100644
index 26af33bf..00000000
Binary files a/src/asset/img/deepseek_gui_tray.png and /dev/null differ
diff --git a/src/asset/img/feishu.gif b/src/asset/img/feishu.gif
deleted file mode 100644
index dd8b4ae4..00000000
Binary files a/src/asset/img/feishu.gif and /dev/null differ
diff --git a/src/asset/img/feishu.mp4 b/src/asset/img/feishu.mp4
deleted file mode 100644
index fffca1f0..00000000
Binary files a/src/asset/img/feishu.mp4 and /dev/null differ
diff --git a/src/asset/img/ikun-ui-plugin.gif b/src/asset/img/ikun-ui-plugin.gif
new file mode 100644
index 00000000..93e4c563
Binary files /dev/null and b/src/asset/img/ikun-ui-plugin.gif differ
diff --git a/src/asset/img/ikun-ui-plugin.mp4 b/src/asset/img/ikun-ui-plugin.mp4
new file mode 100644
index 00000000..18b1641a
Binary files /dev/null and b/src/asset/img/ikun-ui-plugin.mp4 differ
diff --git a/src/asset/img/ikun.png b/src/asset/img/ikun.png
new file mode 100644
index 00000000..4fd3ae0e
Binary files /dev/null and b/src/asset/img/ikun.png differ
diff --git a/src/asset/img/ikun_boba.png b/src/asset/img/ikun_boba.png
new file mode 100644
index 00000000..7f00e0fe
Binary files /dev/null and b/src/asset/img/ikun_boba.png differ
diff --git a/src/asset/img/ikun_run.png b/src/asset/img/ikun_run.png
new file mode 100644
index 00000000..18eedcac
Binary files /dev/null and b/src/asset/img/ikun_run.png differ
diff --git a/src/asset/img/ikun_sleep.png b/src/asset/img/ikun_sleep.png
new file mode 100644
index 00000000..b16f1d87
Binary files /dev/null and b/src/asset/img/ikun_sleep.png differ
diff --git a/src/asset/img/ikun_stand.png b/src/asset/img/ikun_stand.png
new file mode 100644
index 00000000..51bf1971
Binary files /dev/null and b/src/asset/img/ikun_stand.png differ
diff --git a/src/asset/img/ikun_wave.png b/src/asset/img/ikun_wave.png
new file mode 100644
index 00000000..83a35565
Binary files /dev/null and b/src/asset/img/ikun_wave.png differ
diff --git a/src/asset/img/kun.png b/src/asset/img/kun.png
new file mode 100644
index 00000000..8f493b24
Binary files /dev/null and b/src/asset/img/kun.png differ
diff --git a/src/asset/img/kun_bird.png b/src/asset/img/kun_bird.png
new file mode 100644
index 00000000..cd5ff6cb
Binary files /dev/null and b/src/asset/img/kun_bird.png differ
diff --git a/src/asset/img/kun_greet.png b/src/asset/img/kun_greet.png
new file mode 100644
index 00000000..0a1b3360
Binary files /dev/null and b/src/asset/img/kun_greet.png differ
diff --git a/src/asset/img/kun_mac.png b/src/asset/img/kun_mac.png
new file mode 100644
index 00000000..d7902612
Binary files /dev/null and b/src/asset/img/kun_mac.png differ
diff --git a/src/asset/img/kun_sit.png b/src/asset/img/kun_sit.png
new file mode 100644
index 00000000..65e02fa0
Binary files /dev/null and b/src/asset/img/kun_sit.png differ
diff --git a/src/asset/img/kun_sleep.png b/src/asset/img/kun_sleep.png
new file mode 100644
index 00000000..3ac84bfd
Binary files /dev/null and b/src/asset/img/kun_sleep.png differ
diff --git a/src/asset/img/kun_surf.png b/src/asset/img/kun_surf.png
new file mode 100644
index 00000000..ef2723ae
Binary files /dev/null and b/src/asset/img/kun_surf.png differ
diff --git a/src/asset/img/kun_tray.png b/src/asset/img/kun_tray.png
new file mode 100644
index 00000000..e3983398
Binary files /dev/null and b/src/asset/img/kun_tray.png differ
diff --git a/src/asset/img/pdf-research.gif b/src/asset/img/pdf-research.gif
new file mode 100644
index 00000000..596d2ff9
Binary files /dev/null and b/src/asset/img/pdf-research.gif differ
diff --git a/src/asset/img/pdf-research.mp4 b/src/asset/img/pdf-research.mp4
new file mode 100644
index 00000000..e4ebbdee
Binary files /dev/null and b/src/asset/img/pdf-research.mp4 differ
diff --git a/src/asset/img/sdd.gif b/src/asset/img/sdd.gif
index fe7b3466..228a0091 100644
Binary files a/src/asset/img/sdd.gif and b/src/asset/img/sdd.gif differ
diff --git a/src/asset/img/sdd.mp4 b/src/asset/img/sdd.mp4
index bdf4f524..e522fa15 100644
Binary files a/src/asset/img/sdd.mp4 and b/src/asset/img/sdd.mp4 differ
diff --git a/src/asset/img/web.gif b/src/asset/img/web.gif
deleted file mode 100644
index 4719508a..00000000
Binary files a/src/asset/img/web.gif and /dev/null differ
diff --git a/src/asset/img/web.mp4 b/src/asset/img/web.mp4
deleted file mode 100644
index e5790d7b..00000000
Binary files a/src/asset/img/web.mp4 and /dev/null differ
diff --git a/src/asset/img/write.gif b/src/asset/img/write.gif
index ca782569..87ea1f71 100644
Binary files a/src/asset/img/write.gif and b/src/asset/img/write.gif differ
diff --git a/src/asset/img/write.mp4 b/src/asset/img/write.mp4
index 49fedb3f..14cdc825 100644
Binary files a/src/asset/img/write.mp4 and b/src/asset/img/write.mp4 differ
diff --git a/src/asset/img/writemode.png b/src/asset/img/writemode.png
deleted file mode 100644
index 5ea50c10..00000000
Binary files a/src/asset/img/writemode.png and /dev/null differ
diff --git a/src/main/app-icon.ts b/src/main/app-icon.ts
index 5dc881ed..89347df3 100644
--- a/src/main/app-icon.ts
+++ b/src/main/app-icon.ts
@@ -1,5 +1,5 @@
 import { readFileSync } from 'node:fs'
-import { dirname, isAbsolute, join } from 'node:path'
+import { dirname, isAbsolute, join, win32 } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { nativeImage } from 'electron'
 
@@ -28,7 +28,7 @@ export function resolveAppIconPath(source: string, baseDir: string = __dirname):
   // 前导斜杠剥掉,再判断 absoluteness。Windows 风格的真绝对路径(带盘符或 UNC)
   // 不以斜杠开头,原样透传。
   const normalized = source.replace(/^\/+/, '')
-  return isAbsolute(normalized) ? normalized : join(baseDir, normalized)
+  return isAbsolute(normalized) || win32.isAbsolute(normalized) ? normalized : join(baseDir, normalized)
 }
 
 /**
@@ -54,7 +54,7 @@ export function createAppIcon(source: string): Electron.NativeImage {
   } catch (error) {
     const message = error instanceof Error ? error.message : String(error)
     console.warn(
-      '[deepseek-gui] failed to load app icon from',
+      '[kun-gui] failed to load app icon from',
       absolute,
       '-',
       message
diff --git a/src/main/app-identity.test.ts b/src/main/app-identity.test.ts
index 8eaf590f..80b94875 100644
--- a/src/main/app-identity.test.ts
+++ b/src/main/app-identity.test.ts
@@ -22,7 +22,7 @@ describe('app identity bootstrap', () => {
     configureAppIdentity()
     expect(setName).toHaveBeenCalledTimes(1)
     expect(setName).toHaveBeenCalledWith(APP_PRODUCT_NAME)
-    expect(APP_PRODUCT_NAME).toBe('DeepSeek GUI')
+    expect(APP_PRODUCT_NAME).toBe('Kun')
   })
 
   it('does not call app.setAppUserModelId (caller responsibility on win32)', async () => {
diff --git a/src/main/app-identity.ts b/src/main/app-identity.ts
index 47f8fd2f..ce50d3e7 100644
--- a/src/main/app-identity.ts
+++ b/src/main/app-identity.ts
@@ -7,8 +7,15 @@ import { app } from 'electron'
  *   - tray 菜单和 tooltip
  * 保持一致。Windows 任务栏 / 系统托盘 / 通知中心看到的应用名都来自
  * 这条字符串(在打包产物里还会被写进 VERSIONINFO)。
+ *
+ * 2026-06 起品牌从 “DeepSeek GUI” 升级为 “Kun”。这个名字同时决定
+ * userData 默认目录(appData/Kun),老目录由 legacy-data-migration.ts
+ * 在启动最早期搬迁;历史名字列表见该模块的 LEGACY_USER_DATA_DIR_NAMES。
+ * 注意:electron-builder 的 appId(com.xingyuzhong.deepseekgui)刻意
+ * 不随品牌改名 —— macOS 自动更新的签名校验和 NSIS 升级卸载的注册表
+ * GUID 都锚定在 appId 上,改了老版本就无法平滑升级到新版本。
  */
-export const APP_PRODUCT_NAME = 'DeepSeek GUI'
+export const APP_PRODUCT_NAME = 'Kun'
 
 /**
  * 在 main 进程最早期调用,把 app 的对外名称设好。
diff --git a/src/main/ci-release-version.test.ts b/src/main/ci-release-version.test.ts
index 50cb702a..1d1def1d 100644
--- a/src/main/ci-release-version.test.ts
+++ b/src/main/ci-release-version.test.ts
@@ -33,7 +33,7 @@ describe('CI release version computation', () => {
     ).toEqual({
       version: '0.1.1',
       tag: 'v0.1.1',
-      releaseName: 'DeepSeek GUI 0.1.1',
+      releaseName: 'Kun 0.1.1',
       previousTag: '',
       existingTag: false
     })
diff --git a/src/main/claw-runtime-helpers.ts b/src/main/claw-runtime-helpers.ts
index 2f897057..7ce5f434 100644
--- a/src/main/claw-runtime-helpers.ts
+++ b/src/main/claw-runtime-helpers.ts
@@ -8,6 +8,7 @@ import type {
   ClawImProvider,
   ClawImRemoteSessionV1,
   ClawRunMode,
+  ScheduleReasoningEffort,
   ScheduleTaskFromTextResult
 } from '../shared/app-settings'
 import { CLAW_FEISHU_INBOUND_MESSAGE_HEADING } from '../shared/app-settings'
@@ -31,9 +32,18 @@ export type ClawRuntimeDeps = {
     to: string
     text: string
   }) => Promise<{ ok: true; messageId: string } | { ok: false; message: string }>
+  /** WeChat owner (`ilink_user_id`) for a bridge account; '' when unknown. */
+  resolveWeixinAccountUserId?: (accountId: string) => Promise
   createScheduledTaskFromText?: (
     text: string,
-    options?: { workspaceRoot?: string | null; modelHint?: string | null; mode?: ClawRunMode | null }
+    options?: {
+      workspaceRoot?: string | null
+      clawChannelId?: string | null
+      providerId?: string | null
+      modelHint?: string | null
+      reasoningEffort?: ScheduleReasoningEffort | null
+      mode?: ClawRunMode | null
+    }
   ) => Promise
 }
 
@@ -52,6 +62,7 @@ export type TurnRecordJson = {
 export type TurnItemJson = {
   kind: string
   turnId?: string
+  toolName?: string
   toolKind?: string
   output?: unknown
   isError?: boolean | null
@@ -78,6 +89,7 @@ export type RunPromptOptions = {
   waitForResult: boolean
   responseTimeoutMs: number
   source: 'task' | 'im'
+  providerId?: string
   threadId?: string
   channel?: ClawImChannelV1
   onTurnStarted?: (payload: { threadId: string; turnId: string }) => Promise | void
@@ -125,7 +137,7 @@ export function formatFeishuMirrorText(text: string, direction: 'user' | 'assist
   const trimmed = text.trim()
   if (direction === 'user') {
     return {
-      markdown: `**From DeepSeek GUI**\n\n> ${trimmed.replace(/\n/g, '\n> ')}`
+      markdown: `**From Kun**\n\n> ${trimmed.replace(/\n/g, '\n> ')}`
     }
   }
   return { markdown: trimmed || '(empty reply)' }
@@ -188,22 +200,46 @@ function outputRecord(output: unknown): Record | null {
     : null
 }
 
-function generatedFileFromToolResult(
-  item: TurnItemJson,
+function generatedFileFromRecord(
+  record: Record,
   workspaceRoot: string
 ): ClawGeneratedFileV1 | null {
-  if (item.kind !== 'tool_result' || item.toolKind !== 'file_change' || item.isError === true) return null
-  const output = outputRecord(item.output)
-  if (!output) return null
-  const path = asString(output.path) || asString(output.absolute_path)
-  const relativePath = asString(output.relative_path)
+  const path = asString(record.path) || asString(record.absolutePath) || asString(record.absolute_path)
+  const relativePath = asString(record.relativePath) || asString(record.relative_path)
   const resolvedPath = path || (workspaceRoot && relativePath ? join(workspaceRoot, relativePath) : '')
   if (!resolvedPath) return null
   return {
     path: resolvedPath,
     ...(relativePath ? { relativePath } : {}),
-    fileName: basename(relativePath || resolvedPath)
+    fileName: asString(record.fileName) || asString(record.name) || basename(relativePath || resolvedPath)
+  }
+}
+
+function generatedFilesFromToolResult(
+  item: TurnItemJson,
+  workspaceRoot: string
+): ClawGeneratedFileV1[] {
+  if (item.kind !== 'tool_result' || item.isError === true) return []
+  const output = outputRecord(item.output)
+  if (!output) return []
+  if (item.toolKind === 'file_change') {
+    const file = generatedFileFromRecord(output, workspaceRoot)
+    return file ? [file] : []
+  }
+  if (
+    (item.toolName === 'generate_image' ||
+      item.toolName === 'generate_speech' ||
+      item.toolName === 'generate_music' ||
+      item.toolName === 'generate_video') &&
+    Array.isArray(output.files)
+  ) {
+    return output.files
+      .map((entry) => outputRecord(entry))
+      .filter((entry): entry is Record => entry != null)
+      .map((entry) => generatedFileFromRecord(entry, workspaceRoot))
+      .filter((file): file is ClawGeneratedFileV1 => file != null)
   }
+  return []
 }
 
 function threadItems(detail: ThreadDetailJson): TurnItemJson[] {
@@ -237,10 +273,11 @@ function extractGeneratedFiles(
 ): ClawGeneratedFileV1[] {
   const files: ClawGeneratedFileV1[] = []
   for (let index = items.length - 1; index >= 0; index -= 1) {
-    const file = generatedFileFromToolResult(items[index], workspaceRoot)
-    if (!file) continue
-    if (files.some((existing) => isPathLikeDuplicate(existing, file))) continue
-    files.push(file)
+    for (const file of generatedFilesFromToolResult(items[index], workspaceRoot).reverse()) {
+      if (files.some((existing) => isPathLikeDuplicate(existing, file))) continue
+      files.push(file)
+      if (files.length >= maxFiles) break
+    }
     if (files.length >= maxFiles) break
   }
   return files.reverse()
@@ -255,12 +292,11 @@ export function latestGeneratedFiles(
   const items = threadItems(detail)
   const turnId = options.turnId?.trim()
   if (turnId) {
-    const currentTurnFiles = extractGeneratedFiles(
+    return extractGeneratedFiles(
       items.filter((item) => item.turnId === turnId),
       workspaceRoot,
       maxFiles
     )
-    if (currentTurnFiles.length > 0) return currentTurnFiles
   }
   return extractGeneratedFiles(items, workspaceRoot, maxFiles)
 }
@@ -270,7 +306,10 @@ export function shouldSendGeneratedFilesForPrompt(prompt: string): boolean {
   if (!text) return false
   return /发给我|发送给我|发一下|发来|发过来|传给我|传过来|上传|附件|以附件|发文件|文件发|文档发/i.test(text) ||
     /\b(send|attach|attachment|upload)\b/i.test(text) ||
-    /给我(?:一个|一份)?.{0,24}(文档|文件|\.(?:md|txt|pdf|docx|xlsx|csv|pptx))/i.test(text)
+    /给我(?:一个|一份)?.{0,24}(文档|文件|\.(?:md|txt|pdf|docx|xlsx|csv|pptx))/i.test(text) ||
+    /(生成|画|绘制|做|制作|创建|出).{0,24}(图|图片|图像|照片|海报|插画|表情包|logo)/i.test(text) ||
+    /(生成|做|制作|创建|配|出).{0,24}(语音|音频|朗读|旁白|配音|音乐|歌曲|视频|短片|影片)/i.test(text) ||
+    /\b(generate|create|draw|make)\b.{0,40}\b(image|picture|photo|poster|illustration|meme|logo|speech|voice|audio|music|song|video)\b/i.test(text)
 }
 
 export function shouldDirectSendExistingGeneratedFilesForPrompt(prompt: string): boolean {
diff --git a/src/main/claw-runtime.test.ts b/src/main/claw-runtime.test.ts
index 6f7aa51e..1a66541c 100644
--- a/src/main/claw-runtime.test.ts
+++ b/src/main/claw-runtime.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it, vi } from 'vitest'
-import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import {
@@ -11,7 +11,8 @@ import {
   defaultWriteSettings,
   type AppSettingsV1,
   type ClawImChannelV1,
-  type ClawImConversationV1
+  type ClawImConversationV1,
+  type ModelProviderProfileV1
 } from '../shared/app-settings'
 import { createClawRuntime } from './claw-runtime'
 
@@ -42,6 +43,7 @@ function buildSettings(): AppSettingsV1 {
           enabled: true,
           prompt: 'Summarize changes',
           workspaceRoot: '/tmp/workspace',
+          clawChannelId: '',
           model: 'auto',
           reasoningEffort: 'medium',
           mode: 'agent',
@@ -95,12 +97,28 @@ function buildChannel(overrides: Partial = {}): ClawImChannelV1
       replyRules: ''
     },
     conversations: [],
+    // Most tests model an already-greeted channel; welcome tests reset
+    // this to '' to exercise the first-contact intro.
+    welcomeSentAt: '2026-06-02T00:00:00.000Z',
     createdAt: '2026-06-02T00:00:00.000Z',
     updatedAt: '2026-06-02T00:00:00.000Z',
     ...overrides
   }
 }
 
+function buildModelProvider(overrides: Partial = {}): ModelProviderProfileV1 {
+  return {
+    id: 'minimax',
+    name: 'MiniMax',
+    apiKey: 'sk-minimax',
+    baseUrl: 'https://api.minimaxi.com/anthropic',
+    endpointFormat: 'messages',
+    models: ['MiniMax-M3', 'MiniMax-M2.7'],
+    modelProfiles: {},
+    ...overrides
+  }
+}
+
 function mutableSettingsStore(initialSettings: AppSettingsV1): {
   current: () => AppSettingsV1
   store: {
@@ -286,6 +304,8 @@ describe('ClawRuntime', () => {
     })
     expect(createScheduledTaskFromText).toHaveBeenCalledWith('Remind me tomorrow to ship the review.', {
       workspaceRoot: settings.workspaceRoot,
+      clawChannelId: null,
+      providerId: null,
       modelHint: settings.claw.im.model,
       mode: settings.claw.im.mode
     })
@@ -392,6 +412,21 @@ describe('ClawRuntime', () => {
     })
 
     expect(result).toMatchObject({ ok: true, text: 'hello from claw' })
+    const createThreadCall = runtimeRequest.mock.calls.find(
+      ([, path, init]) => path === '/v1/threads' && init?.method === 'POST'
+    )
+    expect(JSON.parse(String(createThreadCall?.[2]?.body ?? '{}'))).toMatchObject({
+      approvalPolicy: 'auto',
+      sandboxMode: 'danger-full-access'
+    })
+    const turnCall = runtimeRequest.mock.calls.find(
+      ([, path, init]) => path === '/v1/threads/thr_1/turns' && init?.method === 'POST'
+    )
+    expect(JSON.parse(String(turnCall?.[2]?.body ?? '{}'))).toMatchObject({
+      disableUserInput: true,
+      approvalPolicy: 'auto',
+      sandboxMode: 'danger-full-access'
+    })
   })
 
   it('reads assistant text from the Kun thread detail shape used by the real runtime', async () => {
@@ -716,6 +751,232 @@ describe('ClawRuntime', () => {
     )
   })
 
+  it('lists and switches IM model providers locally for the current channel', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.provider.providers = [
+      ...settings.provider.providers,
+      buildModelProvider()
+    ]
+    settings.claw.channels = [buildChannel()]
+    const { current, store } = mutableSettingsStore(settings)
+    const runtimeRequest = vi.fn()
+    const send = vi.fn(async () => ({ messageId: 'om_sent' }))
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: runtimeRequest as never,
+      logError: () => undefined
+    })
+    ;(runtime as unknown as { feishuChannels: Map })
+      .feishuChannels
+      .set('channel_1', { send })
+    const handleFeishuMessage = (content: string, messageId: string): Promise =>
+      (runtime as unknown as {
+        handleFeishuMessage: (channelId: string, message: {
+          chatId: string
+          messageId: string
+          senderId: string
+          senderName?: string
+          chatType: 'p2p' | 'group'
+          mentionedBot: boolean
+          mentionAll: boolean
+          content: string
+          rawContentType: string
+          mentions: unknown[]
+        }) => Promise
+      }).handleFeishuMessage('channel_1', {
+        chatId: 'oc_chat_a',
+        messageId,
+        senderId: 'ou_1',
+        senderName: 'Alice',
+        chatType: 'p2p',
+        mentionedBot: false,
+        mentionAll: false,
+        content,
+        rawContentType: 'text',
+        mentions: []
+      })
+
+    await handleFeishuMessage('/provider', 'om_provider_list')
+    expect(runtimeRequest).not.toHaveBeenCalled()
+    expect(send).toHaveBeenLastCalledWith(
+      'oc_chat_a',
+      { markdown: expect.stringContaining('Loaded providers:') },
+      { replyTo: 'om_provider_list', replyInThread: false }
+    )
+    const providerListCall = send.mock.calls[send.mock.calls.length - 1] as unknown as [
+      string,
+      { markdown?: string },
+      Record
+    ]
+    expect(providerListCall[1]).toMatchObject({ markdown: expect.stringContaining('`minimax`') })
+
+    await handleFeishuMessage('/provider minimax', 'om_provider_switch')
+    expect(current().claw.channels[0]).toMatchObject({
+      providerId: 'minimax',
+      model: 'MiniMax-M2.7'
+    })
+    expect(send).toHaveBeenLastCalledWith(
+      'oc_chat_a',
+      { markdown: 'IM provider switched to `minimax`; model is `MiniMax-M2.7`. Send `/model` to list models for this provider.' },
+      { replyTo: 'om_provider_switch', replyInThread: false }
+    )
+  })
+
+  it('lists and switches models only within the current IM provider', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.provider.providers = [
+      ...settings.provider.providers,
+      buildModelProvider()
+    ]
+    settings.claw.channels = [buildChannel({ providerId: 'minimax', model: 'MiniMax-M2.7' })]
+    const { current, store } = mutableSettingsStore(settings)
+    const runtimeRequest = vi.fn()
+    const send = vi.fn(async () => ({ messageId: 'om_sent' }))
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: runtimeRequest as never,
+      logError: () => undefined
+    })
+    ;(runtime as unknown as { feishuChannels: Map })
+      .feishuChannels
+      .set('channel_1', { send })
+    const handleFeishuMessage = (content: string, messageId: string): Promise =>
+      (runtime as unknown as {
+        handleFeishuMessage: (channelId: string, message: {
+          chatId: string
+          messageId: string
+          senderId: string
+          senderName?: string
+          chatType: 'p2p' | 'group'
+          mentionedBot: boolean
+          mentionAll: boolean
+          content: string
+          rawContentType: string
+          mentions: unknown[]
+        }) => Promise
+      }).handleFeishuMessage('channel_1', {
+        chatId: 'oc_chat_a',
+        messageId,
+        senderId: 'ou_1',
+        senderName: 'Alice',
+        chatType: 'p2p',
+        mentionedBot: false,
+        mentionAll: false,
+        content,
+        rawContentType: 'text',
+        mentions: []
+      })
+
+    await handleFeishuMessage('/model', 'om_model_list')
+    expect(send).toHaveBeenLastCalledWith(
+      'oc_chat_a',
+      { markdown: expect.stringContaining('Available models:') },
+      { replyTo: 'om_model_list', replyInThread: false }
+    )
+    const modelListCall = send.mock.calls[send.mock.calls.length - 1] as unknown as [
+      string,
+      { markdown?: string },
+      Record
+    ]
+    expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('`MiniMax-M3`') })
+    expect(modelListCall[1]).toMatchObject({ markdown: expect.not.stringContaining('deepseek-v4-flash') })
+
+    await handleFeishuMessage('/model MiniMax-M3', 'om_model_switch')
+    expect(current().claw.channels[0].model).toBe('MiniMax-M3')
+    expect(send).toHaveBeenLastCalledWith(
+      'oc_chat_a',
+      { markdown: 'Claw IM model switched to `MiniMax-M3`.' },
+      { replyTo: 'om_model_switch', replyInThread: false }
+    )
+  })
+
+  it('uses the current IM provider when starting an agent turn', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.claw.im.responseTimeoutMs = 2_000
+    settings.provider.providers = [
+      ...settings.provider.providers,
+      buildModelProvider()
+    ]
+    settings.claw.channels = [buildChannel({
+      providerId: 'minimax',
+      model: 'MiniMax-M3',
+      threadId: 'thr_minimax',
+      conversations: [buildConversation({ localThreadId: 'thr_minimax' })]
+    })]
+    const { store } = mutableSettingsStore(settings)
+    const runtimeRequest = vi.fn(async (requestSettings: AppSettingsV1, path, init) => {
+      expect(requestSettings.agents.kun.providerId).toBe('minimax')
+      expect(requestSettings.agents.kun.model).toBe('MiniMax-M3')
+      if (path === '/v1/threads/thr_minimax/turns' && init?.method === 'POST') {
+        const body = JSON.parse(init?.body ?? '{}') as { model?: string }
+        expect(body.model).toBe('MiniMax-M3')
+        return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_minimax', turnId: 'turn_minimax' }) }
+      }
+      if (path === '/v1/threads/thr_minimax' && init?.method === 'GET') {
+        return {
+          ok: true,
+          status: 200,
+          body: JSON.stringify({
+            id: 'thr_minimax',
+            status: 'idle',
+            turns: [
+              {
+                id: 'turn_minimax',
+                status: 'completed',
+                items: [{ kind: 'assistant_text', text: 'hello from minimax' }]
+              }
+            ]
+          })
+        }
+      }
+      throw new Error(`unexpected path ${path}`)
+    })
+    const send = vi.fn(async () => ({ messageId: 'om_sent' }))
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: runtimeRequest as never,
+      logError: () => undefined
+    })
+    ;(runtime as unknown as { feishuChannels: Map })
+      .feishuChannels
+      .set('channel_1', { send })
+
+    await (runtime as unknown as {
+      handleFeishuMessage: (channelId: string, message: {
+        chatId: string
+        messageId: string
+        senderId: string
+        senderName?: string
+        chatType: 'p2p' | 'group'
+        mentionedBot: boolean
+        mentionAll: boolean
+        content: string
+        rawContentType: string
+        mentions: unknown[]
+      }) => Promise
+    }).handleFeishuMessage('channel_1', {
+      chatId: 'oc_chat_a',
+      messageId: 'om_inbound',
+      senderId: 'ou_1',
+      senderName: 'Alice',
+      chatType: 'p2p',
+      mentionedBot: false,
+      mentionAll: false,
+      content: 'hello',
+      rawContentType: 'text',
+      mentions: []
+    })
+
+    expect(send).toHaveBeenCalledWith(
+      'oc_chat_a',
+      { markdown: 'hello from minimax' },
+      { replyTo: 'om_inbound', replyInThread: false }
+    )
+  })
+
   it('handles webhook /help as an IM command before starting a Kun turn', async () => {
     const settings = buildSettings()
     settings.claw.im.enabled = true
@@ -854,9 +1115,18 @@ describe('ClawRuntime', () => {
       senderName: 'Alice',
       localThreadId: 'thr_weixin'
     })
+    const turnCall = runtimeRequest.mock.calls.find(
+      ([, path, init]) => path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST'
+    )
+    expect(turnCall).toBeDefined()
+    expect(JSON.parse(String(turnCall?.[2]?.body ?? '{}'))).toMatchObject({
+      disableUserInput: true,
+      approvalPolicy: 'auto',
+      sandboxMode: 'danger-full-access'
+    })
   })
 
-  it('waits for the current WeChat turn to complete before returning the final reply', async () => {
+  it('backfills a WeChat conversation when an existing channel thread handles the webhook', async () => {
     const settings = buildSettings()
     settings.claw.im.enabled = true
     settings.claw.im.responseTimeoutMs = 2_500
@@ -864,66 +1134,29 @@ describe('ClawRuntime', () => {
       provider: 'weixin' as const,
       id: 'channel_weixin',
       label: 'WeChat',
-      threadId: '',
+      threadId: 'thr_weixin',
       conversations: []
     })]
-    const { store } = mutableSettingsStore(settings)
-    let getCount = 0
+    const { current, store } = mutableSettingsStore(settings)
     const runtimeRequest = vi.fn(async (_settings, path, init) => {
-      if (path === '/v1/threads' && init?.method === 'POST') {
-        return { ok: true, status: 201, body: JSON.stringify({ id: 'thr_weixin' }) }
-      }
-      if (path === '/v1/threads/thr_weixin' && init?.method === 'PATCH') {
-        return { ok: true, status: 200, body: '{}' }
-      }
       if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
         return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_weixin' }) }
       }
       if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
-        getCount += 1
         return {
           ok: true,
           status: 200,
-          body: JSON.stringify(getCount === 1
-            ? {
-                id: 'thr_weixin',
-                status: 'running',
-                turns: [
-                  {
-                    id: 'turn_previous',
-                    status: 'completed',
-                    items: [{ kind: 'assistant_text', text: 'previous reply' }]
-                  },
-                  {
-                    id: 'turn_weixin',
-                    status: 'running',
-                    items: [
-                      { kind: 'assistant_text', text: 'intermediate reply' },
-                      { kind: 'tool_call', detail: 'checking disk usage' }
-                    ]
-                  }
-                ]
+          body: JSON.stringify({
+            id: 'thr_weixin',
+            status: 'idle',
+            turns: [
+              {
+                id: 'turn_weixin',
+                status: 'completed',
+                items: [{ kind: 'assistant_text', text: 'hello from existing thread' }]
               }
-            : {
-                id: 'thr_weixin',
-                status: 'idle',
-                turns: [
-                  {
-                    id: 'turn_previous',
-                    status: 'completed',
-                    items: [{ kind: 'assistant_text', text: 'previous reply' }]
-                  },
-                  {
-                    id: 'turn_weixin',
-                    status: 'completed',
-                    items: [
-                      { kind: 'assistant_text', text: 'intermediate reply' },
-                      { kind: 'tool_result', detail: 'tool finished' },
-                      { kind: 'assistant_text', text: 'final result' }
-                    ]
-                  }
-                ]
-              })
+            ]
+          })
         }
       }
       throw new Error(`unexpected path ${path}`)
@@ -935,7 +1168,7 @@ describe('ClawRuntime', () => {
       createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
     })
     const body = JSON.stringify({
-      text: 'clean disk',
+      text: '你好',
       provider: 'weixin',
       channelId: 'channel_weixin',
       chatId: 'wx_user_1',
@@ -969,32 +1202,33 @@ describe('ClawRuntime', () => {
     expect(status).toBe(200)
     expect(JSON.parse(responseBody)).toMatchObject({
       ok: true,
-      reply: 'final result'
+      reply: 'hello from existing thread'
+    })
+    expect(current().claw.channels[0].threadId).toBe('thr_weixin')
+    expect(current().claw.channels[0].conversations[0]).toMatchObject({
+      chatId: 'wx_user_1',
+      latestMessageId: 'wx_msg_1',
+      senderId: 'wx_user_1',
+      senderName: 'Alice',
+      localThreadId: 'thr_weixin'
     })
-    expect(getCount).toBe(2)
   })
 
-  it('does not return a previous WeChat session reply for a new turn', async () => {
+  it('backfills a WeChat conversation from legacy webhook sender fields', async () => {
     const settings = buildSettings()
     settings.claw.im.enabled = true
-    settings.claw.im.responseTimeoutMs = 10
+    settings.claw.im.responseTimeoutMs = 2_500
     settings.claw.channels = [buildChannel({
       provider: 'weixin' as const,
       id: 'channel_weixin',
       label: 'WeChat',
       threadId: 'thr_weixin',
-      conversations: [buildConversation({
-        chatId: 'wx_user_1',
-        latestMessageId: 'wx_previous',
-        senderId: 'wx_user_1',
-        senderName: 'Alice',
-        localThreadId: 'thr_weixin'
-      })]
+      conversations: []
     })]
-    const { store } = mutableSettingsStore(settings)
+    const { current, store } = mutableSettingsStore(settings)
     const runtimeRequest = vi.fn(async (_settings, path, init) => {
       if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
-        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
+        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_weixin' }) }
       }
       if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
         return {
@@ -1005,14 +1239,9 @@ describe('ClawRuntime', () => {
             status: 'idle',
             turns: [
               {
-                id: 'turn_previous',
-                status: 'completed',
-                items: [{ kind: 'assistant_text', text: 'previous reply' }]
-              },
-              {
-                id: 'turn_current',
+                id: 'turn_weixin',
                 status: 'completed',
-                items: []
+                items: [{ kind: 'assistant_text', text: 'hello from legacy sender' }]
               }
             ]
           })
@@ -1027,13 +1256,10 @@ describe('ClawRuntime', () => {
       createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
     })
     const body = JSON.stringify({
-      text: 'new question',
+      text: '你好',
       provider: 'weixin',
       channelId: 'channel_weixin',
-      chatId: 'wx_user_1',
-      messageId: 'wx_msg_2',
-      senderId: 'wx_user_1',
-      senderName: 'Alice'
+      sender: 'wx_user_1'
     })
     const req = {
       method: 'POST',
@@ -1058,34 +1284,116 @@ describe('ClawRuntime', () => {
       handleWebhook: (request: typeof req, response: typeof res) => Promise
     }).handleWebhook(req, res)
 
-    expect(status).toBe(500)
+    expect(status).toBe(200)
     expect(JSON.parse(responseBody)).toMatchObject({
-      ok: false,
-      message: 'Timed out waiting for agent response.'
+      ok: true,
+      reply: 'hello from legacy sender'
+    })
+    expect(current().claw.channels[0].conversations[0]).toMatchObject({
+      chatId: 'wx_user_1',
+      senderId: 'wx_user_1',
+      senderName: 'wx_user_1',
+      localThreadId: 'thr_weixin'
     })
+    expect(current().claw.channels[0].conversations[0].latestMessageId).toMatch(/^wx_/)
   })
 
-  it('does not return historical WeChat text when the current turn fails', async () => {
+  it('sends the channel intro before handling the first Feishu message', async () => {
     const settings = buildSettings()
     settings.claw.im.enabled = true
-    settings.claw.im.responseTimeoutMs = 2_000
+    settings.claw.channels = [buildChannel({ welcomeSentAt: '' })]
+    const { current, store } = mutableSettingsStore(settings)
+    const send = vi.fn(async () => ({ messageId: 'om_sent' }))
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: vi.fn() as never,
+      logError: () => undefined
+    })
+    ;(runtime as unknown as { feishuChannels: Map })
+      .feishuChannels
+      .set('channel_1', { send })
+
+    await (runtime as unknown as {
+      handleFeishuMessage: (channelId: string, message: {
+        chatId: string
+        messageId: string
+        senderId: string
+        senderName?: string
+        chatType: 'p2p' | 'group'
+        mentionedBot: boolean
+        mentionAll: boolean
+        content: string
+        rawContentType: string
+        mentions: unknown[]
+      }) => Promise
+    }).handleFeishuMessage('channel_1', {
+      chatId: 'oc_chat_a',
+      messageId: 'om_inbound',
+      senderId: 'ou_1',
+      senderName: 'Alice',
+      chatType: 'p2p',
+      mentionedBot: false,
+      mentionAll: false,
+      content: '/help',
+      rawContentType: 'text',
+      mentions: []
+    })
+
+    expect(send).toHaveBeenCalledTimes(2)
+    const welcomeCall = send.mock.calls[0] as unknown as [string, { markdown?: string }, Record]
+    expect(welcomeCall[0]).toBe('oc_chat_a')
+    expect(welcomeCall[1].markdown).toContain('Kun')
+    expect(welcomeCall[1].markdown).toContain('`/new`')
+    expect(welcomeCall[1].markdown).toContain('`/model`')
+    expect(welcomeCall[2]).toEqual({})
+    expect(current().claw.channels[0].welcomeSentAt).toBeTruthy()
+
+    send.mockClear()
+    await (runtime as unknown as {
+      handleFeishuMessage: (channelId: string, message: Record) => Promise
+    }).handleFeishuMessage('channel_1', {
+      chatId: 'oc_chat_a',
+      messageId: 'om_inbound_2',
+      senderId: 'ou_1',
+      senderName: 'Alice',
+      chatType: 'p2p',
+      mentionedBot: false,
+      mentionAll: false,
+      content: '/help',
+      rawContentType: 'text',
+      mentions: []
+    })
+    expect(send).toHaveBeenCalledTimes(1)
+  })
+
+  it('pushes the WeChat intro as its own message on first contact and keeps the reply clean', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.claw.im.responseTimeoutMs = 2_500
     settings.claw.channels = [buildChannel({
       provider: 'weixin' as const,
       id: 'channel_weixin',
       label: 'WeChat',
-      threadId: 'thr_weixin',
-      conversations: [buildConversation({
-        chatId: 'wx_user_1',
-        latestMessageId: 'wx_previous',
-        senderId: 'wx_user_1',
-        senderName: 'Alice',
-        localThreadId: 'thr_weixin'
-      })]
+      threadId: '',
+      conversations: [],
+      welcomeSentAt: '',
+      platformCredential: {
+        kind: 'weixin',
+        accountId: 'acc_1',
+        sessionKey: 'sess_1',
+        createdAt: '2026-06-02T00:00:00.000Z'
+      }
     })]
-    const { store } = mutableSettingsStore(settings)
+    const { current, store } = mutableSettingsStore(settings)
     const runtimeRequest = vi.fn(async (_settings, path, init) => {
+      if (path === '/v1/threads' && init?.method === 'POST') {
+        return { ok: true, status: 201, body: JSON.stringify({ id: 'thr_weixin' }) }
+      }
+      if (path === '/v1/threads/thr_weixin' && init?.method === 'PATCH') {
+        return { ok: true, status: 200, body: '{}' }
+      }
       if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
-        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
+        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_weixin' }) }
       }
       if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
         return {
@@ -1096,14 +1404,9 @@ describe('ClawRuntime', () => {
             status: 'idle',
             turns: [
               {
-                id: 'turn_previous',
+                id: 'turn_weixin',
                 status: 'completed',
-                items: [{ kind: 'assistant_text', text: 'previous reply' }]
-              },
-              {
-                id: 'turn_current',
-                status: 'failed',
-                items: []
+                items: [{ kind: 'assistant_text', text: 'hello from GUI' }]
               }
             ]
           })
@@ -1111,18 +1414,20 @@ describe('ClawRuntime', () => {
       }
       throw new Error(`unexpected path ${path}`)
     })
+    const sendWeixinBridgeMessage = vi.fn(async () => ({ ok: true as const, messageId: 'wx_out_1' }))
     const runtime = createClawRuntime({
       store: store as never,
       runtimeRequest: runtimeRequest as never,
       logError: () => undefined,
+      sendWeixinBridgeMessage,
       createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
     })
     const body = JSON.stringify({
-      text: 'new question',
+      text: '你好',
       provider: 'weixin',
       channelId: 'channel_weixin',
       chatId: 'wx_user_1',
-      messageId: 'wx_msg_2',
+      messageId: 'wx_msg_1',
       senderId: 'wx_user_1',
       senderName: 'Alice'
     })
@@ -1149,134 +1454,1233 @@ describe('ClawRuntime', () => {
       handleWebhook: (request: typeof req, response: typeof res) => Promise
     }).handleWebhook(req, res)
 
-    expect(status).toBe(500)
-    expect(JSON.parse(responseBody)).toMatchObject({
-      ok: false,
-      message: 'Agent turn failed.'
+    expect(status).toBe(200)
+    expect(JSON.parse(responseBody)).toMatchObject({ ok: true, reply: 'hello from GUI' })
+    expect(sendWeixinBridgeMessage).toHaveBeenCalledTimes(1)
+    expect(sendWeixinBridgeMessage).toHaveBeenCalledWith({
+      accountId: 'acc_1',
+      to: 'wx_user_1',
+      text: expect.stringContaining('`/new`')
     })
+    expect(current().claw.channels[0].welcomeSentAt).toBeTruthy()
   })
 
-  it('mirrors local Claw thread messages back to the bundled WeChat bridge', async () => {
+  it('prepends the intro to the first webhook reply when no push channel exists', async () => {
     const settings = buildSettings()
     settings.claw.im.enabled = true
+    settings.claw.im.responseTimeoutMs = 2_500
     settings.claw.channels = [buildChannel({
       provider: 'weixin' as const,
       id: 'channel_weixin',
-      threadId: 'thr_weixin',
+      label: 'WeChat',
+      threadId: '',
+      conversations: [],
+      welcomeSentAt: ''
+    })]
+    const { current, store } = mutableSettingsStore(settings)
+    const runtimeRequest = vi.fn(async (_settings, path, init) => {
+      if (path === '/v1/threads' && init?.method === 'POST') {
+        return { ok: true, status: 201, body: JSON.stringify({ id: 'thr_weixin' }) }
+      }
+      if (path === '/v1/threads/thr_weixin' && init?.method === 'PATCH') {
+        return { ok: true, status: 200, body: '{}' }
+      }
+      if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
+        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_weixin' }) }
+      }
+      if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
+        return {
+          ok: true,
+          status: 200,
+          body: JSON.stringify({
+            id: 'thr_weixin',
+            status: 'idle',
+            turns: [
+              {
+                id: 'turn_weixin',
+                status: 'completed',
+                items: [{ kind: 'assistant_text', text: 'hello from GUI' }]
+              }
+            ]
+          })
+        }
+      }
+      throw new Error(`unexpected path ${path}`)
+    })
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: runtimeRequest as never,
+      logError: () => undefined,
+      createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+    })
+    const body = JSON.stringify({
+      text: '你好',
+      provider: 'weixin',
+      channelId: 'channel_weixin',
+      chatId: 'wx_user_1',
+      messageId: 'wx_msg_1',
+      senderId: 'wx_user_1',
+      senderName: 'Alice'
+    })
+    const req = {
+      method: 'POST',
+      url: settings.claw.im.path,
+      headers: {},
+      async *[Symbol.asyncIterator]() {
+        yield Buffer.from(body)
+      }
+    }
+    let responseBody = ''
+    const res = {
+      writeHead: vi.fn(),
+      end: vi.fn((payload: string) => {
+        responseBody = payload
+      })
+    }
+
+    await (runtime as unknown as {
+      handleWebhook: (request: typeof req, response: typeof res) => Promise
+    }).handleWebhook(req, res)
+
+    const reply = String(JSON.parse(responseBody).reply)
+    expect(reply).toContain('Kun')
+    expect(reply).toContain('`/new`')
+    expect(reply.endsWith('hello from GUI')).toBe(true)
+    expect(current().claw.channels[0].welcomeSentAt).toBeTruthy()
+  })
+
+  it('greets the WeChat owner right after the channel is first connected', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.claw.channels = [buildChannel({
+      provider: 'weixin' as const,
+      id: 'channel_weixin',
+      welcomeSentAt: '',
       platformCredential: {
         kind: 'weixin',
-        accountId: 'wx_account',
-        sessionKey: 'wx_session',
+        accountId: 'acc_1',
+        sessionKey: 'sess_1',
         createdAt: '2026-06-02T00:00:00.000Z'
-      },
-      conversations: [buildConversation({
-        chatId: 'wx_user_1',
-        localThreadId: 'thr_weixin'
-      })]
+      }
     })]
-    const sendWeixinBridgeMessage = vi.fn(async () => ({
-      ok: true as const,
-      messageId: 'wx_out_1'
-    }))
+    const { current, store } = mutableSettingsStore(settings)
+    const sendWeixinBridgeMessage = vi.fn(async () => ({ ok: true as const, messageId: 'wx_out_1' }))
+    const resolveWeixinAccountUserId = vi.fn(async () => 'owner_1')
     const runtime = createClawRuntime({
-      store: { load: vi.fn(async () => settings), patch: vi.fn(async () => settings) } as never,
+      store: store as never,
       runtimeRequest: vi.fn() as never,
       logError: () => undefined,
-      sendWeixinBridgeMessage
+      sendWeixinBridgeMessage,
+      resolveWeixinAccountUserId
     })
 
-    const result = await runtime.mirrorThreadMessageToIm('thr_weixin', 'hello from local', 'assistant')
+    const internals = runtime as unknown as {
+      syncWeixinConnectWelcomes: (settings: AppSettingsV1) => Promise
+    }
+    await internals.syncWeixinConnectWelcomes(settings)
 
-    expect(result).toEqual({ ok: true })
+    expect(resolveWeixinAccountUserId).toHaveBeenCalledWith('acc_1')
+    expect(sendWeixinBridgeMessage).toHaveBeenCalledTimes(1)
     expect(sendWeixinBridgeMessage).toHaveBeenCalledWith({
-      accountId: 'wx_account',
-      to: 'wx_user_1',
-      text: 'hello from local'
+      accountId: 'acc_1',
+      to: 'owner_1',
+      text: expect.stringContaining('`/help`')
     })
+    expect(current().claw.channels[0].welcomeSentAt).toBeTruthy()
+
+    await internals.syncWeixinConnectWelcomes(current())
+    expect(sendWeixinBridgeMessage).toHaveBeenCalledTimes(1)
   })
 
-  it('sends the latest generated workspace file to Feishu when the user asks for it', async () => {
-    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-file-'))
-    const filePath = join(workspaceRoot, 'hello.md')
-    await writeFile(filePath, '# Hello\n')
-    const realFilePath = await realpath(filePath)
-    try {
-      const settings = buildSettings()
-      settings.claw.im.enabled = true
-      settings.claw.im.responseTimeoutMs = 2_000
-      const conversation: ClawImConversationV1 = {
-        id: 'conv_1',
-        chatId: 'oc_chat_a',
-        remoteThreadId: '',
-        latestMessageId: 'om_previous',
-        senderId: 'ou_1',
-        senderName: 'Alice',
-        localThreadId: 'thr_1',
-        workspaceRoot,
-        createdAt: '2026-06-02T00:00:00.000Z',
-        updatedAt: '2026-06-02T00:00:00.000Z'
+  it('waits for the current WeChat turn to complete before returning the final reply', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.claw.im.responseTimeoutMs = 2_500
+    settings.claw.channels = [buildChannel({
+      provider: 'weixin' as const,
+      id: 'channel_weixin',
+      label: 'WeChat',
+      threadId: '',
+      conversations: []
+    })]
+    const { store } = mutableSettingsStore(settings)
+    let getCount = 0
+    const runtimeRequest = vi.fn(async (_settings, path, init) => {
+      if (path === '/v1/threads' && init?.method === 'POST') {
+        return { ok: true, status: 201, body: JSON.stringify({ id: 'thr_weixin' }) }
+      }
+      if (path === '/v1/threads/thr_weixin' && init?.method === 'PATCH') {
+        return { ok: true, status: 200, body: '{}' }
+      }
+      if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
+        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_weixin' }) }
+      }
+      if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
+        getCount += 1
+        return {
+          ok: true,
+          status: 200,
+          body: JSON.stringify(getCount === 1
+            ? {
+                id: 'thr_weixin',
+                status: 'running',
+                turns: [
+                  {
+                    id: 'turn_previous',
+                    status: 'completed',
+                    items: [{ kind: 'assistant_text', text: 'previous reply' }]
+                  },
+                  {
+                    id: 'turn_weixin',
+                    status: 'running',
+                    items: [
+                      { kind: 'assistant_text', text: 'intermediate reply' },
+                      { kind: 'tool_call', detail: 'checking disk usage' }
+                    ]
+                  }
+                ]
+              }
+            : {
+                id: 'thr_weixin',
+                status: 'idle',
+                turns: [
+                  {
+                    id: 'turn_previous',
+                    status: 'completed',
+                    items: [{ kind: 'assistant_text', text: 'previous reply' }]
+                  },
+                  {
+                    id: 'turn_weixin',
+                    status: 'completed',
+                    items: [
+                      { kind: 'assistant_text', text: 'intermediate reply' },
+                      { kind: 'tool_result', detail: 'tool finished' },
+                      { kind: 'assistant_text', text: 'final result' }
+                    ]
+                  }
+                ]
+              })
+        }
+      }
+      throw new Error(`unexpected path ${path}`)
+    })
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: runtimeRequest as never,
+      logError: () => undefined,
+      createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+    })
+    const body = JSON.stringify({
+      text: 'clean disk',
+      provider: 'weixin',
+      channelId: 'channel_weixin',
+      chatId: 'wx_user_1',
+      messageId: 'wx_msg_1',
+      senderId: 'wx_user_1',
+      senderName: 'Alice'
+    })
+    const req = {
+      method: 'POST',
+      url: settings.claw.im.path,
+      headers: {},
+      async *[Symbol.asyncIterator]() {
+        yield Buffer.from(body)
+      }
+    }
+    let status = 0
+    let responseBody = ''
+    const res = {
+      writeHead: vi.fn((nextStatus: number) => {
+        status = nextStatus
+      }),
+      end: vi.fn((payload: string) => {
+        responseBody = payload
+      })
+    }
+
+    await (runtime as unknown as {
+      handleWebhook: (request: typeof req, response: typeof res) => Promise
+    }).handleWebhook(req, res)
+
+    expect(status).toBe(200)
+    expect(JSON.parse(responseBody)).toMatchObject({
+      ok: true,
+      reply: 'final result'
+    })
+    expect(getCount).toBe(2)
+  })
+
+  it('does not return a previous WeChat session reply for a new turn', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.claw.im.responseTimeoutMs = 10
+    settings.claw.channels = [buildChannel({
+      provider: 'weixin' as const,
+      id: 'channel_weixin',
+      label: 'WeChat',
+      threadId: 'thr_weixin',
+      conversations: [buildConversation({
+        chatId: 'wx_user_1',
+        latestMessageId: 'wx_previous',
+        senderId: 'wx_user_1',
+        senderName: 'Alice',
+        localThreadId: 'thr_weixin'
+      })]
+    })]
+    const { store } = mutableSettingsStore(settings)
+    const runtimeRequest = vi.fn(async (_settings, path, init) => {
+      if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
+        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
+      }
+      if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
+        return {
+          ok: true,
+          status: 200,
+          body: JSON.stringify({
+            id: 'thr_weixin',
+            status: 'idle',
+            turns: [
+              {
+                id: 'turn_previous',
+                status: 'completed',
+                items: [{ kind: 'assistant_text', text: 'previous reply' }]
+              },
+              {
+                id: 'turn_current',
+                status: 'completed',
+                items: []
+              }
+            ]
+          })
+        }
+      }
+      throw new Error(`unexpected path ${path}`)
+    })
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: runtimeRequest as never,
+      logError: () => undefined,
+      createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+    })
+    const body = JSON.stringify({
+      text: 'new question',
+      provider: 'weixin',
+      channelId: 'channel_weixin',
+      chatId: 'wx_user_1',
+      messageId: 'wx_msg_2',
+      senderId: 'wx_user_1',
+      senderName: 'Alice'
+    })
+    const req = {
+      method: 'POST',
+      url: settings.claw.im.path,
+      headers: {},
+      async *[Symbol.asyncIterator]() {
+        yield Buffer.from(body)
+      }
+    }
+    let status = 0
+    let responseBody = ''
+    const res = {
+      writeHead: vi.fn((nextStatus: number) => {
+        status = nextStatus
+      }),
+      end: vi.fn((payload: string) => {
+        responseBody = payload
+      })
+    }
+
+    await (runtime as unknown as {
+      handleWebhook: (request: typeof req, response: typeof res) => Promise
+    }).handleWebhook(req, res)
+
+    expect(status).toBe(500)
+    expect(JSON.parse(responseBody)).toMatchObject({
+      ok: false,
+      message: 'Timed out waiting for agent response.'
+    })
+  })
+
+  it('does not return historical WeChat text when the current turn fails', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.claw.im.responseTimeoutMs = 2_000
+    settings.claw.channels = [buildChannel({
+      provider: 'weixin' as const,
+      id: 'channel_weixin',
+      label: 'WeChat',
+      threadId: 'thr_weixin',
+      conversations: [buildConversation({
+        chatId: 'wx_user_1',
+        latestMessageId: 'wx_previous',
+        senderId: 'wx_user_1',
+        senderName: 'Alice',
+        localThreadId: 'thr_weixin'
+      })]
+    })]
+    const { store } = mutableSettingsStore(settings)
+    const runtimeRequest = vi.fn(async (_settings, path, init) => {
+      if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
+        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
+      }
+      if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
+        return {
+          ok: true,
+          status: 200,
+          body: JSON.stringify({
+            id: 'thr_weixin',
+            status: 'idle',
+            turns: [
+              {
+                id: 'turn_previous',
+                status: 'completed',
+                items: [{ kind: 'assistant_text', text: 'previous reply' }]
+              },
+              {
+                id: 'turn_current',
+                status: 'failed',
+                items: []
+              }
+            ]
+          })
+        }
+      }
+      throw new Error(`unexpected path ${path}`)
+    })
+    const runtime = createClawRuntime({
+      store: store as never,
+      runtimeRequest: runtimeRequest as never,
+      logError: () => undefined,
+      createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+    })
+    const body = JSON.stringify({
+      text: 'new question',
+      provider: 'weixin',
+      channelId: 'channel_weixin',
+      chatId: 'wx_user_1',
+      messageId: 'wx_msg_2',
+      senderId: 'wx_user_1',
+      senderName: 'Alice'
+    })
+    const req = {
+      method: 'POST',
+      url: settings.claw.im.path,
+      headers: {},
+      async *[Symbol.asyncIterator]() {
+        yield Buffer.from(body)
+      }
+    }
+    let status = 0
+    let responseBody = ''
+    const res = {
+      writeHead: vi.fn((nextStatus: number) => {
+        status = nextStatus
+      }),
+      end: vi.fn((payload: string) => {
+        responseBody = payload
+      })
+    }
+
+    await (runtime as unknown as {
+      handleWebhook: (request: typeof req, response: typeof res) => Promise
+    }).handleWebhook(req, res)
+
+    expect(status).toBe(500)
+    expect(JSON.parse(responseBody)).toMatchObject({
+      ok: false,
+      message: 'Agent turn failed.'
+    })
+  })
+
+  it('mirrors local Claw thread messages back to the bundled WeChat bridge', async () => {
+    const settings = buildSettings()
+    settings.claw.im.enabled = true
+    settings.claw.channels = [buildChannel({
+      provider: 'weixin' as const,
+      id: 'channel_weixin',
+      threadId: 'thr_weixin',
+      platformCredential: {
+        kind: 'weixin',
+        accountId: 'wx_account',
+        sessionKey: 'wx_session',
+        createdAt: '2026-06-02T00:00:00.000Z'
+      },
+      conversations: [buildConversation({
+        chatId: 'wx_user_1',
+        localThreadId: 'thr_weixin'
+      })]
+    })]
+    const sendWeixinBridgeMessage = vi.fn(async () => ({
+      ok: true as const,
+      messageId: 'wx_out_1'
+    }))
+    const runtime = createClawRuntime({
+      store: { load: vi.fn(async () => settings), patch: vi.fn(async () => settings) } as never,
+      runtimeRequest: vi.fn() as never,
+      logError: () => undefined,
+      sendWeixinBridgeMessage
+    })
+
+    const result = await runtime.mirrorThreadMessageToIm('thr_weixin', 'hello from local', 'assistant')
+
+    expect(result).toEqual({ ok: true })
+    expect(sendWeixinBridgeMessage).toHaveBeenCalledWith({
+      accountId: 'wx_account',
+      to: 'wx_user_1',
+      text: 'hello from local'
+    })
+  })
+
+  it('sends the latest generated workspace file to Feishu when the user asks for it', async () => {
+    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-file-'))
+    const filePath = join(workspaceRoot, 'hello.md')
+    await writeFile(filePath, '# Hello\n')
+    const realFilePath = await realpath(filePath)
+    try {
+      const settings = buildSettings()
+      settings.claw.im.enabled = true
+      settings.claw.im.responseTimeoutMs = 2_000
+      const conversation: ClawImConversationV1 = {
+        id: 'conv_1',
+        chatId: 'oc_chat_a',
+        remoteThreadId: '',
+        latestMessageId: 'om_previous',
+        senderId: 'ou_1',
+        senderName: 'Alice',
+        localThreadId: 'thr_1',
+        workspaceRoot,
+        createdAt: '2026-06-02T00:00:00.000Z',
+        updatedAt: '2026-06-02T00:00:00.000Z'
       }
       const channel: ClawImChannelV1 = {
         id: 'channel_1',
         provider: 'feishu' as const,
         label: 'Phone',
         enabled: true,
-        model: 'auto',
-        threadId: '',
-        workspaceRoot,
-        agentProfile: {
-          name: 'kun',
-          description: '',
-          identity: '',
-          personality: '',
-          userContext: '',
-          replyRules: ''
-        },
-        conversations: [conversation],
-        createdAt: '2026-06-02T00:00:00.000Z',
-        updatedAt: '2026-06-02T00:00:00.000Z'
+        model: 'auto',
+        threadId: '',
+        workspaceRoot,
+        agentProfile: {
+          name: 'kun',
+          description: '',
+          identity: '',
+          personality: '',
+          userContext: '',
+          replyRules: ''
+        },
+        conversations: [conversation],
+        welcomeSentAt: '2026-06-02T00:00:00.000Z',
+        createdAt: '2026-06-02T00:00:00.000Z',
+        updatedAt: '2026-06-02T00:00:00.000Z'
+      }
+      settings.claw.channels = [channel]
+      const store = {
+        load: vi.fn(async () => settings),
+        patch: vi.fn(async () => settings)
+      }
+      const runtimeRequest = vi.fn(async (_settings, path, init) => {
+        if (path === '/v1/threads/thr_1/turns') {
+          return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_2' }) }
+        }
+        if (path === '/v1/threads/thr_1' && init?.method === 'GET') {
+          return {
+            ok: true,
+            status: 200,
+            body: JSON.stringify({
+              id: 'thr_1',
+              status: 'idle',
+              turns: [
+                {
+                  id: 'turn_1',
+                  status: 'completed',
+                  items: [
+                    {
+                      kind: 'tool_result',
+                      toolKind: 'file_change',
+                      output: {
+                        path: filePath,
+                        relative_path: 'hello.md',
+                        bytes_written: 8
+                      },
+                      isError: false
+                    }
+                  ]
+                },
+                {
+                  id: 'turn_2',
+                  status: 'completed',
+                  items: [
+                    {
+                      kind: 'assistant_text',
+                      text: '我无法直接通过飞书发送文件给你,但文件已经创建在 workspace 中。'
+                    }
+                  ]
+                }
+              ]
+            })
+          }
+        }
+        throw new Error(`unexpected path ${path}`)
+      })
+      const send = vi.fn(async () => ({ messageId: 'om_sent' }))
+      const addReaction = vi.fn(async () => 'rc_file_1')
+      const runtime = createClawRuntime({
+        store: store as never,
+        runtimeRequest,
+        logError: () => undefined
+      })
+      ;(runtime as unknown as { feishuChannels: Map })
+        .feishuChannels
+        .set('channel_1', { send, addReaction })
+
+      await (runtime as unknown as {
+        handleFeishuMessage: (channelId: string, message: {
+          chatId: string
+          messageId: string
+          threadId?: string
+          senderId: string
+          senderName?: string
+          chatType: 'p2p' | 'group'
+          mentionedBot: boolean
+          mentionAll: boolean
+          content: string
+          rawContentType: string
+          mentions: unknown[]
+        }) => Promise
+      }).handleFeishuMessage('channel_1', {
+        chatId: 'oc_chat_a',
+        messageId: 'om_inbound',
+        senderId: 'ou_1',
+        senderName: 'Alice',
+        chatType: 'p2p',
+        mentionedBot: false,
+        mentionAll: false,
+        content: '发给我',
+        rawContentType: 'text',
+        mentions: []
+      })
+
+      expect(send).toHaveBeenNthCalledWith(
+        1,
+        'oc_chat_a',
+        { markdown: '可以,我把 hello.md 作为附件发给你。' },
+        { replyTo: 'om_inbound', replyInThread: false }
+      )
+      expect(send).toHaveBeenNthCalledWith(
+        2,
+        'oc_chat_a',
+        { file: { source: realFilePath, fileName: 'hello.md' } },
+        { replyTo: 'om_inbound', replyInThread: false }
+      )
+      // The direct-file path is fast (synchronous file lookup + upload) and
+      // The direct-file path is fast (synchronous file lookup + upload) and
+      // must NOT add a pending reaction — that would be visually noisy.
+      const addReactionSpy = (runtime as unknown as { feishuChannels: Map }> })
+        .feishuChannels.get('channel_1')?.addReaction
+      expect(addReactionSpy).not.toHaveBeenCalled()
+    } finally {
+      await rm(workspaceRoot, { recursive: true, force: true })
+    }
+  })
+
+  it('sends generated image tool output to Feishu for image requests', async () => {
+    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-image-'))
+    const imageDir = join(workspaceRoot, '.deepseekgui-images')
+    const imagePath = join(imageDir, 'img-20260611000100-abcd.png')
+    await mkdir(imageDir, { recursive: true })
+    await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
+    const realImagePath = await realpath(imagePath)
+    try {
+      const settings = buildSettings()
+      settings.claw.im.enabled = true
+      settings.claw.im.responseTimeoutMs = 2_000
+      settings.agents.kun.imageGeneration = {
+        enabled: true,
+        providerId: '',
+        protocol: 'openai-images',
+        baseUrl: 'https://images.example.test/v1',
+        apiKey: 'sk-image',
+        model: 'test-image-model',
+        defaultSize: '1024x1024',
+        timeoutMs: 180000
+      }
+      settings.claw.channels = [
+        buildChannel({
+          threadId: 'thr_1',
+          workspaceRoot,
+          conversations: [buildConversation({ localThreadId: 'thr_1', workspaceRoot })]
+        })
+      ]
+      const store = {
+        load: vi.fn(async () => settings),
+        patch: vi.fn(async () => settings)
+      }
+      const runtimeRequest = vi.fn(async (_settings, path, init) => {
+        if (path === '/v1/threads/thr_1/turns') {
+          const body = JSON.parse(init?.body ?? '{}') as { prompt?: string }
+          expect(body.prompt).toContain('generate_image')
+          return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_img' }) }
+        }
+        if (path === '/v1/threads/thr_1' && init?.method === 'GET') {
+          return {
+            ok: true,
+            status: 200,
+            body: JSON.stringify({
+              id: 'thr_1',
+              status: 'idle',
+              turns: [
+                {
+                  id: 'turn_img',
+                  status: 'completed',
+                  items: [
+                    {
+                      kind: 'tool_result',
+                      toolName: 'generate_image',
+                      toolKind: 'tool_call',
+                      output: {
+                        files: [{
+                          absolutePath: imagePath,
+                          relativePath: '.deepseekgui-images/img-20260611000100-abcd.png',
+                          mimeType: 'image/png'
+                        }],
+                        endpoint: 'generations'
+                      },
+                      isError: false
+                    },
+                    {
+                      kind: 'assistant_text',
+                      text: '图片已生成。'
+                    }
+                  ]
+                }
+              ]
+            })
+          }
+        }
+        throw new Error(`unexpected path ${path}`)
+      })
+      const send = vi.fn(async () => ({ messageId: 'om_sent' }))
+      const addReaction = vi.fn(async () => 'rc_image_1')
+      const runtime = createClawRuntime({
+        store: store as never,
+        runtimeRequest,
+        logError: () => undefined
+      })
+      ;(runtime as unknown as { feishuChannels: Map })
+        .feishuChannels
+        .set('channel_1', { send, addReaction })
+
+      await (runtime as unknown as {
+        handleFeishuMessage: (channelId: string, message: {
+          chatId: string
+          messageId: string
+          threadId?: string
+          senderId: string
+          senderName?: string
+          chatType: 'p2p' | 'group'
+          mentionedBot: boolean
+          mentionAll: boolean
+          content: string
+          rawContentType: string
+          mentions: unknown[]
+        }) => Promise
+      }).handleFeishuMessage('channel_1', {
+        chatId: 'oc_chat_a',
+        messageId: 'om_inbound',
+        senderId: 'ou_1',
+        senderName: 'Alice',
+        chatType: 'p2p',
+        mentionedBot: false,
+        mentionAll: false,
+        content: '帮我生成一张图片',
+        rawContentType: 'text',
+        mentions: []
+      })
+
+      expect(addReaction).toHaveBeenCalledWith('om_inbound', 'OnIt')
+      expect(send).toHaveBeenNthCalledWith(
+        1,
+        'oc_chat_a',
+        { markdown: '图片已生成。' },
+        { replyTo: 'om_inbound', replyInThread: false }
+      )
+      expect(send).toHaveBeenNthCalledWith(
+        2,
+        'oc_chat_a',
+        { file: { source: realImagePath, fileName: 'img-20260611000100-abcd.png' } },
+        { replyTo: 'om_inbound', replyInThread: false }
+      )
+    } finally {
+      await rm(workspaceRoot, { recursive: true, force: true })
+    }
+  })
+
+  it('returns generated files in the WeChat webhook reply for image requests', async () => {
+    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-image-'))
+    const imageDir = join(workspaceRoot, '.deepseekgui-images')
+    const imagePath = join(imageDir, 'img-20260611000200-beef.png')
+    await mkdir(imageDir, { recursive: true })
+    await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
+    const realImagePath = await realpath(imagePath)
+    try {
+      const settings = buildSettings()
+      settings.claw.im.enabled = true
+      settings.claw.im.responseTimeoutMs = 2_000
+      settings.agents.kun.imageGeneration = {
+        enabled: true,
+        providerId: '',
+        protocol: 'openai-images',
+        baseUrl: 'https://images.example.test/v1',
+        apiKey: 'sk-image',
+        model: 'test-image-model',
+        defaultSize: '1024x1024',
+        timeoutMs: 180000
+      }
+      settings.claw.channels = [
+        buildChannel({
+          provider: 'weixin' as const,
+          id: 'channel_weixin',
+          label: 'WeChat',
+          threadId: 'thr_wx',
+          conversations: [
+            buildConversation({
+              chatId: 'wx_user_1',
+              senderId: 'wx_user_1',
+              localThreadId: 'thr_wx',
+              workspaceRoot
+            })
+          ]
+        })
+      ]
+      const { store } = mutableSettingsStore(settings)
+      const runtimeRequest = vi.fn(async (_settings, path, init) => {
+        if (path === '/v1/threads/thr_wx/turns' && init?.method === 'POST') {
+          const body = JSON.parse(init?.body ?? '{}') as { prompt?: string }
+          expect(body.prompt).toContain('generate_image')
+          return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_img' }) }
+        }
+        if (path === '/v1/threads/thr_wx' && init?.method === 'GET') {
+          return {
+            ok: true,
+            status: 200,
+            body: JSON.stringify({
+              id: 'thr_wx',
+              status: 'idle',
+              turns: [
+                {
+                  id: 'turn_wx_img',
+                  status: 'completed',
+                  items: [
+                    {
+                      kind: 'tool_result',
+                      toolName: 'generate_image',
+                      toolKind: 'tool_call',
+                      output: {
+                        files: [{
+                          absolutePath: imagePath,
+                          relativePath: '.deepseekgui-images/img-20260611000200-beef.png',
+                          mimeType: 'image/png'
+                        }],
+                        endpoint: 'generations'
+                      },
+                      isError: false
+                    },
+                    { kind: 'assistant_text', text: '图片已生成。' }
+                  ]
+                }
+              ]
+            })
+          }
+        }
+        throw new Error(`unexpected path ${path}`)
+      })
+      const runtime = createClawRuntime({
+        store: store as never,
+        runtimeRequest: runtimeRequest as never,
+        logError: () => undefined,
+        createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+      })
+      const body = JSON.stringify({
+        text: '帮我画一张猫的图片',
+        provider: 'weixin',
+        channelId: 'channel_weixin',
+        chatId: 'wx_user_1',
+        messageId: 'wx_msg_img',
+        senderId: 'wx_user_1',
+        senderName: 'Alice'
+      })
+      const req = {
+        method: 'POST',
+        url: settings.claw.im.path,
+        headers: {},
+        async *[Symbol.asyncIterator]() {
+          yield Buffer.from(body)
+        }
+      }
+      let status = 0
+      let responseBody = ''
+      const res = {
+        writeHead: vi.fn((nextStatus: number) => {
+          status = nextStatus
+        }),
+        end: vi.fn((payload: string) => {
+          responseBody = payload
+        })
+      }
+
+      await (runtime as unknown as {
+        handleWebhook: (request: typeof req, response: typeof res) => Promise
+      }).handleWebhook(req, res)
+
+      expect(status).toBe(200)
+      const parsed = JSON.parse(responseBody)
+      expect(parsed).toMatchObject({ ok: true, reply: '图片已生成。' })
+      expect(parsed.files).toEqual([
+        {
+          path: realImagePath,
+          relativePath: '.deepseekgui-images/img-20260611000200-beef.png',
+          fileName: 'img-20260611000200-beef.png'
+        }
+      ])
+    } finally {
+      await rm(workspaceRoot, { recursive: true, force: true })
+    }
+  })
+
+  it('returns current-turn generated music files in the WeChat webhook reply for follow-up prompts', async () => {
+    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-music-'))
+    const mediaDir = join(workspaceRoot, '.deepseekgui-media')
+    const musicPath = join(mediaDir, 'music-20260612054704-78a2.mp3')
+    await mkdir(mediaDir, { recursive: true })
+    await writeFile(musicPath, Buffer.from([0x49, 0x44, 0x33, 0x03]))
+    const realMusicPath = await realpath(musicPath)
+    try {
+      const settings = buildSettings()
+      settings.claw.im.enabled = true
+      settings.claw.im.responseTimeoutMs = 2_000
+      settings.agents.kun.musicGeneration = {
+        enabled: true,
+        providerId: '',
+        protocol: 'minimax-music',
+        baseUrl: 'https://api.minimax.io',
+        apiKey: 'sk-music',
+        model: 'music-2.6',
+        format: 'mp3',
+        timeoutMs: 300000
       }
-      settings.claw.channels = [channel]
-      const store = {
-        load: vi.fn(async () => settings),
-        patch: vi.fn(async () => settings)
+      settings.claw.channels = [
+        buildChannel({
+          provider: 'weixin' as const,
+          id: 'channel_weixin',
+          label: 'WeChat',
+          threadId: 'thr_wx_music',
+          conversations: [
+            buildConversation({
+              chatId: 'wx_user_1',
+              senderId: 'wx_user_1',
+              localThreadId: 'thr_wx_music',
+              workspaceRoot
+            })
+          ]
+        })
+      ]
+      const { store } = mutableSettingsStore(settings)
+      const runtimeRequest = vi.fn(async (_settings, path, init) => {
+        if (path === '/v1/threads/thr_wx_music/turns' && init?.method === 'POST') {
+          const body = JSON.parse(init?.body ?? '{}') as { prompt?: string }
+          expect(body.prompt).toContain('欢快的人声')
+          expect(body.prompt).toContain('generate_music')
+          return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_music' }) }
+        }
+        if (path === '/v1/threads/thr_wx_music' && init?.method === 'GET') {
+          return {
+            ok: true,
+            status: 200,
+            body: JSON.stringify({
+              id: 'thr_wx_music',
+              status: 'idle',
+              turns: [
+                {
+                  id: 'turn_wx_music',
+                  status: 'completed',
+                  items: [
+                    {
+                      kind: 'tool_result',
+                      toolName: 'generate_music',
+                      toolKind: 'tool_call',
+                      output: {
+                        files: [{
+                          absolutePath: musicPath,
+                          relativePath: '.deepseekgui-media/music-20260612054704-78a2.mp3',
+                          mimeType: 'audio/mpeg'
+                        }]
+                      },
+                      isError: false
+                    },
+                    { kind: 'assistant_text', text: '欢快的人声歌曲已生成~' }
+                  ]
+                }
+              ]
+            })
+          }
+        }
+        throw new Error(`unexpected path ${path}`)
+      })
+      const runtime = createClawRuntime({
+        store: store as never,
+        runtimeRequest: runtimeRequest as never,
+        logError: () => undefined,
+        createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+      })
+      const body = JSON.stringify({
+        text: '欢快的人声',
+        provider: 'weixin',
+        channelId: 'channel_weixin',
+        chatId: 'wx_user_1',
+        messageId: 'wx_msg_music',
+        senderId: 'wx_user_1',
+        senderName: 'Alice'
+      })
+      const req = {
+        method: 'POST',
+        url: settings.claw.im.path,
+        headers: {},
+        async *[Symbol.asyncIterator]() {
+          yield Buffer.from(body)
+        }
+      }
+      let status = 0
+      let responseBody = ''
+      const res = {
+        writeHead: vi.fn((nextStatus: number) => {
+          status = nextStatus
+        }),
+        end: vi.fn((payload: string) => {
+          responseBody = payload
+        })
+      }
+
+      await (runtime as unknown as {
+        handleWebhook: (request: typeof req, response: typeof res) => Promise
+      }).handleWebhook(req, res)
+
+      expect(status).toBe(200)
+      const parsed = JSON.parse(responseBody)
+      expect(parsed).toMatchObject({ ok: true, reply: '欢快的人声歌曲已生成~' })
+      expect(parsed.files).toEqual([
+        {
+          path: realMusicPath,
+          relativePath: '.deepseekgui-media/music-20260612054704-78a2.mp3',
+          fileName: 'music-20260612054704-78a2.mp3'
+        }
+      ])
+    } finally {
+      await rm(workspaceRoot, { recursive: true, force: true })
+    }
+  })
+
+  it('does not return files from previous turns when the current IM turn produces none', async () => {
+    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-stale-files-'))
+    const imageDir = join(workspaceRoot, '.deepseekgui-images')
+    const imagePath = join(imageDir, 'img-20260611000300-cafe.png')
+    await mkdir(imageDir, { recursive: true })
+    await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
+    try {
+      const settings = buildSettings()
+      settings.claw.im.enabled = true
+      settings.claw.im.responseTimeoutMs = 2_000
+      settings.agents.kun.imageGeneration = {
+        enabled: true,
+        providerId: '',
+        protocol: 'openai-images',
+        baseUrl: 'https://images.example.test/v1',
+        apiKey: 'sk-image',
+        model: 'test-image-model',
+        defaultSize: '1024x1024',
+        timeoutMs: 180000
       }
+      settings.claw.channels = [
+        buildChannel({
+          provider: 'weixin' as const,
+          id: 'channel_weixin',
+          label: 'WeChat',
+          threadId: 'thr_wx_stale',
+          conversations: [
+            buildConversation({
+              chatId: 'wx_user_1',
+              senderId: 'wx_user_1',
+              localThreadId: 'thr_wx_stale',
+              workspaceRoot
+            })
+          ]
+        })
+      ]
+      const { store } = mutableSettingsStore(settings)
       const runtimeRequest = vi.fn(async (_settings, path, init) => {
-        if (path === '/v1/threads/thr_1/turns') {
-          return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_2' }) }
+        if (path === '/v1/threads/thr_wx_stale/turns' && init?.method === 'POST') {
+          const body = JSON.parse(init?.body ?? '{}') as { prompt?: string }
+          expect(body.prompt).toContain('generate_image')
+          return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
         }
-        if (path === '/v1/threads/thr_1' && init?.method === 'GET') {
+        if (path === '/v1/threads/thr_wx_stale' && init?.method === 'GET') {
           return {
             ok: true,
             status: 200,
             body: JSON.stringify({
-              id: 'thr_1',
+              id: 'thr_wx_stale',
               status: 'idle',
               turns: [
                 {
-                  id: 'turn_1',
+                  id: 'turn_previous',
                   status: 'completed',
                   items: [
                     {
                       kind: 'tool_result',
-                      toolKind: 'file_change',
+                      toolName: 'generate_image',
+                      toolKind: 'tool_call',
                       output: {
-                        path: filePath,
-                        relative_path: 'hello.md',
-                        bytes_written: 8
+                        files: [{
+                          absolutePath: imagePath,
+                          relativePath: '.deepseekgui-images/img-20260611000300-cafe.png',
+                          mimeType: 'image/png'
+                        }]
                       },
                       isError: false
-                    }
+                    },
+                    { kind: 'assistant_text', text: '上一张图片。' }
                   ]
                 },
                 {
-                  id: 'turn_2',
+                  id: 'turn_current',
+                  status: 'completed',
+                  items: [
+                    { kind: 'assistant_text', text: '这次没有生成新文件。' }
+                  ]
+                }
+              ]
+            })
+          }
+        }
+        throw new Error(`unexpected path ${path}`)
+      })
+      const runtime = createClawRuntime({
+        store: store as never,
+        runtimeRequest: runtimeRequest as never,
+        logError: () => undefined,
+        createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+      })
+      const body = JSON.stringify({
+        text: '帮我生成一张图片',
+        provider: 'weixin',
+        channelId: 'channel_weixin',
+        chatId: 'wx_user_1',
+        messageId: 'wx_msg_stale',
+        senderId: 'wx_user_1',
+        senderName: 'Alice'
+      })
+      const req = {
+        method: 'POST',
+        url: settings.claw.im.path,
+        headers: {},
+        async *[Symbol.asyncIterator]() {
+          yield Buffer.from(body)
+        }
+      }
+      let status = 0
+      let responseBody = ''
+      const res = {
+        writeHead: vi.fn((nextStatus: number) => {
+          status = nextStatus
+        }),
+        end: vi.fn((payload: string) => {
+          responseBody = payload
+        })
+      }
+
+      await (runtime as unknown as {
+        handleWebhook: (request: typeof req, response: typeof res) => Promise
+      }).handleWebhook(req, res)
+
+      expect(status).toBe(200)
+      const parsed = JSON.parse(responseBody)
+      expect(parsed).toMatchObject({ ok: true, reply: '这次没有生成新文件。' })
+      expect(parsed.files).toEqual([])
+    } finally {
+      await rm(workspaceRoot, { recursive: true, force: true })
+    }
+  })
+
+  it('returns generated speech files in the WeChat webhook reply for voice requests', async () => {
+    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-speech-'))
+    const speechDir = join(workspaceRoot, '.deepseekgui-media')
+    const speechPath = join(speechDir, 'speech-20260612000100-feed.mp3')
+    await mkdir(speechDir, { recursive: true })
+    await writeFile(speechPath, Buffer.from([0x49, 0x44, 0x33, 0x03]))
+    const realSpeechPath = await realpath(speechPath)
+    try {
+      const settings = buildSettings()
+      settings.claw.im.enabled = true
+      settings.claw.im.responseTimeoutMs = 2_000
+      settings.agents.kun.textToSpeech = {
+        enabled: true,
+        providerId: '',
+        protocol: 'minimax-t2a',
+        baseUrl: 'https://api.minimax.io',
+        apiKey: 'sk-speech',
+        model: 'speech-2.8-hd',
+        voice: '',
+        format: 'mp3',
+        timeoutMs: 120000
+      }
+      settings.claw.channels = [
+        buildChannel({
+          provider: 'weixin' as const,
+          id: 'channel_weixin',
+          label: 'WeChat',
+          threadId: 'thr_wx_speech',
+          conversations: [
+            buildConversation({
+              chatId: 'wx_user_1',
+              senderId: 'wx_user_1',
+              localThreadId: 'thr_wx_speech',
+              workspaceRoot
+            })
+          ]
+        })
+      ]
+      const { store } = mutableSettingsStore(settings)
+      const runtimeRequest = vi.fn(async (_settings, path, init) => {
+        if (path === '/v1/threads/thr_wx_speech/turns' && init?.method === 'POST') {
+          const body = JSON.parse(init?.body ?? '{}') as { prompt?: string }
+          expect(body.prompt).toContain('generate_speech')
+          return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_speech' }) }
+        }
+        if (path === '/v1/threads/thr_wx_speech' && init?.method === 'GET') {
+          return {
+            ok: true,
+            status: 200,
+            body: JSON.stringify({
+              id: 'thr_wx_speech',
+              status: 'idle',
+              turns: [
+                {
+                  id: 'turn_wx_speech',
                   status: 'completed',
                   items: [
                     {
-                      kind: 'assistant_text',
-                      text: '我无法直接通过飞书发送文件给你,但文件已经创建在 workspace 中。'
-                    }
+                      kind: 'tool_result',
+                      toolName: 'generate_speech',
+                      toolKind: 'tool_call',
+                      output: {
+                        files: [{
+                          absolutePath: speechPath,
+                          relativePath: '.deepseekgui-media/speech-20260612000100-feed.mp3',
+                          mimeType: 'audio/mpeg'
+                        }]
+                      },
+                      isError: false
+                    },
+                    { kind: 'assistant_text', text: '语音已生成。' }
                   ]
                 }
               ]
@@ -1285,62 +2689,188 @@ describe('ClawRuntime', () => {
         }
         throw new Error(`unexpected path ${path}`)
       })
-      const send = vi.fn(async () => ({ messageId: 'om_sent' }))
-      const addReaction = vi.fn(async () => 'rc_file_1')
       const runtime = createClawRuntime({
         store: store as never,
-        runtimeRequest,
-        logError: () => undefined
+        runtimeRequest: runtimeRequest as never,
+        logError: () => undefined,
+        createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
       })
-      ;(runtime as unknown as { feishuChannels: Map })
-        .feishuChannels
-        .set('channel_1', { send, addReaction })
+      const body = JSON.stringify({
+        text: '帮我生成一段语音旁白',
+        provider: 'weixin',
+        channelId: 'channel_weixin',
+        chatId: 'wx_user_1',
+        messageId: 'wx_msg_speech',
+        senderId: 'wx_user_1',
+        senderName: 'Alice'
+      })
+      const req = {
+        method: 'POST',
+        url: settings.claw.im.path,
+        headers: {},
+        async *[Symbol.asyncIterator]() {
+          yield Buffer.from(body)
+        }
+      }
+      let status = 0
+      let responseBody = ''
+      const res = {
+        writeHead: vi.fn((nextStatus: number) => {
+          status = nextStatus
+        }),
+        end: vi.fn((payload: string) => {
+          responseBody = payload
+        })
+      }
 
       await (runtime as unknown as {
-        handleFeishuMessage: (channelId: string, message: {
-          chatId: string
-          messageId: string
-          threadId?: string
-          senderId: string
-          senderName?: string
-          chatType: 'p2p' | 'group'
-          mentionedBot: boolean
-          mentionAll: boolean
-          content: string
-          rawContentType: string
-          mentions: unknown[]
-        }) => Promise
-      }).handleFeishuMessage('channel_1', {
-        chatId: 'oc_chat_a',
-        messageId: 'om_inbound',
-        senderId: 'ou_1',
-        senderName: 'Alice',
-        chatType: 'p2p',
-        mentionedBot: false,
-        mentionAll: false,
-        content: '发给我',
-        rawContentType: 'text',
-        mentions: []
+        handleWebhook: (request: typeof req, response: typeof res) => Promise
+      }).handleWebhook(req, res)
+
+      expect(status).toBe(200)
+      const parsed = JSON.parse(responseBody)
+      expect(parsed).toMatchObject({ ok: true, reply: '语音已生成。' })
+      expect(parsed.files).toEqual([
+        {
+          path: realSpeechPath,
+          relativePath: '.deepseekgui-media/speech-20260612000100-feed.mp3',
+          fileName: 'speech-20260612000100-feed.mp3'
+        }
+      ])
+    } finally {
+      await rm(workspaceRoot, { recursive: true, force: true })
+    }
+  })
+
+  it('returns current-turn generated video files in the WeChat webhook reply for follow-up prompts', async () => {
+    const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-video-'))
+    const mediaDir = join(workspaceRoot, '.deepseekgui-media')
+    const videoPath = join(mediaDir, 'video-20260612061000-c0de.mp4')
+    await mkdir(mediaDir, { recursive: true })
+    await writeFile(videoPath, Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]))
+    const realVideoPath = await realpath(videoPath)
+    try {
+      const settings = buildSettings()
+      settings.claw.im.enabled = true
+      settings.claw.im.responseTimeoutMs = 2_000
+      settings.agents.kun.videoGeneration = {
+        enabled: true,
+        providerId: '',
+        protocol: 'minimax-video',
+        baseUrl: 'https://api.minimax.io',
+        apiKey: 'sk-video',
+        model: 'MiniMax-Hailuo-2.3',
+        defaultDuration: 6,
+        defaultResolution: '1080P',
+        timeoutMs: 900000,
+        pollIntervalMs: 10000
+      }
+      settings.claw.channels = [
+        buildChannel({
+          provider: 'weixin' as const,
+          id: 'channel_weixin',
+          label: 'WeChat',
+          threadId: 'thr_wx_video',
+          conversations: [
+            buildConversation({
+              chatId: 'wx_user_1',
+              senderId: 'wx_user_1',
+              localThreadId: 'thr_wx_video',
+              workspaceRoot
+            })
+          ]
+        })
+      ]
+      const { store } = mutableSettingsStore(settings)
+      const runtimeRequest = vi.fn(async (_settings, path, init) => {
+        if (path === '/v1/threads/thr_wx_video/turns' && init?.method === 'POST') {
+          const body = JSON.parse(init?.body ?? '{}') as { prompt?: string }
+          expect(body.prompt).toContain('16:9')
+          expect(body.prompt).toContain('generate_video')
+          return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_video' }) }
+        }
+        if (path === '/v1/threads/thr_wx_video' && init?.method === 'GET') {
+          return {
+            ok: true,
+            status: 200,
+            body: JSON.stringify({
+              id: 'thr_wx_video',
+              status: 'idle',
+              turns: [
+                {
+                  id: 'turn_wx_video',
+                  status: 'completed',
+                  items: [
+                    {
+                      kind: 'tool_result',
+                      toolName: 'generate_video',
+                      toolKind: 'tool_call',
+                      output: {
+                        files: [{
+                          absolutePath: videoPath,
+                          relativePath: '.deepseekgui-media/video-20260612061000-c0de.mp4',
+                          mimeType: 'video/mp4'
+                        }]
+                      },
+                      isError: false
+                    },
+                    { kind: 'assistant_text', text: '视频已生成。' }
+                  ]
+                }
+              ]
+            })
+          }
+        }
+        throw new Error(`unexpected path ${path}`)
+      })
+      const runtime = createClawRuntime({
+        store: store as never,
+        runtimeRequest: runtimeRequest as never,
+        logError: () => undefined,
+        createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
       })
+      const body = JSON.stringify({
+        text: '16:9',
+        provider: 'weixin',
+        channelId: 'channel_weixin',
+        chatId: 'wx_user_1',
+        messageId: 'wx_msg_video',
+        senderId: 'wx_user_1',
+        senderName: 'Alice'
+      })
+      const req = {
+        method: 'POST',
+        url: settings.claw.im.path,
+        headers: {},
+        async *[Symbol.asyncIterator]() {
+          yield Buffer.from(body)
+        }
+      }
+      let status = 0
+      let responseBody = ''
+      const res = {
+        writeHead: vi.fn((nextStatus: number) => {
+          status = nextStatus
+        }),
+        end: vi.fn((payload: string) => {
+          responseBody = payload
+        })
+      }
 
-      expect(send).toHaveBeenNthCalledWith(
-        1,
-        'oc_chat_a',
-        { markdown: '可以,我把 hello.md 作为附件发给你。' },
-        { replyTo: 'om_inbound', replyInThread: false }
-      )
-      expect(send).toHaveBeenNthCalledWith(
-        2,
-        'oc_chat_a',
-        { file: { source: realFilePath, fileName: 'hello.md' } },
-        { replyTo: 'om_inbound', replyInThread: false }
-      )
-      // The direct-file path is fast (synchronous file lookup + upload) and
-      // The direct-file path is fast (synchronous file lookup + upload) and
-      // must NOT add a pending reaction — that would be visually noisy.
-      const addReactionSpy = (runtime as unknown as { feishuChannels: Map }> })
-        .feishuChannels.get('channel_1')?.addReaction
-      expect(addReactionSpy).not.toHaveBeenCalled()
+      await (runtime as unknown as {
+        handleWebhook: (request: typeof req, response: typeof res) => Promise
+      }).handleWebhook(req, res)
+
+      expect(status).toBe(200)
+      const parsed = JSON.parse(responseBody)
+      expect(parsed).toMatchObject({ ok: true, reply: '视频已生成。' })
+      expect(parsed.files).toEqual([
+        {
+          path: realVideoPath,
+          relativePath: '.deepseekgui-media/video-20260612061000-c0de.mp4',
+          fileName: 'video-20260612061000-c0de.mp4'
+        }
+      ])
     } finally {
       await rm(workspaceRoot, { recursive: true, force: true })
     }
diff --git a/src/main/claw-runtime.ts b/src/main/claw-runtime.ts
index 8d9ff6fb..457b0455 100644
--- a/src/main/claw-runtime.ts
+++ b/src/main/claw-runtime.ts
@@ -19,16 +19,23 @@ import type {
   ClawImFeishuPlatformCredentialV1,
   ClawImChannelV1,
   ClawImConversationV1,
-  ClawModel,
   ClawImProvider,
   ClawImRemoteSessionV1,
   ClawRunResult,
-  ClawRuntimeStatus
+  ClawRuntimeStatus,
+  ModelProviderProfileV1
 } from '../shared/app-settings'
 import {
-  CLAW_MODEL_IDS,
   DEFAULT_CLAW_MODEL,
+  DEFAULT_MODEL_PROVIDER_ID,
   buildClawRuntimePrompt,
+  getKunRuntimeSettings,
+  getModelProviderSettings,
+  isComposerChatModelId,
+  listNonTextModelIds,
+  modelProfileSupportsTextChat,
+  modelProviderModelProfile,
+  normalizeModelProviderId,
   parseClawUserPromptForDisplay
 } from '../shared/app-settings'
 import { parseClawCommand } from '../shared/claw-commands'
@@ -64,12 +71,19 @@ import {
   type ThreadRecordJson
 } from './claw-runtime-helpers'
 
-const MAX_FEISHU_FILE_UPLOAD_BYTES = 50 * 1024 * 1024
+const MAX_IM_FILE_UPLOAD_BYTES = 50 * 1024 * 1024
+const CLAW_IM_APPROVAL_POLICY = 'auto'
+const CLAW_IM_SANDBOX_MODE = 'danger-full-access'
 
 type FeishuClawChannel = ClawImChannelV1 & {
   platformCredential: ClawImFeishuPlatformCredentialV1
 }
 
+type IncomingRemoteSession = Pick<
+  ClawImRemoteSessionV1,
+  'chatId' | 'messageId' | 'threadId' | 'senderId' | 'senderName'
+>
+
 function hasFeishuPlatformCredential(channel: ClawImChannelV1): channel is FeishuClawChannel {
   return channel.platformCredential?.kind === 'feishu' &&
     !!channel.platformCredential.appId.trim() &&
@@ -86,6 +100,69 @@ function errorMessage(error: unknown): string {
   return error instanceof Error ? error.message : String(error)
 }
 
+function fallbackWeixinRemoteSession(
+  payload: Record,
+  senderLabel: string
+): IncomingRemoteSession | null {
+  const message = nestedRecord(payload.message)
+  const data = nestedRecord(payload.data)
+  const chatId = asString(
+    payload.chatId ||
+    payload.chat_id ||
+    payload.open_chat_id ||
+    payload.from ||
+    payload.conversationId ||
+    payload.conversation_id ||
+    message.chatId ||
+    message.chat_id ||
+    message.from ||
+    message.sender ||
+    data.chatId ||
+    data.chat_id ||
+    data.from ||
+    data.sender ||
+    senderLabel
+  )
+  if (!chatId || chatId === 'webhook' || chatId === 'WeChat') return null
+  const messageId = asString(
+    payload.messageId ||
+    payload.message_id ||
+    message.messageId ||
+    message.message_id ||
+    data.messageId ||
+    data.message_id
+  ) || `wx_${randomUUID()}`
+  const threadId = asString(
+    payload.threadId ||
+    payload.thread_id ||
+    message.threadId ||
+    message.thread_id ||
+    data.threadId ||
+    data.thread_id
+  )
+  const senderId = asString(
+    payload.senderId ||
+    payload.sender_id ||
+    message.senderId ||
+    message.sender_id ||
+    message.sender ||
+    data.senderId ||
+    data.sender_id ||
+    data.sender
+  ) || chatId
+  const senderName = asString(
+    payload.senderName ||
+    payload.sender_name ||
+    message.senderName ||
+    message.sender_name ||
+    message.sender ||
+    data.senderName ||
+    data.sender_name ||
+    data.sender
+  ) || chatId
+  return { chatId, messageId, threadId, senderId, senderName }
+}
+
 function isChineseLocale(settings: AppSettingsV1): boolean {
   return settings.locale.toLowerCase().startsWith('zh')
 }
@@ -94,38 +171,190 @@ function currentImModel(settings: AppSettingsV1, channel?: ClawImChannelV1): str
   return channel?.model?.trim() || settings.claw.im.model.trim() || DEFAULT_CLAW_MODEL
 }
 
+function currentImProviderId(settings: AppSettingsV1, channel?: ClawImChannelV1): string {
+  return channel?.providerId?.trim() ||
+    settings.claw.im.providerId?.trim() ||
+    getKunRuntimeSettings(settings).providerId.trim() ||
+    DEFAULT_MODEL_PROVIDER_ID
+}
+
+function providerLabel(provider: ModelProviderProfileV1): string {
+  const name = provider.name.trim()
+  return name && name !== provider.id ? `${name} (${provider.id})` : provider.id
+}
+
+function providerTextModels(settings: AppSettingsV1, provider: ModelProviderProfileV1): string[] {
+  const nonTextModelIds = listNonTextModelIds(settings)
+  const models: string[] = []
+  for (const model of provider.models) {
+    const trimmed = model.trim()
+    if (!trimmed) continue
+    if (!isComposerChatModelId(trimmed, nonTextModelIds)) continue
+    if (!modelProfileSupportsTextChat(modelProviderModelProfile(provider, trimmed))) continue
+    models.push(trimmed)
+  }
+  return models
+}
+
+function findImProvider(settings: AppSettingsV1, value: string): ModelProviderProfileV1 | undefined {
+  const query = value.trim()
+  if (!query) return undefined
+  const normalizedId = normalizeModelProviderId(query)
+  const providers = getModelProviderSettings(settings).providers
+  return providers.find((provider) => provider.id === normalizedId) ??
+    providers.find((provider) => provider.id.toLowerCase() === query.toLowerCase()) ??
+    providers.find((provider) => provider.name.trim().toLowerCase() === query.toLowerCase())
+}
+
+function currentImProvider(settings: AppSettingsV1, channel?: ClawImChannelV1): ModelProviderProfileV1 {
+  const providers = getModelProviderSettings(settings).providers
+  const providerId = currentImProviderId(settings, channel)
+  return providers.find((provider) => provider.id === providerId) ??
+    providers.find((provider) => provider.id === DEFAULT_MODEL_PROVIDER_ID) ??
+    providers[0]
+}
+
+function resolveImModelAlias(value: string): string {
+  const normalized = value.trim().toLowerCase()
+  if (normalized === '自动') return 'auto'
+  if (normalized === 'pro') return 'deepseek-v4-pro'
+  if (normalized === 'flash') return 'deepseek-v4-flash'
+  return value.trim()
+}
+
+function findProviderModel(models: readonly string[], value: string): string | undefined {
+  const requested = resolveImModelAlias(value)
+  if (!requested) return undefined
+  if (requested.toLowerCase() === 'auto') return 'auto'
+  return models.find((model) => model === requested) ??
+    models.find((model) => model.toLowerCase() === requested.toLowerCase())
+}
+
+function firstProviderModel(settings: AppSettingsV1, providerId: string): string {
+  const provider = findImProvider(settings, providerId)
+  return provider ? providerTextModels(settings, provider)[0] ?? DEFAULT_CLAW_MODEL : DEFAULT_CLAW_MODEL
+}
+
+function settingsWithImModelProvider(
+  settings: AppSettingsV1,
+  providerId: string | undefined,
+  model: string
+): AppSettingsV1 {
+  const trimmedProviderId = providerId?.trim()
+  if (!trimmedProviderId) return settings
+  const resolvedModel = model.trim() && model.trim() !== DEFAULT_CLAW_MODEL
+    ? model.trim()
+    : firstProviderModel(settings, trimmedProviderId)
+  return {
+    ...settings,
+    agents: {
+      ...settings.agents,
+      kun: {
+        ...settings.agents.kun,
+        providerId: trimmedProviderId,
+        model: resolvedModel
+      }
+    }
+  }
+}
+
 function imCommandHelpText(settings: AppSettingsV1): string {
   if (isChineseLocale(settings)) {
     return [
       'Claw IM 命令:',
       '- `/help`:查看命令帮助',
       '- `/new`:当前 IM 连接开启新话题',
-      '- `/model`:查看当前模型',
-      '- `/model auto|pro|flash`:切换当前 IM 连接模型',
-      '也支持 `-new`、`-help`、`-model flash` 这种写法。'
+      '- `/provider`:查看已加载的模型供应商',
+      '- `/provider `:切换当前 IM 连接供应商',
+      '- `/model`:查看当前供应商可用模型',
+      '- `/model `:切换当前 IM 连接模型',
+      '也支持 `-new`、`-help`、`-provider minimax`、`-model MiniMax-M3` 这种写法。'
     ].join('\n')
   }
   return [
     'Claw IM commands:',
     '- `/help`: show command help',
     '- `/new`: start a new topic for this IM connection',
-    '- `/model`: show the current model',
-    '- `/model auto|pro|flash`: switch this IM connection model',
-    '`-new`, `-help`, and `-model flash` are supported too.'
+    '- `/provider`: list loaded model providers',
+    '- `/provider `: switch the provider for this IM connection',
+    '- `/model`: list models for the current provider',
+    '- `/model `: switch the model for this IM connection',
+    '`-new`, `-help`, `-provider minimax`, and `-model MiniMax-M3` are supported too.'
   ].join('\n')
 }
 
-function imModelCommandHint(settings: AppSettingsV1): string {
-  const ids = CLAW_MODEL_IDS.join(', ')
+function imProviderListText(settings: AppSettingsV1, channel?: ClawImChannelV1): string {
+  const providers = getModelProviderSettings(settings).providers
+  const currentProviderId = currentImProviderId(settings, channel)
+  const rows = providers.map((provider) => {
+    const marker = provider.id === currentProviderId ? '*' : '-'
+    const modelCount = providerTextModels(settings, provider).length
+    const keyStatus = provider.apiKey.trim()
+      ? (isChineseLocale(settings) ? '已配置 API Key' : 'API key set')
+      : (isChineseLocale(settings) ? '未配置 API Key' : 'no API key')
+    return `${marker} \`${provider.id}\` ${providerLabel(provider)} · ${modelCount} models · ${keyStatus}`
+  })
+  if (isChineseLocale(settings)) {
+    return [
+      `当前供应商:\`${currentProviderId}\`。`,
+      '已加载供应商:',
+      ...rows,
+      '切换供应商:`/provider `。切换后可用 `/model` 查看该供应商模型。'
+    ].join('\n')
+  }
+  return [
+    `Current provider: \`${currentProviderId}\`.`,
+    'Loaded providers:',
+    ...rows,
+    'Switch provider with `/provider `. Use `/model` after switching to list its models.'
+  ].join('\n')
+}
+
+function imProviderCommandHint(settings: AppSettingsV1, value: string): string {
   return isChineseLocale(settings)
-    ? `可使用 /model auto、/model pro 或 /model flash。可用模型:${ids}。`
-    : `Use /model auto, /model pro, or /model flash. Available models: ${ids}.`
+    ? `没有找到供应商 \`${value}\`。发送 \`/provider\` 查看已加载供应商。`
+    : `Provider \`${value}\` was not found. Send \`/provider\` to list loaded providers.`
 }
 
-function imModelCurrentText(settings: AppSettingsV1, model: string): string {
+function imProviderChangedText(
+  settings: AppSettingsV1,
+  provider: ModelProviderProfileV1,
+  model: string
+): string {
   return isChineseLocale(settings)
-    ? `当前 Claw IM 模型是 \`${model}\`。`
-    : `Current Claw IM model: \`${model}\`.`
+    ? `当前 IM 供应商已切换到 \`${provider.id}\`,模型为 \`${model}\`。发送 \`/model\` 可查看这个供应商的可用模型。`
+    : `IM provider switched to \`${provider.id}\`; model is \`${model}\`. Send \`/model\` to list models for this provider.`
+}
+
+function imModelListText(settings: AppSettingsV1, channel?: ClawImChannelV1): string {
+  const provider = currentImProvider(settings, channel)
+  const models = providerTextModels(settings, provider)
+  const currentModel = currentImModel(settings, channel)
+  const rows = models.map((model) => `${model === currentModel ? '*' : '-'} \`${model}\``)
+  if (isChineseLocale(settings)) {
+    return [
+      `当前供应商:\`${provider.id}\` ${providerLabel(provider)}`,
+      `当前模型:\`${currentModel}\`。`,
+      ...(rows.length > 0
+        ? ['可用模型:', ...rows, '切换模型:`/model `。']
+        : ['这个供应商还没有可用的文本模型,请先在设置里为它配置模型。'])
+    ].join('\n')
+  }
+  return [
+    `Current provider: \`${provider.id}\` ${providerLabel(provider)}`,
+    `Current model: \`${currentModel}\`.`,
+    ...(rows.length > 0
+      ? ['Available models:', ...rows, 'Switch model with `/model `.']
+      : ['This provider has no usable text models yet. Add models for it in Settings first.'])
+  ].join('\n')
+}
+
+function imModelCommandHint(settings: AppSettingsV1, provider: ModelProviderProfileV1, value: string): string {
+  const models = providerTextModels(settings, provider)
+  const ids = models.map((model) => `\`${model}\``).join(', ')
+  return isChineseLocale(settings)
+    ? `供应商 \`${provider.id}\` 下没有找到模型 \`${value}\`。${ids ? `可用模型:${ids}。` : '这个供应商还没有可用的文本模型。'}`
+    : `Model \`${value}\` was not found for provider \`${provider.id}\`. ${ids ? `Available models: ${ids}.` : 'This provider has no usable text models yet.'}`
 }
 
 function imModelChangedText(settings: AppSettingsV1, model: string): string {
@@ -140,6 +369,32 @@ function imNewTopicText(settings: AppSettingsV1): string {
     : 'Started a new topic. The next message will create a fresh local conversation.'
 }
 
+/**
+ * One-time intro sent to an IM conversation when the channel is first
+ * connected: who the assistant is, what it can do, and the IM commands.
+ */
+export function imWelcomeText(settings: AppSettingsV1, channel?: ClawImChannelV1): string {
+  const profile = channel?.agentProfile
+  const name = profile?.name.trim() || channel?.label.trim() || 'Kun'
+  const description = profile?.description.trim() ?? ''
+  if (isChineseLocale(settings)) {
+    return [
+      `你好,我是 ${name},通过 Kun 连接到这个对话的 AI 助手。`,
+      ...(description ? [description] : []),
+      '你可以直接发消息让我帮忙:回答问题、查资料、读写已连接电脑工作区里的文件、生成文档等,完成后我会在这里回复你。',
+      imCommandHelpText(settings),
+      '直接发一条消息就可以开始。'
+    ].join('\n\n')
+  }
+  return [
+    `Hi, I am ${name}, an AI assistant connected to this chat through Kun.`,
+    ...(description ? [description] : []),
+    'Send me a message and I will handle it on the connected computer: answering questions, research, reading and writing workspace files, generating documents — I reply here once done.',
+    imCommandHelpText(settings),
+    'Send any message to get started.'
+  ].join('\n\n')
+}
+
 export class ClawRuntime {
   private readonly deps: ClawRuntimeDeps
   private server: Server | null = null
@@ -147,6 +402,10 @@ export class ClawRuntime {
   private feishuChannels = new Map()
   private feishuChannelKeys = new Map()
   private feishuSyncVersion = 0
+  /** Channels with an in-flight first-message welcome delivery. */
+  private readonly welcomeInFlight = new Set()
+  /** WeChat channels already greeted (or attempted) at connect time this run. */
+  private readonly weixinConnectWelcomeAttempted = new Set()
 
   constructor(deps: ClawRuntimeDeps) {
     this.deps = deps
@@ -155,6 +414,98 @@ export class ClawRuntime {
   sync(settings: AppSettingsV1): void {
     this.syncWebhook(settings)
     void this.syncFeishuChannels(settings)
+    void this.syncWeixinConnectWelcomes(settings)
+  }
+
+  /**
+   * Greets the WeChat owner right after a channel is first connected.
+   * The QR login records the owner's user id, so the intro can be
+   * pushed before any inbound message. Failures fall back to the
+   * first-inbound-message welcome.
+   */
+  private async syncWeixinConnectWelcomes(settings: AppSettingsV1): Promise {
+    if (!settings.claw.enabled || !settings.claw.im.enabled) return
+    if (!this.deps.sendWeixinBridgeMessage || !this.deps.resolveWeixinAccountUserId) return
+    for (const channel of settings.claw.channels) {
+      if (!channel.enabled || channel.provider !== 'weixin' || channel.welcomeSentAt) continue
+      const credential = channel.platformCredential
+      if (credential?.kind !== 'weixin' || !credential.accountId.trim()) continue
+      if (this.weixinConnectWelcomeAttempted.has(channel.id) || this.welcomeInFlight.has(channel.id)) continue
+      this.weixinConnectWelcomeAttempted.add(channel.id)
+      this.welcomeInFlight.add(channel.id)
+      try {
+        const owner = (await this.deps.resolveWeixinAccountUserId(credential.accountId)).trim()
+        if (!owner) continue
+        const result = await this.deps.sendWeixinBridgeMessage({
+          accountId: credential.accountId,
+          to: owner,
+          text: imWelcomeText(settings, channel)
+        })
+        if (result.ok) {
+          await this.markChannelWelcomeSent(channel.id)
+        } else {
+          this.deps.logError('claw-weixin', 'Failed to greet the WeChat owner after connect; the welcome will be sent on the first inbound message instead.', {
+            channelId: channel.id,
+            message: result.message
+          })
+        }
+      } catch (error) {
+        this.deps.logError('claw-weixin', 'Failed to greet the WeChat owner after connect', {
+          channelId: channel.id,
+          message: errorMessage(error)
+        })
+      } finally {
+        this.welcomeInFlight.delete(channel.id)
+      }
+    }
+  }
+
+  private async markChannelWelcomeSent(channelId: string): Promise {
+    const settings = await this.deps.store.load()
+    const now = new Date().toISOString()
+    await this.deps.store.patch({
+      claw: {
+        channels: settings.claw.channels.map((item) =>
+          item.id === channelId ? { ...item, welcomeSentAt: now, updatedAt: now } : item
+        )
+      }
+    })
+  }
+
+  /** Welcome text still owed to this channel, or '' when already delivered. */
+  private pendingWelcomeText(settings: AppSettingsV1, channel: ClawImChannelV1 | undefined): string {
+    if (!channel || channel.welcomeSentAt || this.welcomeInFlight.has(channel.id)) return ''
+    return imWelcomeText(settings, channel)
+  }
+
+  /**
+   * Sends the welcome as its own WeChat bubble so it arrives ahead of
+   * the (slow) model reply. Returns false when the channel cannot push
+   * (non-WeChat provider, missing bridge, unknown recipient) so the
+   * caller falls back to prepending the text to the HTTP reply.
+   */
+  private async pushWeixinWelcome(
+    channel: ClawImChannelV1,
+    remoteSession: Pick | undefined,
+    text: string
+  ): Promise {
+    if (channel.provider !== 'weixin' || !this.deps.sendWeixinBridgeMessage) return false
+    const credential = channel.platformCredential
+    if (credential?.kind !== 'weixin' || !credential.accountId.trim()) return false
+    const to = remoteSession?.chatId.trim() || channel.remoteSession?.chatId.trim() || ''
+    if (!to) return false
+    const result = await this.deps.sendWeixinBridgeMessage({
+      accountId: credential.accountId,
+      to,
+      text
+    })
+    if (!result.ok) {
+      this.deps.logError('claw-weixin', 'Failed to push the WeChat welcome message; prepending it to the reply instead.', {
+        channelId: channel.id,
+        message: result.message
+      })
+    }
+    return result.ok
   }
 
   stop(): void {
@@ -179,17 +530,23 @@ export class ClawRuntime {
     const workspace = options.workspaceRoot.trim() || settings.workspaceRoot
     const existingThreadId = options.threadId?.trim()
     const model = normalizeTaskModel(options.model) ?? (settings.agents.kun.model.trim() || DEFAULT_CLAW_MODEL)
+    const runtimeSettings = settingsWithImModelProvider(settings, options.providerId, model)
     const createThread = async (): Promise => {
-      const create = await this.deps.runtimeRequest(settings, '/v1/threads', {
+      const body: Record = { workspace, model, mode: options.mode }
+      if (options.source === 'im') {
+        body.approvalPolicy = CLAW_IM_APPROVAL_POLICY
+        body.sandboxMode = CLAW_IM_SANDBOX_MODE
+      }
+      const create = await this.deps.runtimeRequest(runtimeSettings, '/v1/threads', {
         method: 'POST',
-        body: JSON.stringify({ workspace, model, mode: options.mode })
+        body: JSON.stringify(body)
       })
       if (!create.ok) return null
       return JSON.parse(create.body) as ThreadRecordJson
     }
     const patchThreadTitle = (thread: ThreadRecordJson): void => {
       if (!options.title.trim()) return
-      void this.deps.runtimeRequest(settings, `/v1/threads/${encodeURIComponent(thread.id)}`, {
+      void this.deps.runtimeRequest(runtimeSettings, `/v1/threads/${encodeURIComponent(thread.id)}`, {
         method: 'PATCH',
         body: JSON.stringify({ title: options.title.trim() })
       })
@@ -198,7 +555,7 @@ export class ClawRuntime {
     if (!thread) return { ok: false, message: 'Failed to create thread.' }
     if (!existingThreadId) patchThreadTitle(thread)
 
-    const runtimePrompt = buildClawRuntimePrompt(settings, options.prompt, { channel: options.channel })
+    const runtimePrompt = buildClawRuntimePrompt(runtimeSettings, options.prompt, { channel: options.channel })
     const displayText = options.displayText?.trim() || parseClawUserPromptForDisplay(options.prompt).text
     const turnBody: Record = {
       prompt: runtimePrompt,
@@ -206,7 +563,14 @@ export class ClawRuntime {
     }
     if (displayText && displayText !== runtimePrompt) turnBody.displayText = displayText
     if (model) turnBody.model = model
-    let turn = await this.startRuntimeTurn(settings, thread.id, turnBody)
+    // IM senders can only reply in their chat app; they cannot answer
+    // GUI prompts, so the runtime must not expose user-input tools.
+    if (options.source === 'im') {
+      turnBody.disableUserInput = true
+      turnBody.approvalPolicy = CLAW_IM_APPROVAL_POLICY
+      turnBody.sandboxMode = CLAW_IM_SANDBOX_MODE
+    }
+    let turn = await this.startRuntimeTurn(runtimeSettings, thread.id, turnBody)
     if (!turn.ok && existingThreadId && isMissingThreadResult(turn)) {
       this.deps.logError('claw-runtime', 'Configured IM thread was missing; creating a replacement thread.', {
         threadId: existingThreadId,
@@ -216,7 +580,7 @@ export class ClawRuntime {
       thread = await createThread()
       if (!thread) return { ok: false, message: 'Failed to create thread.' }
       patchThreadTitle(thread)
-      turn = await this.startRuntimeTurn(settings, thread.id, turnBody)
+      turn = await this.startRuntimeTurn(runtimeSettings, thread.id, turnBody)
     }
     if (!turn.ok) return { ok: false, message: runtimeErrorMessage(turn, 'Failed to start turn.') }
 
@@ -232,7 +596,7 @@ export class ClawRuntime {
       return { ok: true, threadId: thread.id, turnId, message: 'Started' }
     }
 
-    const result = await this.waitForAssistantResult(settings, thread.id, turnId, options.responseTimeoutMs, workspace)
+    const result = await this.waitForAssistantResult(runtimeSettings, thread.id, turnId, options.responseTimeoutMs, workspace)
     return {
       ok: true,
       threadId: thread.id,
@@ -399,7 +763,7 @@ export class ClawRuntime {
     })
   }
 
-  private async setIncomingImModel(channel: ClawImChannelV1 | undefined, model: ClawModel): Promise {
+  private async setIncomingImModel(channel: ClawImChannelV1 | undefined, model: string): Promise {
     if (!channel) {
       await this.deps.store.patch({ claw: { im: { model } } })
       return
@@ -421,6 +785,33 @@ export class ClawRuntime {
     })
   }
 
+  private async setIncomingImProvider(
+    channel: ClawImChannelV1 | undefined,
+    providerId: string,
+    model: string
+  ): Promise {
+    if (!channel) {
+      await this.deps.store.patch({ claw: { im: { providerId, model } } })
+      return
+    }
+    const currentSettings = await this.deps.store.load()
+    const now = new Date().toISOString()
+    await this.deps.store.patch({
+      claw: {
+        channels: currentSettings.claw.channels.map((item) =>
+          item.id === channel.id
+            ? {
+                ...item,
+                providerId,
+                model,
+                updatedAt: now
+              }
+            : item
+        )
+      }
+    })
+  }
+
   private async handleIncomingImCommand(
     settings: AppSettingsV1,
     input: {
@@ -433,11 +824,26 @@ export class ClawRuntime {
     const command = parseClawCommand(input.text)
     if (!command) return null
     if (command.kind === 'help') return imCommandHelpText(settings)
-    if (command.kind === 'showModel') return imModelCurrentText(settings, currentImModel(settings, input.channel))
-    if (command.kind === 'invalidModel') return imModelCommandHint(settings)
+    if (command.kind === 'showProvider') return imProviderListText(settings, input.channel)
+    if (command.kind === 'provider') {
+      const provider = findImProvider(settings, command.providerId)
+      if (!provider) return imProviderCommandHint(settings, command.providerId)
+      const models = providerTextModels(settings, provider)
+      const currentModel = currentImModel(settings, input.channel)
+      const currentProviderModel = currentModel === DEFAULT_CLAW_MODEL
+        ? undefined
+        : findProviderModel(models, currentModel)
+      const nextModel = currentProviderModel ?? models[0] ?? DEFAULT_CLAW_MODEL
+      await this.setIncomingImProvider(input.channel, provider.id, nextModel)
+      return imProviderChangedText(settings, provider, nextModel)
+    }
+    if (command.kind === 'showModel') return imModelListText(settings, input.channel)
     if (command.kind === 'model') {
-      await this.setIncomingImModel(input.channel, command.model)
-      return imModelChangedText(settings, command.model)
+      const provider = currentImProvider(settings, input.channel)
+      const model = findProviderModel(providerTextModels(settings, provider), command.model)
+      if (!model) return imModelCommandHint(settings, provider, command.model)
+      await this.setIncomingImModel(input.channel, model)
+      return imModelChangedText(settings, model)
     }
     if (command.kind === 'clear') {
       await this.resetIncomingImThread({
@@ -471,6 +877,7 @@ export class ClawRuntime {
       title: channel ? `[Claw IM:${channel.label}] ${sender}` : `[Claw IM:${provider}] ${sender}`,
       workspaceRoot: this.resolveIncomingWorkspaceRoot(settings, channel, conversation, remoteSession),
       model: channel?.model ?? settings.claw.im.model,
+      providerId: channel?.providerId ?? settings.claw.im.providerId,
       mode: settings.claw.im.mode,
       waitForResult: true,
       responseTimeoutMs: settings.claw.im.responseTimeoutMs,
@@ -480,6 +887,10 @@ export class ClawRuntime {
       onTurnStarted: async ({ threadId }) => {
         if (!channel) return
         const now = new Date().toISOString()
+        // Patch from a fresh settings snapshot: the request-scoped
+        // `settings` may be stale by now (e.g. the welcome marker was
+        // persisted while this turn was starting).
+        const latestSettings = await this.deps.store.load()
         if (remoteSession) {
           const existingConversation = conversation ?? this.findChannelConversation(channel, remoteSession)
           const nextConversation: ClawImConversationV1 = existingConversation
@@ -506,7 +917,7 @@ export class ClawRuntime {
               }
           await this.deps.store.patch({
             claw: {
-              channels: settings.claw.channels.map((item) =>
+              channels: latestSettings.claw.channels.map((item) =>
                 item.id === channel.id
                   ? {
                       ...item,
@@ -523,7 +934,7 @@ export class ClawRuntime {
         } else if (!initialThreadId) {
           await this.deps.store.patch({
             claw: {
-              channels: settings.claw.channels.map((item) =>
+              channels: latestSettings.claw.channels.map((item) =>
                 item.id === channel.id
                   ? {
                       ...item,
@@ -646,7 +1057,7 @@ export class ClawRuntime {
     }
   }
 
-  private async resolveFeishuGeneratedFiles(
+  private async resolveImGeneratedFiles(
     files: readonly ClawGeneratedFileV1[],
     workspaceRoot: string,
     context: Record
@@ -657,7 +1068,7 @@ export class ClawRuntime {
     try {
       realRoot = await realpath(resolve(root))
     } catch (error) {
-      this.deps.logError('claw-feishu', 'Failed to resolve Feishu file workspace root', {
+      this.deps.logError('claw-im', 'Failed to resolve IM file workspace root', {
         ...context,
         workspaceRoot: root,
         message: errorMessage(error)
@@ -672,7 +1083,7 @@ export class ClawRuntime {
         const realFile = await realpath(resolve(file.path))
         const relativePath = relative(realRoot, realFile)
         if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
-          this.deps.logError('claw-feishu', 'Skipping generated file outside the Feishu workspace', {
+          this.deps.logError('claw-im', 'Skipping generated file outside the IM workspace', {
             ...context,
             filePath: file.path,
             workspaceRoot: root
@@ -682,12 +1093,12 @@ export class ClawRuntime {
         if (seen.has(realFile)) continue
         const fileStat = await stat(realFile)
         if (!fileStat.isFile()) continue
-        if (fileStat.size > MAX_FEISHU_FILE_UPLOAD_BYTES) {
-          this.deps.logError('claw-feishu', 'Skipping generated file because it is too large for Feishu upload', {
+        if (fileStat.size > MAX_IM_FILE_UPLOAD_BYTES) {
+          this.deps.logError('claw-im', 'Skipping generated file because it is too large for IM upload', {
             ...context,
             filePath: realFile,
             bytes: fileStat.size,
-            maxBytes: MAX_FEISHU_FILE_UPLOAD_BYTES
+            maxBytes: MAX_IM_FILE_UPLOAD_BYTES
           })
           continue
         }
@@ -698,7 +1109,7 @@ export class ClawRuntime {
           fileName: file.fileName || realFile.split(/[\\/]/).pop() || 'attachment'
         })
       } catch (error) {
-        this.deps.logError('claw-feishu', 'Skipping generated file that cannot be read for Feishu upload', {
+        this.deps.logError('claw-im', 'Skipping generated file that cannot be read for IM upload', {
           ...context,
           filePath: file.path,
           message: errorMessage(error)
@@ -915,6 +1326,36 @@ export class ClawRuntime {
     const workspaceRoot = this.resolveIncomingWorkspaceRoot(settings, channel, conversation, remoteSession)
     const replyOptions = { replyTo: message.messageId, replyInThread: Boolean(message.threadId) }
 
+    // Feishu has no recipient until someone messages the bot, so the
+    // one-time channel intro goes out before handling the first message.
+    const welcomeText = this.pendingWelcomeText(settings, channel)
+    if (welcomeText) {
+      this.welcomeInFlight.add(channel.id)
+      try {
+        await this.sendFeishuMessage(
+          bridge,
+          message.chatId,
+          { markdown: welcomeText },
+          {},
+          {
+            purpose: 'welcome',
+            channelId,
+            chatId: message.chatId,
+            inboundMessageId: message.messageId
+          }
+        )
+        await this.markChannelWelcomeSent(channel.id)
+      } catch (error) {
+        this.deps.logError('claw-feishu', 'Failed to send the Feishu welcome message; it will be retried on the next inbound message.', {
+          message: errorMessage(error),
+          channelId,
+          chatId: message.chatId
+        })
+      } finally {
+        this.welcomeInFlight.delete(channel.id)
+      }
+    }
+
     const commandReply = await this.handleIncomingImCommand(settings, {
       text: message.content,
       channel,
@@ -940,6 +1381,8 @@ export class ClawRuntime {
     const sender = feishuSenderLabel(message)
     const taskCreation = await this.deps.createScheduledTaskFromText?.(message.content, {
       workspaceRoot: this.resolveChannelWorkspaceRoot(settings, channel),
+      clawChannelId: channel.id,
+      providerId: channel.providerId?.trim() || settings.claw.im.providerId?.trim() || null,
       modelHint: channel.model,
       mode: settings.claw.im.mode
     }) ?? { kind: 'noop' as const }
@@ -998,7 +1441,7 @@ export class ClawRuntime {
 
     if (shouldDirectSendExistingGeneratedFilesForPrompt(message.content)) {
       const existingThreadId = conversation?.localThreadId.trim() || channel.threadId.trim()
-      const existingFiles = await this.resolveFeishuGeneratedFiles(
+      const existingFiles = await this.resolveImGeneratedFiles(
         await this.recentGeneratedFilesForThread(settings, existingThreadId, workspaceRoot, {
           purpose: 'direct-existing-file-lookup',
           channelId,
@@ -1137,8 +1580,9 @@ export class ClawRuntime {
       return
     }
 
-    const filesToSend = result.ok && shouldSendGeneratedFilesForPrompt(message.content)
-      ? await this.resolveFeishuGeneratedFiles(result.files ?? [], workspaceRoot, {
+    const generatedFiles = result.ok ? result.files ?? [] : []
+    const filesToSend = result.ok && (generatedFiles.length > 0 || shouldSendGeneratedFilesForPrompt(message.content))
+      ? await this.resolveImGeneratedFiles(generatedFiles, workspaceRoot, {
           purpose: 'agent-file-resolve',
           channelId,
           chatId: message.chatId,
@@ -1255,7 +1699,7 @@ export class ClawRuntime {
           appSecret,
           domain: domain === 'lark' ? Domain.Lark : Domain.Feishu,
           loggerLevel: LoggerLevel.warn,
-          source: 'deepseek-gui',
+          source: 'kun',
           transport: 'websocket',
           policy: {
             dmMode: 'open',
@@ -1405,9 +1849,10 @@ export class ClawRuntime {
       }
       if (im.secret) {
         const auth = req.headers.authorization ?? ''
-        const headerSecret = Array.isArray(req.headers['x-deepseek-gui-secret'])
-          ? req.headers['x-deepseek-gui-secret'][0]
-          : req.headers['x-deepseek-gui-secret']
+        // 新名字 x-kun-secret 优先;旧名字 x-deepseek-gui-secret 已配置
+        // 在外部系统里,属于对外契约,必须长期兼容。
+        const rawHeaderSecret = req.headers['x-kun-secret'] ?? req.headers['x-deepseek-gui-secret']
+        const headerSecret = Array.isArray(rawHeaderSecret) ? rawHeaderSecret[0] : rawHeaderSecret
         if (auth !== `Bearer ${im.secret}` && headerSecret !== im.secret) {
           writeJson(res, 401, { ok: false, message: 'Unauthorized.' })
           return
@@ -1437,7 +1882,8 @@ export class ClawRuntime {
         : settings.claw.channels.find(
             (item) => item.enabled && item.provider === provider
           )
-      const remoteSession = extractIncomingRemoteSession(payload)
+      const remoteSession = extractIncomingRemoteSession(payload) ??
+        (provider === 'weixin' ? fallbackWeixinRemoteSession(payload, sender) : null)
       if (provider === 'feishu' && channel) {
         if (remoteSession) {
           await this.rememberFeishuRemoteSession(settings, channel, remoteSession)
@@ -1450,6 +1896,21 @@ export class ClawRuntime {
               threadId: remoteSession.threadId
             })
           : undefined
+      // First inbound message on a freshly connected channel: push the
+      // intro over the WeChat bridge when possible (it lands before the
+      // model reply), otherwise prepend it to this response.
+      let welcomePrefix = ''
+      const welcomeText = this.pendingWelcomeText(settings, channel)
+      if (welcomeText && channel) {
+        this.welcomeInFlight.add(channel.id)
+        try {
+          const pushed = await this.pushWeixinWelcome(channel, remoteSession ?? undefined, welcomeText)
+          if (!pushed) welcomePrefix = `${welcomeText}\n\n---\n\n`
+          await this.markChannelWelcomeSent(channel.id)
+        } finally {
+          this.welcomeInFlight.delete(channel.id)
+        }
+      }
       const commandReply = await this.handleIncomingImCommand(settings, {
         text: prompt,
         channel,
@@ -1457,16 +1918,18 @@ export class ClawRuntime {
         remoteSession: remoteSession ?? undefined
       })
       if (commandReply !== null) {
-        writeJson(res, 200, { ok: true, reply: commandReply })
+        writeJson(res, 200, { ok: true, reply: `${welcomePrefix}${commandReply}` })
         return
       }
       const taskCreation = await this.deps.createScheduledTaskFromText?.(prompt, {
         workspaceRoot: this.resolveChannelWorkspaceRoot(settings, channel),
+        clawChannelId: channel?.id ?? null,
+        providerId: channel?.providerId?.trim() || im.providerId?.trim() || null,
         modelHint: channel?.model ?? im.model,
         mode: im.mode
       }) ?? { kind: 'noop' as const }
       if (taskCreation.kind === 'created') {
-        writeJson(res, 200, { ok: true, createdTaskId: taskCreation.taskId, reply: taskCreation.confirmationText })
+        writeJson(res, 200, { ok: true, createdTaskId: taskCreation.taskId, reply: `${welcomePrefix}${taskCreation.confirmationText}` })
         return
       }
       if (taskCreation.kind === 'error') {
@@ -1481,7 +1944,29 @@ export class ClawRuntime {
         conversation,
         remoteSession: remoteSession ?? undefined
       })
-      writeJson(res, result.ok ? 200 : 500, result.ok ? { ...result, reply: result.text ?? '' } : result)
+      if (!result.ok) {
+        writeJson(res, 500, result)
+        return
+      }
+      // Current-turn deliverable media files ride along in the response so
+      // push-capable bridges (WeChat) can upload them after the text reply.
+      // The prompt heuristic remains as a fallback for explicit file-send
+      // requests when the current run returns an empty list.
+      const generatedFiles = result.files ?? []
+      const files = generatedFiles.length > 0 || shouldSendGeneratedFilesForPrompt(prompt)
+        ? await this.resolveImGeneratedFiles(
+            generatedFiles,
+            this.resolveIncomingWorkspaceRoot(settings, channel, conversation, remoteSession ?? undefined),
+            {
+              purpose: 'im-webhook-file-resolve',
+              provider,
+              channelId: channel?.id,
+              threadId: result.threadId,
+              turnId: result.turnId
+            }
+          )
+        : []
+      writeJson(res, 200, { ...result, files, reply: `${welcomePrefix}${result.text ?? ''}` })
     } catch (error) {
       const message = error instanceof Error ? error.message : String(error)
       this.deps.logError('claw-webhook', 'Claw IM webhook request failed', { message })
diff --git a/src/main/claw-schedule-mcp-config.test.ts b/src/main/claw-schedule-mcp-config.test.ts
index 48731255..b9292318 100644
--- a/src/main/claw-schedule-mcp-config.test.ts
+++ b/src/main/claw-schedule-mcp-config.test.ts
@@ -72,8 +72,8 @@ function createSettings(patch: Partial =
 }
 
 const launch: ClawScheduleMcpLaunchConfig = {
-  appPath: '/Applications/DeepSeek GUI.app',
-  execPath: '/Applications/DeepSeek GUI.app/Contents/MacOS/DeepSeek GUI',
+  appPath: '/Applications/Kun.app',
+  execPath: '/Applications/Kun.app/Contents/MacOS/Kun',
   isPackaged: false
 }
 
@@ -128,7 +128,7 @@ describe('claw schedule MCP config', () => {
 
   it('uses the macOS Electron helper for real app bundle paths', () => {
     expect(resolveClawScheduleMcpCommand(launch, 'darwin')).toBe(
-      '/Applications/DeepSeek GUI.app/Contents/Frameworks/DeepSeek GUI Helper.app/Contents/MacOS/DeepSeek GUI Helper'
+      '/Applications/Kun.app/Contents/Frameworks/Kun Helper.app/Contents/MacOS/Kun Helper'
     )
     expect(resolveClawScheduleMcpCommand({
       appPath: '/tmp/deepseek-gui-test-app',
diff --git a/src/main/claw-schedule-mcp-server.ts b/src/main/claw-schedule-mcp-server.ts
index 5e53ec65..a2e1a87c 100644
--- a/src/main/claw-schedule-mcp-server.ts
+++ b/src/main/claw-schedule-mcp-server.ts
@@ -71,15 +71,15 @@ export async function runClawScheduleMcpServerFromArgv(argv: string[]): Promise<
   if (!options) return false
 
   const server = new McpServer(
-    { name: 'deepseek-gui-schedule', version: '0.1.0' },
+    { name: 'kun-schedule', version: '0.1.0' },
     { capabilities: { logging: {} } }
   )
 
   const registerListTool = (name: string): void => {
     server.registerTool(name, {
       description: name.startsWith('claw_')
-        ? 'Legacy alias. List scheduled tasks managed by the currently running DeepSeek GUI app.'
-        : 'List scheduled tasks managed by the currently running DeepSeek GUI app.'
+        ? 'Legacy alias. List scheduled tasks managed by the currently running Kun app.'
+        : 'List scheduled tasks managed by the currently running Kun app.'
     }, async () => {
       try {
         const result = await postJson(options, '/schedule/internal/list', {})
@@ -101,8 +101,8 @@ export async function runClawScheduleMcpServerFromArgv(argv: string[]): Promise<
   const registerCreateTool = (name: string): void => {
     server.registerTool(name, {
       description: name.startsWith('claw_')
-        ? 'Legacy alias. Create a scheduled task in DeepSeek GUI. Supports one-time (`at`), daily, or interval schedules.'
-        : 'Create a scheduled task in DeepSeek GUI. Supports one-time (`at`), daily, or interval schedules.',
+        ? 'Legacy alias. Create a scheduled task in Kun. Supports one-time (`at`), daily, or interval schedules.'
+        : 'Create a scheduled task in Kun. Supports one-time (`at`), daily, or interval schedules. When creating from an existing conversation, pass that conversation\'s provider_id, model, and reasoning_effort so the scheduled task keeps the same execution settings.',
       inputSchema: {
         title: z.string().min(1).describe('Short task title shown in the GUI'),
         prompt: z.string().min(1).describe('The prompt/instruction the agent should run at schedule time'),
@@ -111,8 +111,10 @@ export async function runClawScheduleMcpServerFromArgv(argv: string[]): Promise<
         time_of_day: z.string().optional().describe('24h time like 09:00, required when schedule_kind is `daily`'),
         every_minutes: z.number().int().min(1).max(10080).optional().describe('Interval in minutes, required when schedule_kind is `interval`'),
         workspace_root: z.string().optional().describe('Optional workspace directory override'),
-        model: z.string().optional().describe('Optional model id, e.g. auto / deepseek-v4-pro / deepseek-v4-flash'),
-        reasoning_effort: z.enum(['off', 'low', 'medium', 'high', 'max']).optional().describe('Optional reasoning strength'),
+        claw_channel_id: z.string().optional().describe('Optional Claw IM channel id whose persona should run this task'),
+        provider_id: z.string().optional().describe('Optional model provider id configured in Kun settings'),
+        model: z.string().optional().describe('Optional model id, e.g. deepseek-v4-pro / deepseek-v4-flash'),
+        reasoning_effort: z.enum(['auto', 'off', 'low', 'medium', 'high', 'max']).optional().describe('Optional reasoning strength'),
         mode: z.enum(['agent', 'plan']).optional().describe('Execution mode'),
         enabled: z.boolean().optional().describe('Whether the task should be enabled immediately')
       }
@@ -123,6 +125,8 @@ export async function runClawScheduleMcpServerFromArgv(argv: string[]): Promise<
             title: args.title,
             prompt: args.prompt,
             workspaceRoot: args.workspace_root,
+            clawChannelId: args.claw_channel_id,
+            providerId: args.provider_id,
             model: args.model,
             reasoningEffort: args.reasoning_effort,
             mode: args.mode,
@@ -151,16 +155,18 @@ export async function runClawScheduleMcpServerFromArgv(argv: string[]): Promise<
   const registerUpdateTool = (name: string): void => {
     server.registerTool(name, {
       description: name.startsWith('claw_')
-        ? 'Legacy alias. Update an existing DeepSeek GUI scheduled task.'
-        : 'Update an existing DeepSeek GUI scheduled task.',
+        ? 'Legacy alias. Update an existing Kun scheduled task.'
+        : 'Update an existing Kun scheduled task.',
       inputSchema: {
         task_id: z.string().min(1).describe('Task id returned by gui_schedule_list or gui_schedule_create'),
         title: z.string().optional(),
         prompt: z.string().optional(),
         enabled: z.boolean().optional(),
         workspace_root: z.string().optional(),
+        claw_channel_id: z.string().optional(),
+        provider_id: z.string().optional(),
         model: z.string().optional(),
-        reasoning_effort: z.enum(['off', 'low', 'medium', 'high', 'max']).optional(),
+        reasoning_effort: z.enum(['auto', 'off', 'low', 'medium', 'high', 'max']).optional(),
         mode: z.enum(['agent', 'plan']).optional(),
         schedule_kind: z.enum(['manual', 'at', 'daily', 'interval']).optional(),
         at_time: z.string().optional(),
@@ -174,6 +180,8 @@ export async function runClawScheduleMcpServerFromArgv(argv: string[]): Promise<
         if (args.prompt !== undefined) patch.prompt = args.prompt
         if (args.enabled !== undefined) patch.enabled = args.enabled
         if (args.workspace_root !== undefined) patch.workspaceRoot = args.workspace_root
+        if (args.claw_channel_id !== undefined) patch.clawChannelId = args.claw_channel_id
+        if (args.provider_id !== undefined) patch.providerId = args.provider_id
         if (args.model !== undefined) patch.model = args.model
         if (args.reasoning_effort !== undefined) patch.reasoningEffort = args.reasoning_effort
         if (args.mode !== undefined) patch.mode = args.mode
@@ -210,8 +218,8 @@ export async function runClawScheduleMcpServerFromArgv(argv: string[]): Promise<
   const registerDeleteTool = (name: string): void => {
     server.registerTool(name, {
       description: name.startsWith('claw_')
-        ? 'Legacy alias. Delete a scheduled task from DeepSeek GUI.'
-        : 'Delete a scheduled task from DeepSeek GUI.',
+        ? 'Legacy alias. Delete a scheduled task from Kun.'
+        : 'Delete a scheduled task from Kun.',
       inputSchema: {
         task_id: z.string().min(1).describe('Task id returned by gui_schedule_list or gui_schedule_create')
       }
diff --git a/src/main/claw-scheduled-task-detector.ts b/src/main/claw-scheduled-task-detector.ts
index dea61ed7..c4a40d16 100644
--- a/src/main/claw-scheduled-task-detector.ts
+++ b/src/main/claw-scheduled-task-detector.ts
@@ -1,4 +1,10 @@
-import type { AppSettingsV1, ModelEndpointFormat, ScheduleRunMode, ScheduledTaskV1 } from '../shared/app-settings'
+import type {
+  AppSettingsV1,
+  ModelEndpointFormat,
+  ScheduleReasoningEffort,
+  ScheduleRunMode,
+  ScheduledTaskV1
+} from '../shared/app-settings'
 import {
   DEFAULT_SCHEDULE_MODEL,
   DEFAULT_SCHEDULE_REASONING_EFFORT,
@@ -316,7 +322,9 @@ export async function detectClawScheduledTaskRequest(
 export function buildScheduledTaskFromDetectedRequest(options: {
   request: ParsedClawScheduledTaskRequest
   workspaceRoot: string
+  providerId?: string
   model: string
+  reasoningEffort?: ScheduleReasoningEffort
   mode: ScheduleRunMode
   id: string
   now?: string
@@ -328,8 +336,10 @@ export function buildScheduledTaskFromDetectedRequest(options: {
     enabled: true,
     prompt: options.request.taskPrompt,
     workspaceRoot: options.workspaceRoot.trim(),
+    clawChannelId: '',
+    providerId: options.providerId?.trim() ?? '',
     model: options.model.trim() || DEFAULT_SCHEDULE_MODEL,
-    reasoningEffort: DEFAULT_SCHEDULE_REASONING_EFFORT,
+    reasoningEffort: options.reasoningEffort ?? DEFAULT_SCHEDULE_REASONING_EFFORT,
     mode: options.mode,
     schedule: {
       kind: 'at',
diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts
index 338d46c9..f6cb5744 100644
--- a/src/main/gui-updater.test.ts
+++ b/src/main/gui-updater.test.ts
@@ -15,6 +15,7 @@ type MockUpdater = EventEmitter & {
 
 let updater: MockUpdater
 let nativeUpdater: EventEmitter
+let originalEnv: NodeJS.ProcessEnv
 
 function createUpdater(): MockUpdater {
   return Object.assign(new EventEmitter(), {
@@ -31,6 +32,7 @@ function createUpdater(): MockUpdater {
 }
 
 beforeEach(() => {
+  originalEnv = { ...process.env }
   vi.useFakeTimers()
   vi.resetModules()
   updater = createUpdater()
@@ -52,13 +54,126 @@ beforeEach(() => {
 })
 
 afterEach(() => {
+  process.env = originalEnv
   vi.clearAllTimers()
   vi.useRealTimers()
+  vi.unstubAllGlobals()
   vi.doUnmock('electron')
   vi.doUnmock('electron-updater')
   vi.resetModules()
 })
 
+function platformManifestName(): string {
+  if (process.platform === 'darwin') return 'latest-mac.yml'
+  if (process.platform === 'linux') return 'latest-linux.yml'
+  return 'latest.yml'
+}
+
+describe('checkGuiUpdate feed URL', () => {
+  it('prefers the kun-agent update feed when metadata is reachable', async () => {
+    process.env.DEEPSEEK_GUI_ALLOW_UNSIGNED_UPDATES = '1'
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true })
+    vi.stubGlobal('fetch', fetchMock)
+    updater.checkForUpdates.mockResolvedValue({
+      updateInfo: { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' },
+      isUpdateAvailable: true
+    })
+
+    const module = await import('./gui-updater')
+    module.initializeGuiUpdater(() => null, () => 'stable')
+
+    await expect(module.checkGuiUpdate('stable')).resolves.toMatchObject({
+      ok: true,
+      latestVersion: '0.2.0',
+      hasUpdate: true
+    })
+    expect(fetchMock).toHaveBeenCalledWith(
+      `https://www.kun-agent.com/api/r2/deepseek-gui/channels/stable/latest/${platformManifestName()}`,
+      expect.objectContaining({ method: 'HEAD' })
+    )
+    expect(updater.setFeedURL).toHaveBeenLastCalledWith({
+      provider: 'generic',
+      url: 'https://www.kun-agent.com/api/r2/deepseek-gui/channels/stable/latest/'
+    })
+  })
+
+  it('falls back to the bare kun-agent feed before the legacy feed', async () => {
+    process.env.DEEPSEEK_GUI_ALLOW_UNSIGNED_UPDATES = '1'
+    const fetchMock = vi.fn()
+      .mockResolvedValueOnce({ ok: false, status: 404 })
+      .mockResolvedValueOnce({ ok: true })
+    vi.stubGlobal('fetch', fetchMock)
+    updater.checkForUpdates.mockResolvedValue({
+      updateInfo: { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' },
+      isUpdateAvailable: true
+    })
+
+    const module = await import('./gui-updater')
+    module.initializeGuiUpdater(() => null, () => 'stable')
+
+    await expect(module.checkGuiUpdate('stable')).resolves.toMatchObject({
+      ok: true,
+      latestVersion: '0.2.0',
+      hasUpdate: true
+    })
+    expect(fetchMock).toHaveBeenNthCalledWith(
+      1,
+      `https://www.kun-agent.com/api/r2/deepseek-gui/channels/stable/latest/${platformManifestName()}`,
+      expect.objectContaining({ method: 'HEAD' })
+    )
+    expect(fetchMock).toHaveBeenNthCalledWith(
+      2,
+      `https://kun-agent.com/api/r2/deepseek-gui/channels/stable/latest/${platformManifestName()}`,
+      expect.objectContaining({ method: 'HEAD' })
+    )
+    expect(updater.setFeedURL).toHaveBeenLastCalledWith({
+      provider: 'generic',
+      url: 'https://kun-agent.com/api/r2/deepseek-gui/channels/stable/latest/'
+    })
+  })
+
+  it('falls back to the legacy deepseek-gui feed when both kun-agent feeds are unavailable', async () => {
+    process.env.DEEPSEEK_GUI_ALLOW_UNSIGNED_UPDATES = '1'
+    const fetchMock = vi.fn()
+      .mockResolvedValueOnce({ ok: false, status: 404 })
+      .mockResolvedValueOnce({ ok: false, status: 404 })
+      .mockResolvedValueOnce({ ok: true })
+    vi.stubGlobal('fetch', fetchMock)
+    updater.checkForUpdates.mockResolvedValue({
+      updateInfo: { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' },
+      isUpdateAvailable: true
+    })
+
+    const module = await import('./gui-updater')
+    module.initializeGuiUpdater(() => null, () => 'stable')
+
+    await expect(module.checkGuiUpdate('stable')).resolves.toMatchObject({
+      ok: true,
+      latestVersion: '0.2.0',
+      hasUpdate: true
+    })
+    expect(fetchMock).toHaveBeenNthCalledWith(
+      1,
+      `https://www.kun-agent.com/api/r2/deepseek-gui/channels/stable/latest/${platformManifestName()}`,
+      expect.objectContaining({ method: 'HEAD' })
+    )
+    expect(fetchMock).toHaveBeenNthCalledWith(
+      2,
+      `https://kun-agent.com/api/r2/deepseek-gui/channels/stable/latest/${platformManifestName()}`,
+      expect.objectContaining({ method: 'HEAD' })
+    )
+    expect(fetchMock).toHaveBeenNthCalledWith(
+      3,
+      `https://deepseek-gui.com/api/r2/deepseek-gui/channels/stable/latest/${platformManifestName()}`,
+      expect.objectContaining({ method: 'HEAD' })
+    )
+    expect(updater.setFeedURL).toHaveBeenLastCalledWith({
+      provider: 'generic',
+      url: 'https://deepseek-gui.com/api/r2/deepseek-gui/channels/stable/latest/'
+    })
+  })
+})
+
 describe('installGuiUpdate', () => {
   it('waits for managed runtime cleanup before asking the updater to quit and install', async () => {
     const module = await import('./gui-updater')
diff --git a/src/main/gui-updater.ts b/src/main/gui-updater.ts
index a4ebd51d..b2115aec 100644
--- a/src/main/gui-updater.ts
+++ b/src/main/gui-updater.ts
@@ -15,10 +15,20 @@ import type {
 import { nextGuiUpdateCheckDelay } from '../shared/gui-update-schedule'
 import { DEFAULT_GUI_UPDATE_CHANNEL, normalizeGuiUpdateChannel } from '../shared/gui-update'
 
-const DEFAULT_R2_PUBLIC_BASE_URL = 'https://deepseek-gui.com/api/r2'
+// R2 prefix 保持旧值:线上还在运行的 DeepSeek GUI 老版本轮询的
+// 就是 `deepseek-gui/channels//latest/`,prefix 一改老客户端
+// 就再也收不到 Kun 的升级包。域名优先使用 kun-agent,旧域名仅作兜底。
+const PRIMARY_R2_PUBLIC_BASE_URL = 'https://www.kun-agent.com/api/r2'
+const SECONDARY_R2_PUBLIC_BASE_URL = 'https://kun-agent.com/api/r2'
+const LEGACY_R2_PUBLIC_BASE_URL = 'https://deepseek-gui.com/api/r2'
 const DEFAULT_R2_RELEASE_PREFIX = 'deepseek-gui'
+const UPDATE_FEED_PROBE_TIMEOUT_MS = 5_000
 const { autoUpdater } = electronUpdater
 
+function envWithLegacyFallback(kunName: string, legacyName: string): string {
+  return process.env[kunName]?.trim() || process.env[legacyName]?.trim() || ''
+}
+
 let initialized = false
 let getMainWindow: (() => BrowserWindow | null) | null = null
 let lastInfo: Extract | null = null
@@ -26,7 +36,7 @@ let lastState: GuiUpdateState = { status: 'idle' }
 let downloaded = false
 let downloadPromise: Promise | null = null
 let configuredChannel: GuiUpdateChannel = normalizeGuiUpdateChannel(
-  process.env.DEEPSEEK_GUI_UPDATE_CHANNEL?.trim()
+  envWithLegacyFallback('KUN_UPDATE_CHANNEL', 'DEEPSEEK_GUI_UPDATE_CHANNEL') || undefined
 )
 let configuredFeedUrl = ''
 let getSelectedChannel: (() => GuiUpdateChannel | Promise) | null = null
@@ -52,18 +62,70 @@ function joinUrl(base: string, ...parts: string[]): string {
 }
 
 function envUpdateUrl(channel: GuiUpdateChannel): string {
-  const channelSpecific = process.env[`DEEPSEEK_GUI_UPDATE_URL_${channel.toUpperCase()}`]?.trim()
-  const direct = channelSpecific || process.env.DEEPSEEK_GUI_UPDATE_URL?.trim() || ''
+  const channelSpecific = envWithLegacyFallback(
+    `KUN_UPDATE_URL_${channel.toUpperCase()}`,
+    `DEEPSEEK_GUI_UPDATE_URL_${channel.toUpperCase()}`
+  )
+  const direct = channelSpecific || envWithLegacyFallback('KUN_UPDATE_URL', 'DEEPSEEK_GUI_UPDATE_URL')
   return direct ? direct.replace(/\{channel\}/g, channel).replace(/\/?$/, '/') : ''
 }
 
-function updateFeedUrl(channel: GuiUpdateChannel): string {
+function uniqueStrings(values: string[]): string[] {
+  return Array.from(new Set(values.filter(Boolean)))
+}
+
+function defaultR2BaseUrls(): string[] {
+  const configured = process.env.R2_PUBLIC_BASE_URL?.trim()
+  if (configured) return [configured]
+  return [PRIMARY_R2_PUBLIC_BASE_URL, SECONDARY_R2_PUBLIC_BASE_URL, LEGACY_R2_PUBLIC_BASE_URL]
+}
+
+function updateFeedUrlCandidates(channel: GuiUpdateChannel): string[] {
   const direct = envUpdateUrl(channel)
-  if (direct) return direct
+  if (direct) return [direct]
 
-  const base = process.env.R2_PUBLIC_BASE_URL?.trim() || DEFAULT_R2_PUBLIC_BASE_URL
   const prefix = process.env.R2_RELEASE_PREFIX?.trim() || DEFAULT_R2_RELEASE_PREFIX
-  return `${joinUrl(base, prefix, 'channels', channel, 'latest')}/`
+  return uniqueStrings(
+    defaultR2BaseUrls().map((base) => `${joinUrl(base, prefix, 'channels', channel, 'latest')}/`)
+  )
+}
+
+function updateFeedUrl(channel: GuiUpdateChannel): string {
+  return updateFeedUrlCandidates(channel)[0]
+}
+
+function updateFeedManifestUrl(feedUrl: string): string {
+  return `${feedUrl}${platformManifestName()}`
+}
+
+async function isUpdateFeedAccessible(feedUrl: string): Promise {
+  const controller = new AbortController()
+  const timeout = setTimeout(() => controller.abort(), UPDATE_FEED_PROBE_TIMEOUT_MS)
+  try {
+    const res = await fetch(updateFeedManifestUrl(feedUrl), {
+      method: 'HEAD',
+      headers: {
+        Accept: 'application/x-yaml,text/yaml,text/plain,*/*',
+        'User-Agent': `kun/${app.getVersion()}`
+      },
+      signal: controller.signal
+    })
+    return res.ok
+  } catch {
+    return false
+  } finally {
+    clearTimeout(timeout)
+  }
+}
+
+async function resolveUpdateFeedUrl(channel: GuiUpdateChannel): Promise {
+  const candidates = updateFeedUrlCandidates(channel)
+  if (candidates.length <= 1) return candidates[0]
+
+  for (const candidate of candidates) {
+    if (await isUpdateFeedAccessible(candidate)) return candidate
+  }
+  return candidates[candidates.length - 1]
 }
 
 function guiUpdateSchedulePath(): string {
@@ -134,7 +196,7 @@ function resolveGithubReleaseUrl(): string | null {
 }
 
 function downloadPageUrl(): string {
-  const direct = process.env.DEEPSEEK_GUI_DOWNLOAD_URL?.trim()
+  const direct = envWithLegacyFallback('KUN_DOWNLOAD_URL', 'DEEPSEEK_GUI_DOWNLOAD_URL')
   if (direct) return direct
 
   const pkg = readPackageJson()
@@ -307,7 +369,7 @@ async function runScheduledGuiUpdateCheck(): Promise {
       await writeLastScheduledCheckAt(nowMs)
       await checkGuiUpdate()
     } catch (error) {
-      console.warn('[deepseek-gui updater] scheduled GUI update check failed:', error)
+      console.warn('[kun-gui updater] scheduled GUI update check failed:', error)
     } finally {
       backgroundCheckPromise = null
       void scheduleNextBackgroundCheck()
@@ -324,9 +386,8 @@ async function resolveUpdateChannel(requested?: GuiUpdateChannel): Promise {
+  configureUpdaterChannel(channel, await resolveUpdateFeedUrl(channel))
+}
+
 export function setGuiUpdateChannel(channel: GuiUpdateChannel): void {
   configureUpdaterChannel(channel)
 }
@@ -349,11 +414,14 @@ async function checkManualUpdate(
 ): Promise {
   const currentVersion = app.getVersion()
   try {
-    const url = `${updateFeedUrl(channel)}${platformManifestName()}`
+    const feedUrl = configuredChannel === channel && configuredFeedUrl
+      ? configuredFeedUrl
+      : await resolveUpdateFeedUrl(channel)
+    const url = updateFeedManifestUrl(feedUrl)
     const res = await fetch(url, {
       headers: {
         Accept: 'application/x-yaml,text/yaml,text/plain,*/*',
-        'User-Agent': `deepseek-gui/${currentVersion}`
+        'User-Agent': `kun/${currentVersion}`
       }
     })
     if (!res.ok) {
@@ -423,9 +491,9 @@ export function initializeGuiUpdater(
   }
 
   autoUpdater.logger = {
-    info: (message?: unknown) => console.info('[deepseek-gui updater]', message),
-    warn: (message?: unknown) => console.warn('[deepseek-gui updater]', message),
-    error: (message?: unknown) => console.error('[deepseek-gui updater]', message)
+    info: (message?: unknown) => console.info('[kun-gui updater]', message),
+    warn: (message?: unknown) => console.warn('[kun-gui updater]', message),
+    error: (message?: unknown) => console.error('[kun-gui updater]', message)
   }
 
   autoUpdater.on('checking-for-update', () => {
@@ -464,7 +532,7 @@ export function initializeGuiUpdater(
 
   nativeAutoUpdater?.on?.('before-quit-for-update', () => {
     void runBeforeInstallUpdate().catch((error) => {
-      console.warn('[deepseek-gui updater] failed to stop runtimes before update quit:', error)
+      console.warn('[kun-gui updater] failed to stop runtimes before update quit:', error)
     })
   })
 
@@ -477,7 +545,7 @@ export function getGuiUpdateState(): GuiUpdateState {
 
 export async function checkGuiUpdate(channel?: GuiUpdateChannel): Promise {
   const selectedChannel = await resolveUpdateChannel(channel)
-  configureUpdaterChannel(selectedChannel)
+  await configureReachableUpdaterChannel(selectedChannel)
 
   if (!macAutoUpdateAllowed()) {
     return checkManualUpdate(selectedChannel, 'unsupported')
@@ -510,7 +578,7 @@ export async function checkGuiUpdate(channel?: GuiUpdateChannel): Promise {
   const selectedChannel = await resolveUpdateChannel(channel)
-  configureUpdaterChannel(selectedChannel)
+  await configureReachableUpdaterChannel(selectedChannel)
 
   if (!macAutoUpdateAllowed()) {
     return {
diff --git a/src/main/index.test.ts b/src/main/index.test.ts
index 30e8938d..07c1f9b2 100644
--- a/src/main/index.test.ts
+++ b/src/main/index.test.ts
@@ -30,7 +30,7 @@ type AppIconModule = typeof import('./app-icon')
 
 /**
  * electron-vite 的 main config 用 Rollup 处理资源,?url import 在 dev 和打包后
- * 都返回 *相对于 main bundle* 的路径(例如 'chunks/deepseek-XXXX.png')。
+ * 都返回 *相对于 main bundle* 的路径(例如 'chunks/kun-XXXX.png')。
  * main bundle 输出在 out/main/,所以运行时 __dirname = out/main/。
  * 因此 resolveAppIconPath 只需要做一件事:把相对路径 join 到 baseDir 上。
  * 这个 baseDir 在生产里是 __dirname,在测试里可以显式传入。
@@ -53,21 +53,21 @@ describe('app icon loader', () => {
 
   describe('resolveAppIconPath', () => {
     it('joins a relative source with the provided baseDir', () => {
-      const resolved = mod.resolveAppIconPath('chunks/deepseek-XXXX.png', '/app/bundle')
+      const resolved = mod.resolveAppIconPath('chunks/kun-XXXX.png', '/app/bundle')
       // 路径分隔符因平台而异(Windows 是 \,其它是 /),用 toMatch 避免硬编码
-      expect(resolved.replace(/\\/g, '/')).toBe('/app/bundle/chunks/deepseek-XXXX.png')
+      expect(resolved.replace(/\\/g, '/')).toBe('/app/bundle/chunks/kun-XXXX.png')
     })
 
     it('strips a leading slash before joining with baseDir (dev mode quirk)', () => {
-      // Vite ?url import 在 dev 模式下会返回 '/chunks/deepseek-XXXX.png'(带前导斜杠)。
+      // Vite ?url import 在 dev 模式下会返回 '/chunks/kun-XXXX.png'(带前导斜杠)。
       // 在 Windows 上 path.isAbsolute('/foo') === true,但实际文件并不在当前盘根下,
       // 而是在 main bundle 输出目录里 —— 必须把前导斜杠剥掉,当作相对路径 join。
-      const resolved = mod.resolveAppIconPath('/chunks/deepseek-XXXX.png', 'd:\\app\\bundle')
-      expect(resolved.replace(/\\/g, '/')).toBe('d:/app/bundle/chunks/deepseek-XXXX.png')
+      const resolved = mod.resolveAppIconPath('/chunks/kun-XXXX.png', 'd:\\app\\bundle')
+      expect(resolved.replace(/\\/g, '/')).toBe('d:/app/bundle/chunks/kun-XXXX.png')
     })
 
     it('passes an absolute source through unchanged', () => {
-      const absolute = 'C:\\Users\\me\\app.asar\\deepseek.png'
+      const absolute = 'C:\\Users\\me\\app.asar\\kun.png'
       expect(mod.resolveAppIconPath(absolute, '/ignored')).toBe(absolute)
     })
 
@@ -92,7 +92,7 @@ describe('app icon loader', () => {
       const pngBytes = Buffer.concat([PNG_MAGIC, Buffer.alloc(2048, 0xab)])
       fsMock.readFileSync.mockReturnValue(pngBytes)
 
-      const icon = mod.createAppIcon('chunks/deepseek-XXXX.png')
+      const icon = mod.createAppIcon('chunks/kun-XXXX.png')
 
       // 关键的反向断言:createFromPath 永远不应被调用 ——
       // 旧实现 (createFromPath) 既读不了 dev server URL,也读不了 asar,
@@ -100,7 +100,7 @@ describe('app icon loader', () => {
       expect(createFromPath).not.toHaveBeenCalled()
       expect(fsMock.readFileSync).toHaveBeenCalledTimes(1)
       const [calledPath] = fsMock.readFileSync.mock.calls[0] as [string]
-      expect(calledPath.replace(/\\/g, '/')).toContain('chunks/deepseek-XXXX.png')
+      expect(calledPath.replace(/\\/g, '/')).toContain('chunks/kun-XXXX.png')
 
       expect(createFromBuffer).toHaveBeenCalledTimes(1)
       const buffer = createFromBuffer.mock.calls[0]?.[0] as Buffer
diff --git a/src/main/index.ts b/src/main/index.ts
index 83beff73..b2e0ed64 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,16 +1,19 @@
 import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, powerSaveBlocker, Tray } from 'electron'
 import { existsSync } from 'node:fs'
+import { homedir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import {
   JsonSettingsStore,
   devServerHintUrl
 } from './settings-store'
-import deepseekLogoPng from '../asset/img/deepseek.png?url'
-import deepseekTrayPng from '../asset/img/deepseek_gui_tray.png?url'
+import kunLogoPng from '../asset/img/kun.png?url'
+import kunMacLogoPng from '../asset/img/kun_mac.png?url'
+import kunTrayPng from '../asset/img/kun_tray.png?url'
 import { createAppIcon, pickTrayIcon } from './app-icon'
 import { configureLinuxWaylandImeSwitches } from './app-command-line'
 import { configureAppIdentity } from './app-identity'
+import { runLegacyKunDataMigration } from './legacy-data-migration'
 import {
   applyKunRuntimePatch,
   kunSettingsEnvelope,
@@ -32,6 +35,7 @@ import {
 import { parseRuntimeErrorBody, runtimeErrorToError, type RuntimeErrorCode } from '../shared/runtime-error'
 import type { GuiUpdateState } from '../shared/gui-update'
 import { isAllowedDevPreviewUrl } from '../shared/dev-preview-url'
+import { isAuthorizedPrototypeFileUrl } from './services/prototype-embed-registry'
 import { fetchUpstreamModelIds } from './upstream-models'
 import {
   kunRuntimeAdapter,
@@ -40,6 +44,8 @@ import {
   runtimeRequestViaHost
 } from './runtime/kun-adapter'
 import { waitForRuntimeTurnsIdle } from './runtime/managed-runtime-idle'
+import { setKunUnexpectedExitHandler, type KunUnexpectedExitInfo } from './kun-process'
+import { RestartBudget, type KunRuntimeStatus } from './kun-runtime-supervisor'
 import { configureLogger, logError, logWarn, pruneOnStartup } from './logger'
 import { createClawRuntime, type ClawRuntime } from './claw-runtime'
 import { createScheduleRuntime, type ScheduleRuntime } from './schedule-runtime'
@@ -62,6 +68,7 @@ import { registerRuntimeSseIpc } from './runtime-sse-ipc'
 import {
   configureWeixinBridgeRuntimeContextProvider,
   ensureWeixinBridgeRpcUrl,
+  getWeixinBridgeAccountUserId,
   sendWeixinBridgeMessage,
   stopWeixinBridgeRuntime
 } from './weixin-bridge-runtime'
@@ -69,9 +76,13 @@ import { webhookUrl } from './claw-runtime-helpers'
 import { isKunHealthResponseBody } from './kun-health'
 
 const __dirname = dirname(fileURLToPath(import.meta.url))
+// 品牌升级为 Kun 后仍保留旧 AppUserModelId:它必须和 electron-builder
+// 的 appId 一致才能让 Windows 通知 / 任务栏分组在升级前后连续,而
+// appId 因为 NSIS 升级 GUID 与 macOS 更新签名校验的原因永远不改。
 const APP_USER_MODEL_ID = 'com.xingyuzhong.deepseekgui'
 const HIDDEN_START_ARG = '--hidden'
-const startupTraceEnabled = process.env.DEEPSEEK_GUI_STARTUP_TRACE === '1'
+const startupTraceEnabled =
+  process.env.KUN_STARTUP_TRACE === '1' || process.env.DEEPSEEK_GUI_STARTUP_TRACE === '1'
 const startupTraceStart = Date.now()
 
 function traceStartup(label: string, detail?: unknown): void {
@@ -154,6 +165,25 @@ if (runningClawScheduleMcpServer && process.platform === 'darwin') {
 // 抽到 app-identity.ts 是为了让测试可以直接 import,不被 main 的
 // whenReady 副作用污染。
 configureAppIdentity()
+
+// 紧跟在身份设置之后、requestSingleInstanceLock() 之前做旧数据迁移:
+// 单实例锁文件就放在 userData 里,必须先把目录定下来。rename 失败
+// (典型场景:老版本还在运行)时退回旧目录,功能不受影响,下次再迁。
+const legacyMigration = runLegacyKunDataMigration({
+  userDataPath: app.getPath('userData'),
+  homeDir: homedir(),
+  log: (message, detail) => console.warn(`[kun-gui] ${message}`, detail ?? '')
+})
+if (legacyMigration.userData.usedLegacyFallback) {
+  app.setPath('userData', legacyMigration.userData.userDataPath)
+}
+traceStartup('legacy data migration checked', {
+  userDataPath: legacyMigration.userData.userDataPath,
+  migratedUserData: legacyMigration.userData.migrated,
+  usedLegacyFallback: legacyMigration.userData.usedLegacyFallback,
+  settingsRewritten: legacyMigration.settingsRewritten
+})
+
 configureLinuxWaylandImeSwitches()
 
 if (!runningClawScheduleMcpServer && process.platform === 'win32') {
@@ -242,7 +272,9 @@ function installDevPreviewWebviewGuards(): void {
   app.on('web-contents-created', (_, contents) => {
     contents.on('will-attach-webview', (event, webPreferences, params) => {
       const src = typeof params.src === 'string' ? params.src : ''
-      if (!isAllowedDevPreviewUrl(src)) {
+      // Prototype embeds are file:// pages the renderer authorized through
+      // write:authorize-prototype right before attaching.
+      if (!isAllowedDevPreviewUrl(src) && !isAuthorizedPrototypeFileUrl(src)) {
         event.preventDefault()
         return
       }
@@ -269,9 +301,9 @@ function installDevPreviewWebviewGuards(): void {
 }
 
 
-const appIcon = createAppIcon(deepseekLogoPng)
-const trayIcon = createAppIcon(deepseekTrayPng)
-traceStartup('app icon loaded', { source: deepseekLogoPng.startsWith('data:') ? 'data-url' : 'path' })
+const appIcon = createAppIcon(kunLogoPng)
+const trayIcon = createAppIcon(kunTrayPng)
+traceStartup('app icon loaded', { source: kunLogoPng.startsWith('data:') ? 'data-url' : 'path' })
 const gotSingleInstanceLock = runningClawScheduleMcpServer || app.requestSingleInstanceLock()
 traceStartup('single instance lock checked', {
   gotSingleInstanceLock,
@@ -281,15 +313,15 @@ traceStartup('single instance lock checked', {
 function trayLabels(locale: AppSettingsV1['locale']): { show: string; quit: string; tooltip: string } {
   if (locale === 'zh') {
     return {
-      show: '显示 DeepSeek GUI',
+      show: '显示 Kun',
       quit: '退出',
-      tooltip: 'DeepSeek GUI'
+      tooltip: 'Kun'
     }
   }
   return {
-    show: 'Show DeepSeek GUI',
+    show: 'Show Kun',
     quit: 'Quit',
-    tooltip: 'DeepSeek GUI'
+    tooltip: 'Kun'
   }
 }
 
@@ -315,7 +347,7 @@ function syncLoginItemSettings(settings: AppSettingsV1): void {
     })
   } catch (error) {
     const message = error instanceof Error ? error.message : String(error)
-    console.warn('[deepseek-gui] failed to update login item settings:', error)
+    console.warn('[kun-gui] failed to update login item settings:', error)
     logWarn('desktop-behavior', 'Failed to update login item settings.', { message })
   }
 }
@@ -388,7 +420,7 @@ async function showTurnCompleteNotification(
     return { ok: true, shown: false, reason: 'unsupported' }
   }
 
-  const title = normalizeNotificationText(payload.title, 'DeepSeek GUI', 80)
+  const title = normalizeNotificationText(payload.title, 'Kun', 80)
   const body = normalizeNotificationText(payload.body, 'Conversation complete.', 180)
 
   try {
@@ -488,11 +520,182 @@ async function sleepWithAbort(ms: number, signal: AbortSignal): Promise {
   })
 }
 
-let runtimeEnsurePromise: Promise | null = null
+let runtimeEnsurePromise: Promise | null = null
 let runtimeEnsureFingerprint: string | null = null
+let runtimeRestartPromise: Promise | null = null
 let runtimeSettingsApplyPromise: Promise | null = null
 let lastAppliedSettings: AppSettingsV1 | null = null
 
+const RUNTIME_WATCHDOG_INTERVAL_MS = 30_000
+const RUNTIME_WATCHDOG_FAILURE_THRESHOLD = 3
+const runtimeRestartBudget = new RestartBudget({ windowMs: 60_000, maxRestarts: 3 })
+let lastRuntimeStatus: KunRuntimeStatus | null = null
+let supervisedRestartInFlight = false
+let runtimeWatchdogTimer: NodeJS.Timeout | null = null
+let runtimeWatchdogFailures = 0
+let runtimeWatchdogTickInFlight = false
+
+function publishRuntimeStatus(status: Omit): void {
+  const full: KunRuntimeStatus = { ...status, at: new Date().toISOString() }
+  lastRuntimeStatus = full
+  logWarn('runtime-status', `${full.state} (${full.source})${full.message ? `: ${full.message}` : ''}`)
+  for (const win of BrowserWindow.getAllWindows()) {
+    if (!win.isDestroyed()) win.webContents.send('runtime:status', full)
+  }
+}
+
+/** Record a healthy runtime: reset the crash budget and watchdog, announce recovery. */
+function noteRuntimeHealthy(source: string): void {
+  runtimeRestartBudget.reset()
+  runtimeWatchdogFailures = 0
+  startRuntimeWatchdog()
+  if (lastRuntimeStatus && lastRuntimeStatus.state !== 'running') {
+    publishRuntimeStatus({ state: 'running', source })
+  }
+}
+
+function handleUnexpectedKunExit(info: KunUnexpectedExitInfo): void {
+  void superviseKunCrash(info).catch((error: unknown) => {
+    logError('kun-supervisor', 'supervised restart crashed', {
+      message: error instanceof Error ? error.message : String(error)
+    })
+  })
+}
+
+async function superviseKunCrash(info: KunUnexpectedExitInfo): Promise {
+  if (managedRuntimesStoppedForQuit || isQuitting) return
+  const exitLabel = info.signal ? `signal ${info.signal}` : `code ${info.code ?? 'unknown'}`
+  publishRuntimeStatus({
+    state: 'crashed',
+    source: 'supervisor',
+    message: `Kun exited unexpectedly (${exitLabel}).`,
+    stderrTail: info.stderrTail
+  })
+  if (supervisedRestartInFlight) return
+  supervisedRestartInFlight = true
+  try {
+    const settings = await store.load()
+    const runtime = getKunRuntimeSettings(settings)
+    if (!resolveConfiguredApiKey(settings) || !runtime.autoStart) {
+      publishRuntimeStatus({
+        state: 'stopped',
+        source: 'supervisor',
+        message: 'Kun exited and automatic restart is unavailable (missing API key or auto-start disabled).'
+      })
+      return
+    }
+    let lastError = ''
+    for (;;) {
+      if (managedRuntimesStoppedForQuit || isQuitting) return
+      const verdict = runtimeRestartBudget.note()
+      if (!verdict.allowed) {
+        publishRuntimeStatus({
+          state: 'failed',
+          source: 'supervisor',
+          message: lastError
+            ? `Kun keeps crashing; automatic restarts are paused. Last error: ${lastError}`
+            : 'Kun keeps crashing; automatic restarts are paused. Check the runtime logs, then retry.',
+          stderrTail: info.stderrTail
+        })
+        return
+      }
+      publishRuntimeStatus({
+        state: 'restarting',
+        source: 'supervisor',
+        attempt: verdict.attempt,
+        maxAttempts: 3,
+        message: `Restarting Kun automatically (attempt ${verdict.attempt}/3).`
+      })
+      await new Promise((resolve) => setTimeout(resolve, verdict.delayMs))
+      try {
+        await ensureRuntime(await store.load())
+        noteRuntimeHealthy('supervisor')
+        return
+      } catch (error) {
+        lastError = error instanceof Error ? error.message : String(error)
+        logWarn('kun-supervisor', `automatic restart attempt ${verdict.attempt} failed: ${lastError}`)
+      }
+    }
+  } finally {
+    supervisedRestartInFlight = false
+  }
+}
+
+function startRuntimeWatchdog(): void {
+  if (runtimeWatchdogTimer) return
+  const timer = setInterval(() => {
+    void runtimeWatchdogTick().catch((error: unknown) => {
+      logWarn('kun-watchdog', 'watchdog tick failed', {
+        message: error instanceof Error ? error.message : String(error)
+      })
+    })
+  }, RUNTIME_WATCHDOG_INTERVAL_MS)
+  timer.unref()
+  runtimeWatchdogTimer = timer
+}
+
+function stopRuntimeWatchdog(): void {
+  if (runtimeWatchdogTimer) {
+    clearInterval(runtimeWatchdogTimer)
+    runtimeWatchdogTimer = null
+  }
+}
+
+/**
+ * Post-startup liveness check for the GUI-managed kun child: the boot
+ * probe only covers launch, so a runtime that hangs later (blocked
+ * event loop, sqlite lock) would otherwise stay dead until the user
+ * restarts the app.
+ */
+async function runtimeWatchdogTick(): Promise {
+  if (runtimeWatchdogTickInFlight) return
+  if (managedRuntimesStoppedForQuit || isQuitting) return
+  if (
+    supervisedRestartInFlight ||
+    runtimeRestartPromise ||
+    runtimeSettingsApplyPromise ||
+    runtimeEnsurePromise
+  ) {
+    return
+  }
+  if (!kunRuntimeAdapter.isChildRunning()) return
+  runtimeWatchdogTickInFlight = true
+  try {
+    const settings = await store.load()
+    const healthy = await waitForKunHealth(settings, 5_000)
+    if (healthy) {
+      runtimeWatchdogFailures = 0
+      return
+    }
+    runtimeWatchdogFailures += 1
+    logWarn(
+      'kun-watchdog',
+      `health probe failed (${runtimeWatchdogFailures}/${RUNTIME_WATCHDOG_FAILURE_THRESHOLD})`
+    )
+    if (runtimeWatchdogFailures < RUNTIME_WATCHDOG_FAILURE_THRESHOLD) return
+    runtimeWatchdogFailures = 0
+    publishRuntimeStatus({
+      state: 'restarting',
+      source: 'watchdog',
+      message: 'Kun stopped responding to health checks; restarting it.'
+    })
+    try {
+      await restartRuntime(settings)
+      noteRuntimeHealthy('watchdog')
+    } catch (error) {
+      publishRuntimeStatus({
+        state: 'failed',
+        source: 'watchdog',
+        message: `Kun is unresponsive and the automatic restart failed: ${
+          error instanceof Error ? error.message : String(error)
+        }`
+      })
+    }
+  } finally {
+    runtimeWatchdogTickInFlight = false
+  }
+}
+
 function queueRuntimeSettingsApply(prev: AppSettingsV1, next: AppSettingsV1): void {
   // Always update the prev/next anchor so a later task diffs against
   // the settings that were actually applied last, not against the
@@ -563,47 +766,80 @@ function runtimeFingerprint(settings: AppSettingsV1): string {
   return stableSettingsStringify(resolveKunRuntimeSettings(settings))
 }
 
-async function ensureRuntime(settings: AppSettingsV1): Promise {
+async function ensureRuntime(settings: AppSettingsV1): Promise {
+  const restart = runtimeRestartPromise
+  if (restart) {
+    try {
+      await restart
+      return store.load()
+    } catch {
+      /* fall through to a normal ensure so callers see the latest state */
+    }
+  }
   const fingerprint = runtimeFingerprint(settings)
   const pending = runtimeEnsurePromise
+  const pendingFingerprint = runtimeEnsureFingerprint
   if (pending) {
     // Wait for the in-flight ensure, then re-evaluate against the
     // fingerprint so callers don't inherit a stale result.
     try {
-      await pending
+      const ensuredSettings = await pending
+      if (pendingFingerprint === fingerprint) return ensuredSettings
     } catch {
       /* fall through to retry with the current settings */
     }
-    if (runtimeEnsureFingerprint === fingerprint) return
   }
   const task = ensureRuntimeOnce(settings)
-  runtimeEnsurePromise = task.finally(() => {
-    if (runtimeEnsurePromise === task) {
+  let trackedTask: Promise
+  trackedTask = task.finally(() => {
+    if (runtimeEnsurePromise === trackedTask) {
       runtimeEnsurePromise = null
       runtimeEnsureFingerprint = null
     }
   })
+  runtimeEnsurePromise = trackedTask
   runtimeEnsureFingerprint = fingerprint
   try {
-    return await task
+    return await trackedTask
   } finally {
     /* cleanup runs via the .finally above */
   }
 }
 
-async function ensureRuntimeOnce(settings: AppSettingsV1): Promise {
+async function ensureRuntimeOnce(settings: AppSettingsV1): Promise {
   await waitForQueuedRuntimeSettingsApply()
-  await ensureKunRuntime(settings)
+  return ensureKunRuntime(settings)
 }
 
-async function ensureKunRuntime(settings: AppSettingsV1): Promise {
+async function resolveManagedKunLaunchSettings(
+  settings: AppSettingsV1,
+  source: string
+): Promise {
+  const runtime = getKunRuntimeSettings(settings)
+  const resolved = await kunRuntimeAdapter.resolveAvailablePort(runtime.port)
+  if (!resolved.changed) return settings
+
+  const next = await store.patch({ agents: { kun: { port: resolved.port } } })
+  lastAppliedSettings = next
+  logWarn(source, `Kun port ${runtime.port} is unavailable; using ${resolved.port} for the managed runtime`, {
+    previousPort: runtime.port,
+    port: resolved.port,
+    message: resolved.message
+  })
+  return next
+}
+
+async function ensureKunRuntime(settings: AppSettingsV1): Promise {
   const runtime = getKunRuntimeSettings(settings)
   const hasApiKey = Boolean(resolveConfiguredApiKey(settings))
 
   const healthy = await waitForKunHealth(settings, 2_000)
   if (healthy) {
     const threadApi = await probeThreadApi(settings)
-    if (threadApi.ok) return
+    if (threadApi.ok) {
+      noteRuntimeHealthy('ensure')
+      return settings
+    }
     throw runtimeJsonError(threadApi.error, threadApi.message)
   }
 
@@ -620,18 +856,15 @@ async function ensureKunRuntime(settings: AppSettingsV1): Promise {
     )
   }
 
+  const launchSettings = await resolveManagedKunLaunchSettings(settings, 'runtime-start')
   const adapter = kunRuntimeAdapter
-  const reclaim = await adapter.reclaimPort(runtime.port)
-  if (!reclaim.ok) {
-    throw runtimeJsonError('runtime_port_conflict', reclaim.message)
-  }
   try {
-    await adapter.ensureRunning(settings)
+    await adapter.ensureRunning(launchSettings)
   } catch (e) {
-    console.error('[deepseek-gui] failed to start kun:', e)
+    console.error('[kun-gui] failed to start kun:', e)
     throw e
   }
-  const started = await waitForKunHealth(settings, 20_000)
+  const started = await waitForKunHealth(launchSettings, 20_000)
   if (!started) {
     throw runtimeJsonError(
       'runtime_unhealthy',
@@ -639,10 +872,69 @@ async function ensureKunRuntime(settings: AppSettingsV1): Promise {
     )
   }
 
-  const threadApi = await probeThreadApi(settings)
+  const threadApi = await probeThreadApi(launchSettings)
+  if (!threadApi.ok) {
+    throw runtimeJsonError(threadApi.error, threadApi.message)
+  }
+  noteRuntimeHealthy('ensure')
+  return launchSettings
+}
+
+async function restartRuntime(settings: AppSettingsV1): Promise {
+  if (runtimeRestartPromise) return runtimeRestartPromise
+  const task = restartRuntimeOnce(settings)
+    .finally(() => {
+      if (runtimeRestartPromise === task) {
+        runtimeRestartPromise = null
+      }
+    })
+  runtimeRestartPromise = task
+  runtimeEnsurePromise = null
+  runtimeEnsureFingerprint = null
+  return task
+}
+
+async function restartRuntimeOnce(settings: AppSettingsV1): Promise {
+  await waitForQueuedRuntimeSettingsApply()
+  const runtime = getKunRuntimeSettings(settings)
+
+  if (!resolveConfiguredApiKey(settings)) {
+    throw runtimeJsonError(
+      'missing_api_key',
+      'DeepSeek API Key is required before the GUI can start Kun.'
+    )
+  }
+  if (!runtime.autoStart) {
+    throw runtimeJsonError(
+      'runtime_offline',
+      'Kun is offline. Enable automatic startup in Settings, or start `kun serve` manually.'
+    )
+  }
+
+  const adapter = kunRuntimeAdapter
+  await adapter.stopAndWait()
+  const launchSettings = await resolveManagedKunLaunchSettings(settings, 'runtime-restart')
+
+  try {
+    await adapter.ensureRunning(launchSettings)
+  } catch (e) {
+    console.error('[kun-gui] failed to restart kun:', e)
+    throw e
+  }
+
+  const healthy = await waitForKunHealth(launchSettings, 20_000)
+  if (!healthy) {
+    throw runtimeJsonError(
+      'runtime_unhealthy',
+      'Kun did not become healthy after restart.'
+    )
+  }
+
+  const threadApi = await probeThreadApi(launchSettings)
   if (!threadApi.ok) {
     throw runtimeJsonError(threadApi.error, threadApi.message)
   }
+  noteRuntimeHealthy('restart')
 }
 
 function createWindow(options: { suppressInitialShow?: boolean } = {}): void {
@@ -672,7 +964,7 @@ function createWindow(options: { suppressInitialShow?: boolean } = {}): void {
   }
   mainWindow.webContents.on('preload-error', (_event, preloadPath, error) => {
     const message = error instanceof Error ? error.message : String(error)
-    console.error(`[deepseek-gui] failed to load preload ${preloadPath}:`, error)
+    console.error(`[kun-gui] failed to load preload ${preloadPath}:`, error)
     logError('preload', 'Failed to load preload script', { preloadPath, message })
   })
   const showWindow = (): void => {
@@ -701,6 +993,9 @@ function createWindow(options: { suppressInitialShow?: boolean } = {}): void {
   })
   mainWindow.webContents.once('did-finish-load', () => {
     traceStartup('window:did-finish-load')
+    if (lastRuntimeStatus && mainWindow && !mainWindow.isDestroyed()) {
+      mainWindow.webContents.send('runtime:status', lastRuntimeStatus)
+    }
     showWindow()
   })
   setTimeout(() => {
@@ -747,6 +1042,29 @@ function runtimeStartupConfigChanged(prev: AppSettingsV1, next: AppSettingsV1):
   return kunRuntimeConfigChanged(prev, next) || clawScheduleMcpSettingsChanged(prev, next)
 }
 
+/**
+ * Reject runtime-affecting values that would persist a config kun can
+ * never boot with. Runs before the settings patch is written to disk.
+ */
+function validateRuntimeSettingsForApply(next: AppSettingsV1): string | null {
+  const runtime = resolveKunRuntimeSettings(next)
+  if (!Number.isInteger(runtime.port) || runtime.port < 1 || runtime.port > 65_535) {
+    return `Kun port must be an integer between 1 and 65535 (got ${String(runtime.port)})`
+  }
+  const baseUrl = (runtime.baseUrl ?? '').trim()
+  if (baseUrl) {
+    try {
+      const parsed = new URL(baseUrl)
+      if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+        return `model base URL must use http(s): ${baseUrl}`
+      }
+    } catch {
+      return `model base URL is not a valid URL: ${baseUrl}`
+    }
+  }
+  return null
+}
+
 async function restartManagedRuntimeForSettingsChange(
   prev: AppSettingsV1,
   next: AppSettingsV1
@@ -758,20 +1076,89 @@ async function restartManagedRuntimeForSettingsChange(
   const wasRunning = adapter.isChildRunning()
 
   if (!wasRunning) return
-  if (wasRunning) {
-    await waitForManagedRuntimeReadyBeforeStop(prev, 'settings-apply')
-    await adapter.stopAndWait()
+  await waitForManagedRuntimeReadyBeforeStop(prev, 'settings-apply')
+  await adapter.stopAndWait()
+  if (!resolveConfiguredApiKey(next) || !runtime.autoStart) {
+    publishRuntimeStatus({
+      state: 'stopped',
+      source: 'settings-apply',
+      message: 'Kun was stopped: the new settings have no API key or auto-start is disabled.'
+    })
+    return
   }
-  if (!resolveConfiguredApiKey(next) || !runtime.autoStart) return
 
+  publishRuntimeStatus({ state: 'restarting', source: 'settings-apply' })
   try {
-    await adapter.ensureRunning(next)
-    const healthy = await waitForKunHealth(next, 20_000)
+    const launchSettings = await resolveManagedKunLaunchSettings(next, 'settings-apply')
+    await adapter.ensureRunning(launchSettings)
+    const healthy = await waitForKunHealth(launchSettings, 20_000)
     if (!healthy) {
-      console.warn('[deepseek-gui] Kun restart did not become healthy after settings change')
+      throw new Error('Kun did not become healthy after the settings change')
     }
+    noteRuntimeHealthy('settings-apply')
+    publishRuntimeStatus({ state: 'running', source: 'settings-apply' })
   } catch (e) {
-    console.warn('[deepseek-gui] Kun restart failed after settings change:', e)
+    const message = e instanceof Error ? e.message : String(e)
+    logWarn('settings-apply', `Kun restart failed after settings change: ${message}`)
+    await rollbackRuntimeSettingsAfterFailedApply(prev, message)
+  }
+}
+
+/**
+ * A settings change took the runtime down and the new config cannot
+ * boot. Restore the previous runtime/provider settings on disk (so the
+ * next app launch is not bricked either) and bring kun back up on the
+ * last-known-good configuration.
+ */
+async function rollbackRuntimeSettingsAfterFailedApply(
+  prev: AppSettingsV1,
+  failureMessage: string
+): Promise {
+  const adapter = kunRuntimeAdapter
+  let base: AppSettingsV1 = prev
+  try {
+    base = await store.patch({
+      agents: { kun: getKunRuntimeSettings(prev) },
+      provider: prev.provider
+    })
+    lastAppliedSettings = base
+  } catch (error) {
+    logWarn('settings-apply', 'failed to restore previous runtime settings on disk', {
+      message: error instanceof Error ? error.message : String(error)
+    })
+  }
+  if (!resolveConfiguredApiKey(base) || !getKunRuntimeSettings(base).autoStart) {
+    publishRuntimeStatus({
+      state: 'stopped',
+      source: 'settings-apply',
+      rolledBack: true,
+      message: `The new settings failed to apply (${failureMessage}); previous settings were restored but auto-start is unavailable.`
+    })
+    return
+  }
+  try {
+    const launchSettings = await resolveManagedKunLaunchSettings(base, 'settings-apply-rollback')
+    await adapter.ensureRunning(launchSettings)
+    const healthy = await waitForKunHealth(launchSettings, 20_000)
+    if (!healthy) {
+      throw new Error('previous configuration did not become healthy')
+    }
+    noteRuntimeHealthy('settings-apply-rollback')
+    publishRuntimeStatus({
+      state: 'running',
+      source: 'settings-apply',
+      rolledBack: true,
+      message: `The new settings failed to apply (${failureMessage}); Kun is running on the previous settings again.`
+    })
+  } catch (error) {
+    publishRuntimeStatus({
+      state: 'failed',
+      source: 'settings-apply',
+      rolledBack: true,
+      message: `The new settings failed to apply (${failureMessage}) and restoring the previous settings also failed: ${
+        error instanceof Error ? error.message : String(error)
+      }`
+    })
   }
 }
 
@@ -785,14 +1172,24 @@ async function restartManagedRuntimeForMcpConfigChange(settings: AppSettingsV1):
   await adapter.stopAndWait()
   if (!resolveConfiguredApiKey(settings) || !runtime.autoStart) return
 
+  publishRuntimeStatus({ state: 'restarting', source: 'mcp-config' })
   try {
-    await adapter.ensureRunning(settings)
-    const healthy = await waitForKunHealth(settings, 20_000)
+    const launchSettings = await resolveManagedKunLaunchSettings(settings, 'mcp-config')
+    await adapter.ensureRunning(launchSettings)
+    const healthy = await waitForKunHealth(launchSettings, 20_000)
     if (!healthy) {
-      console.warn('[deepseek-gui] Kun restart did not become healthy after MCP config change')
+      throw new Error('Kun did not become healthy after the MCP config change')
     }
+    noteRuntimeHealthy('mcp-config')
+    publishRuntimeStatus({ state: 'running', source: 'mcp-config' })
   } catch (e) {
-    console.warn('[deepseek-gui] Kun restart failed after MCP config change:', e)
+    const message = e instanceof Error ? e.message : String(e)
+    logWarn('mcp-config', `Kun restart failed after MCP config change: ${message}`)
+    publishRuntimeStatus({
+      state: 'failed',
+      source: 'mcp-config',
+      message: `Kun failed to restart after the MCP config change: ${message}. Check the MCP config file, then retry.`
+    })
   }
 }
 
@@ -845,14 +1242,16 @@ app.whenReady().then(async () => {
   installDevPreviewWebviewGuards()
   traceStartup('install webview guards:done')
 
-  if (process.platform === 'darwin' && !appIcon.isEmpty()) {
-    app.dock.setIcon(appIcon)
+  if (process.platform === 'darwin') {
+    const macDockIcon = createAppIcon(kunMacLogoPng)
+    app.dock.setIcon(macDockIcon.isEmpty() ? appIcon : macDockIcon)
   }
 
   store = new JsonSettingsStore(app.getPath('userData'))
   traceStartup('settings load:start')
   const initial = await store.load()
   traceStartup('settings load:done')
+  setKunUnexpectedExitHandler(handleUnexpectedKunExit)
   appBehavior = initial.appBehavior
   syncLoginItemSettings(initial)
   syncTray(initial)
@@ -875,6 +1274,7 @@ app.whenReady().then(async () => {
     logError,
     notifyChannelActivity: emitClawChannelActivity,
     sendWeixinBridgeMessage,
+    resolveWeixinAccountUserId: getWeixinBridgeAccountUserId,
     createScheduledTaskFromText: (text, options) =>
       scheduleRuntime?.createScheduledTaskFromText(text, options) ?? Promise.resolve({ kind: 'noop' })
   })
@@ -919,6 +1319,10 @@ app.whenReady().then(async () => {
     if (prev.log.enabled !== next.log.enabled || prev.log.retentionDays !== next.log.retentionDays) {
       configureLogger({ enabled: next.log.enabled, retentionDays: next.log.retentionDays })
     }
+    const runtimeValidationError = validateRuntimeSettingsForApply(next)
+    if (runtimeValidationError) {
+      throw new Error(`Invalid runtime settings: ${runtimeValidationError}`)
+    }
     const saved = await store.patch(partial)
     await syncClawScheduleMcpConfig(saved, getClawScheduleMcpLaunchConfig()).catch((error) => {
       console.error('[claw-schedule-mcp] failed to sync config after settings change:', error)
@@ -927,8 +1331,14 @@ app.whenReady().then(async () => {
       void guiUpdaterModulePromise.then((module) => module.setGuiUpdateChannel(saved.guiUpdate.channel))
     }
     queueRuntimeSettingsApply(prev, saved)
-    scheduleRuntime?.sync(saved)
-    clawRuntime?.sync(saved)
+    try {
+      scheduleRuntime?.sync(saved)
+      clawRuntime?.sync(saved)
+    } catch (error) {
+      logError('settings-apply', 'failed to sync schedule/claw runtimes after settings change', {
+        message: error instanceof Error ? error.message : String(error)
+      })
+    }
     syncWeixinBridgeRuntime(saved)
     syncLoginItemSettings(saved)
     syncTray(saved)
@@ -941,14 +1351,23 @@ app.whenReady().then(async () => {
     return fetchUpstreamModelIds(settings, key)
   }
 
+  const saveSettingsPatch = async (partial: AppSettingsPatch): Promise => {
+    return store.patch(partial)
+  }
+
   registerAppIpcHandlers({
     store,
     getMainWindow: () => mainWindow,
     applySettingsPatch,
+    saveSettingsPatch,
     runtimeRequest: async (path, method, body) => {
       const settings = await store.load()
       return runtimeRequest(settings, path, { method, body })
     },
+    restartRuntime: async () => {
+      const settings = await store.load()
+      await restartRuntime(settings)
+    },
     fetchUpstreamModels: fetchModels,
     getClawRuntime: () => clawRuntime,
     getScheduleRuntime: () => scheduleRuntime,
@@ -970,7 +1389,7 @@ app.whenReady().then(async () => {
   })
 
   void loadGuiUpdaterModule().catch((error) => {
-    console.warn('[deepseek-gui updater] failed to initialize on startup:', error)
+    console.warn('[kun-gui updater] failed to initialize on startup:', error)
   })
 
   registerRuntimeSseIpc({ ipcMain, store, ensureRuntime, logError })
@@ -980,13 +1399,13 @@ app.whenReady().then(async () => {
   traceStartup('createWindow:returned')
 
   void pruneOnStartup().catch((err) => {
-    console.warn('[deepseek-gui] prune logs:', err)
+    console.warn('[kun-gui] prune logs:', err)
   })
 
   if (resolveConfiguredApiKey(initial)) {
     setTimeout(() => {
       void kunRuntimeAdapter.resolveExecutable(initial).catch((err) => {
-        console.warn('[deepseek-gui] prewarm Kun binary:', err)
+        console.warn('[kun-gui] prewarm Kun binary:', err)
       })
     }, 1500)
   }
@@ -1001,15 +1420,15 @@ app.whenReady().then(async () => {
   })
 }).catch((error) => {
   const message = error instanceof Error ? error.message : String(error)
-  console.error('[deepseek-gui] startup failed:', error)
-  dialog.showErrorBox('DeepSeek GUI failed to start', message)
+  console.error('[kun-gui] startup failed:', error)
+  dialog.showErrorBox('Kun failed to start', message)
   app.quit()
 })
 }
 
 app.on('window-all-closed', () => {
   void stopManagedRuntimes().catch((error) => {
-    console.warn('[deepseek-gui] failed to stop Kun runtime:', error)
+    console.warn('[kun-gui] failed to stop Kun runtime:', error)
   })
   if (process.platform !== 'darwin') {
     app.quit()
@@ -1018,11 +1437,12 @@ app.on('window-all-closed', () => {
 
 app.on('before-quit', (event) => {
   isQuitting = true
+  stopRuntimeWatchdog()
   if (managedRuntimesStoppedForQuit) return
   event.preventDefault()
   void stopManagedRuntimesForQuit()
     .catch((error) => {
-      console.warn('[deepseek-gui] failed to stop Kun runtime:', error)
+      console.warn('[kun-gui] failed to stop Kun runtime:', error)
       managedRuntimesStoppedForQuit = true
     })
     .finally(() => {
diff --git a/src/main/ipc/app-ipc-schemas.test.ts b/src/main/ipc/app-ipc-schemas.test.ts
index a2c777fa..288a24f1 100644
--- a/src/main/ipc/app-ipc-schemas.test.ts
+++ b/src/main/ipc/app-ipc-schemas.test.ts
@@ -132,6 +132,16 @@ describe('app-ipc-schemas', () => {
         kun: {
           port: 9000,
           model: 'deepseek-chat',
+          modelProfiles: {
+            'custom-vision-model': {
+              aliases: ['custom-vision'],
+              contextWindowTokens: 128000,
+              inputModalities: ['text', 'image'],
+              outputModalities: ['text'],
+              supportsToolCalling: true,
+              messageParts: ['text', 'image_url']
+            }
+          },
           tokenEconomy: {
             enabled: true,
             compressToolResults: false,
@@ -145,14 +155,90 @@ describe('app-ipc-schemas', () => {
         inlineCompletion: {
           model: 'deepseek-v4-pro',
           maxTokens: 128
+        },
+        selectionAssist: {
+          infographicPrompt: '手绘风格信息图。',
+          quickActions: [
+            { id: 'polish', label: '润色一下', prompt: '请润色这段文字。' },
+            { id: 'custom-1', label: '', prompt: '' }
+          ]
         }
       }
     })
 
     expect(payload.agents?.kun?.port).toBe(9000)
+    expect(payload.agents?.kun?.modelProfiles?.['custom-vision-model']?.inputModalities).toEqual(['text', 'image'])
     expect(payload.agents?.kun?.tokenEconomy?.enabled).toBe(true)
     expect(payload.agents?.kun?.tokenEconomy?.historyHygiene?.maxToolResultTokens).toBe(4000)
     expect(payload.write?.inlineCompletion?.model).toBe('deepseek-v4-pro')
+    expect(payload.write?.selectionAssist?.infographicPrompt).toBe('手绘风格信息图。')
+    expect(payload.write?.selectionAssist?.quickActions).toHaveLength(2)
+  })
+
+  it('accepts media generation settings and provider capability patches', () => {
+    const payload = settingsPatchSchema.parse({
+      provider: {
+        providers: [{
+          id: 'minimax',
+          name: 'MiniMax',
+          apiKey: 'sk-media',
+          baseUrl: 'https://api.minimaxi.com/anthropic',
+          endpointFormat: 'messages',
+          models: ['MiniMax-M3'],
+          textToSpeech: {
+            protocol: 'minimax-t2a',
+            baseUrl: 'https://api.minimax.io',
+            models: ['speech-2.8-hd']
+          },
+          music: {
+            protocol: 'minimax-music',
+            baseUrl: 'https://api.minimax.io',
+            models: ['music-2.6']
+          },
+          video: {
+            protocol: 'minimax-video',
+            baseUrl: 'https://api.minimax.io',
+            models: ['MiniMax-Hailuo-2.3']
+          }
+        }]
+      },
+      agents: {
+        kun: {
+          textToSpeech: {
+            enabled: true,
+            providerId: 'minimax',
+            protocol: 'minimax-t2a',
+            model: 'speech-2.8-hd',
+            voice: 'male-qn-qingse',
+            format: 'mp3',
+            timeoutMs: 120000
+          },
+          musicGeneration: {
+            enabled: true,
+            providerId: 'minimax',
+            protocol: 'minimax-music',
+            model: 'music-2.6',
+            format: 'mp3',
+            timeoutMs: 300000
+          },
+          videoGeneration: {
+            enabled: true,
+            providerId: 'minimax',
+            protocol: 'minimax-video',
+            model: 'MiniMax-Hailuo-2.3',
+            defaultDuration: 6,
+            defaultResolution: '1080P',
+            timeoutMs: 900000,
+            pollIntervalMs: 10000
+          }
+        }
+      }
+    })
+
+    expect(payload.provider?.providers?.[0]?.textToSpeech?.models).toEqual(['speech-2.8-hd'])
+    expect(payload.agents?.kun?.textToSpeech?.enabled).toBe(true)
+    expect(payload.agents?.kun?.musicGeneration?.model).toBe('music-2.6')
+    expect(payload.agents?.kun?.videoGeneration?.defaultResolution).toBe('1080P')
   })
 
   it('accepts schedule settings patches and task payloads', () => {
@@ -161,6 +247,7 @@ describe('app-ipc-schemas', () => {
         enabled: true,
         keepAwake: true,
         defaultWorkspaceRoot: '/tmp/schedule',
+        providerId: 'minimax-token-plan',
         model: 'deepseek-v4-flash',
         mode: 'plan',
         promptPrefix: 'Use the project checklist.',
@@ -178,6 +265,8 @@ describe('app-ipc-schemas', () => {
           enabled: true,
           prompt: 'Review the repo',
           workspaceRoot: '/tmp/schedule',
+          clawChannelId: 'channel-1',
+          providerId: 'minimax-token-plan',
           model: 'auto',
           reasoningEffort: 'high',
           mode: 'agent',
@@ -193,17 +282,22 @@ describe('app-ipc-schemas', () => {
     })
 
     expect(payload.schedule?.internal?.port).toBe(9788)
+    expect(payload.schedule?.providerId).toBe('minimax-token-plan')
     expect(payload.schedule?.tasks?.[0]?.schedule?.kind).toBe('daily')
     expect(payload.schedule?.tasks?.[0]?.reasoningEffort).toBe('high')
+    expect(payload.schedule?.tasks?.[0]?.clawChannelId).toBe('channel-1')
+    expect(payload.schedule?.tasks?.[0]?.providerId).toBe('minimax-token-plan')
 
     const fromText = scheduleTaskFromTextPayloadSchema.parse({
       text: 'Remind me tomorrow morning to ship the review',
       workspaceRoot: '/tmp/schedule',
+      clawChannelId: 'channel-1',
       modelHint: 'deepseek-v4-pro',
       mode: 'agent'
     })
 
     expect(fromText.workspaceRoot).toBe('/tmp/schedule')
+    expect(fromText.clawChannelId).toBe('channel-1')
     expect(fromText.modelHint).toBe('deepseek-v4-pro')
   })
 
@@ -233,6 +327,36 @@ describe('app-ipc-schemas', () => {
     expect('quickChat' in (payload.agents ?? {})).toBe(false)
   })
 
+  it('accepts persisted claw channel welcome markers in full settings snapshots', () => {
+    const payload = settingsPatchSchema.parse({
+      claw: {
+        channels: [{
+          id: 'channel-1',
+          provider: 'weixin',
+          label: 'weixin agent',
+          enabled: true,
+          model: 'auto',
+          threadId: '',
+          workspaceRoot: '',
+          agentProfile: {
+            name: 'weixin agent',
+            description: '',
+            identity: '',
+            personality: '',
+            userContext: '',
+            replyRules: ''
+          },
+          conversations: [],
+          welcomeSentAt: '2026-06-10T00:00:00.000Z',
+          createdAt: '2026-06-10T00:00:00.000Z',
+          updatedAt: '2026-06-10T00:00:00.000Z'
+        }]
+      }
+    })
+
+    expect(payload.claw?.channels?.[0]?.welcomeSentAt).toBe('2026-06-10T00:00:00.000Z')
+  })
+
   it('accepts partial provider profiles in settings patches', () => {
     const payload = settingsPatchSchema.parse({
       provider: {
diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts
index f0b6b7e4..2b72706b 100644
--- a/src/main/ipc/app-ipc-schemas.ts
+++ b/src/main/ipc/app-ipc-schemas.ts
@@ -27,16 +27,26 @@ import {
   KUN_USAGE_TEMPLATE
 } from '../../shared/kun-endpoints'
 import {
-  CLAW_MODEL_IDS,
+  IMAGE_GENERATION_PROTOCOLS,
+  MUSIC_GENERATION_PROTOCOLS,
   MODEL_ENDPOINT_FORMATS,
+  MODEL_PROVIDER_INPUT_MODALITIES,
+  MODEL_PROVIDER_MESSAGE_PARTS,
+  MODEL_REASONING_EFFORTS,
+  MODEL_REASONING_REQUEST_PROTOCOLS,
   SCHEDULE_MODEL_IDS,
   SCHEDULE_REASONING_EFFORT_IDS,
+  SPEECH_TO_TEXT_PROTOCOLS,
+  TEXT_TO_SPEECH_PROTOCOLS,
+  VIDEO_GENERATION_PROTOCOLS,
   WRITE_INLINE_COMPLETION_MODEL_IDS
 } from '../../shared/app-settings'
-import { DESKTOP_COMMANDS } from '../../shared/ds-gui-api'
+import { DESKTOP_COMMANDS } from '../../shared/kun-gui-api'
 import { GUI_UPDATE_CHANNELS } from '../../shared/gui-update'
 import { KEYBOARD_SHORTCUT_COMMANDS } from '../../shared/keyboard-shortcuts'
 import { WRITE_EXPORT_FORMATS } from '../../shared/write-export'
+import { WRITE_INFOGRAPHIC_MAX_TEXT_CHARS } from '../../shared/write-infographic'
+import { SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS, SPEECH_TRANSCRIPTION_MAX_DURATION_MS } from '../../shared/speech-to-text'
 
 const MAX_BODY_BYTES = 2_000_000
 const MAX_PATH_LENGTH = 4_096
@@ -51,6 +61,7 @@ const MAX_SKILL_FILE_BYTES = 1_000_000
 const MAX_CONFIG_FILE_BYTES = 2_000_000
 const MAX_DEVICE_CODE_LENGTH = 8_192
 const MAX_EDITOR_COMPLETION_TEXT = 200_000
+const MAX_SAVE_FILE_BASE64_BYTES = 64 * 1024 * 1024
 
 const SAFE_OPEN_EXTERNAL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
 
@@ -73,6 +84,23 @@ export function isSafeOpenExternalUrl(value: string): boolean {
 
 export const defaultPathSchema = optionalTrimmedString(MAX_PATH_LENGTH)
 
+export const confirmDialogPayloadSchema = z
+  .object({
+    message: trimmedString(4_000),
+    detail: z.string().max(8_000).optional(),
+    confirmLabel: z.string().trim().max(200).optional(),
+    cancelLabel: z.string().trim().max(200).optional()
+  })
+  .strict()
+
+export const providerProbePayloadSchema = z
+  .object({
+    baseUrl: trimmedString(MAX_URL_LENGTH),
+    apiKey: z.string().max(8_192),
+    endpointFormat: z.enum(MODEL_ENDPOINT_FORMATS)
+  })
+  .strict()
+
 interface EndpointTemplate {
   /** Compiled path matcher. */
   match(path: string): boolean
@@ -164,13 +192,44 @@ const clawRunModeSchema = z.enum(['agent', 'plan'])
 const clawImProviderSchema = z.enum(['feishu', 'weixin'])
 const clawScheduleKindSchema = z.enum(['manual', 'interval', 'daily', 'at'])
 const clawTaskStatusSchema = z.enum(['idle', 'running', 'success', 'error'])
-const clawModelSchema = z.enum(CLAW_MODEL_IDS)
 const scheduleReasoningEffortSchema = z.enum(SCHEDULE_REASONING_EFFORT_IDS)
 const writeInlineCompletionModelSchema = z.union([
   z.enum(WRITE_INLINE_COMPLETION_MODEL_IDS),
   trimmedString(128)
 ])
 const modelEndpointFormatSchema = z.enum(MODEL_ENDPOINT_FORMATS)
+const imageGenerationProtocolSchema = z.enum(IMAGE_GENERATION_PROTOCOLS)
+const speechToTextProtocolSchema = z.enum(SPEECH_TO_TEXT_PROTOCOLS)
+const textToSpeechProtocolSchema = z.enum(TEXT_TO_SPEECH_PROTOCOLS)
+const musicGenerationProtocolSchema = z.enum(MUSIC_GENERATION_PROTOCOLS)
+const videoGenerationProtocolSchema = z.enum(VIDEO_GENERATION_PROTOCOLS)
+const speechToTextSettingsSchema = z.object({
+  enabled: z.boolean(),
+  providerId: z.string().trim().max(64),
+  protocol: speechToTextProtocolSchema,
+  baseUrl: z.string().trim().max(MAX_URL_LENGTH),
+  apiKey: z.string().max(MAX_BODY_BYTES),
+  model: z.string().trim().max(128),
+  language: z.string().trim().max(16),
+  timeoutMs: z.number().int().positive().max(600_000)
+}).strict()
+const modelProviderInputModalitySchema = z.enum(MODEL_PROVIDER_INPUT_MODALITIES)
+const modelProviderMessagePartSchema = z.enum(MODEL_PROVIDER_MESSAGE_PARTS)
+const modelReasoningEffortSchema = z.enum(MODEL_REASONING_EFFORTS)
+const modelReasoningRequestProtocolSchema = z.enum(MODEL_REASONING_REQUEST_PROTOCOLS)
+const modelProfilePatchSchema = z.object({
+  aliases: z.array(z.string().trim().min(1).max(128)).max(50).optional(),
+  contextWindowTokens: z.number().int().positive().max(10_000_000).optional(),
+  inputModalities: z.array(modelProviderInputModalitySchema).max(8).optional(),
+  outputModalities: z.array(modelProviderInputModalitySchema).max(8).optional(),
+  supportsToolCalling: z.boolean().optional(),
+  messageParts: z.array(modelProviderMessagePartSchema).max(8).optional(),
+  reasoning: z.object({
+    supportedEfforts: z.array(modelReasoningEffortSchema).min(1).max(8),
+    defaultEffort: modelReasoningEffortSchema,
+    requestProtocol: modelReasoningRequestProtocolSchema
+  }).strict().optional()
+}).strict()
 
 const modelProviderPatchSchema = z.object({
   apiKey: z.string().max(MAX_BODY_BYTES).optional(),
@@ -181,7 +240,36 @@ const modelProviderPatchSchema = z.object({
     apiKey: z.string().max(MAX_BODY_BYTES).optional(),
     baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
     endpointFormat: modelEndpointFormatSchema.optional(),
-    models: z.array(z.string().trim().min(1).max(128)).max(200).optional()
+    models: z.array(z.string().trim().min(1).max(128)).max(200).optional(),
+    modelProfiles: z.record(
+      z.string().trim().min(1).max(128),
+      modelProfilePatchSchema.nullable()
+    ).optional(),
+    image: z.object({
+      protocol: imageGenerationProtocolSchema.optional(),
+      baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+      models: z.array(z.string().trim().min(1).max(128)).max(50).optional()
+    }).strict().nullable().optional(),
+    speech: z.object({
+      protocol: speechToTextProtocolSchema.optional(),
+      baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+      models: z.array(z.string().trim().min(1).max(128)).max(50).optional()
+    }).strict().nullable().optional(),
+    textToSpeech: z.object({
+      protocol: textToSpeechProtocolSchema.optional(),
+      baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+      models: z.array(z.string().trim().min(1).max(128)).max(50).optional()
+    }).strict().nullable().optional(),
+    music: z.object({
+      protocol: musicGenerationProtocolSchema.optional(),
+      baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+      models: z.array(z.string().trim().min(1).max(128)).max(50).optional()
+    }).strict().nullable().optional(),
+    video: z.object({
+      protocol: videoGenerationProtocolSchema.optional(),
+      baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+      models: z.array(z.string().trim().min(1).max(128)).max(50).optional()
+    }).strict().nullable().optional()
   }).strict()).max(50).optional()
 }).strict()
 
@@ -243,7 +331,64 @@ const kunRuntimePatchSchema = z.object({
     toolArgumentRepair: z.object({
       maxStringBytes: z.number().int().positive().max(16 * 1024 * 1024).optional()
     }).strict().optional()
-  }).strict().optional()
+  }).strict().optional(),
+  imageGeneration: z.object({
+    enabled: z.boolean().optional(),
+    providerId: z.string().trim().max(64).optional(),
+    protocol: imageGenerationProtocolSchema.optional(),
+    baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+    apiKey: z.string().max(MAX_BODY_BYTES).optional(),
+    model: z.string().trim().max(128).optional(),
+    defaultSize: z.string().trim().max(16).optional(),
+    timeoutMs: z.number().int().positive().max(600_000).optional()
+  }).strict().optional(),
+  speechToText: z.object({
+    enabled: z.boolean().optional(),
+    providerId: z.string().trim().max(64).optional(),
+    protocol: speechToTextProtocolSchema.optional(),
+    baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+    apiKey: z.string().max(MAX_BODY_BYTES).optional(),
+    model: z.string().trim().max(128).optional(),
+    language: z.string().trim().max(16).optional(),
+    timeoutMs: z.number().int().positive().max(600_000).optional()
+  }).strict().optional(),
+  textToSpeech: z.object({
+    enabled: z.boolean().optional(),
+    providerId: z.string().trim().max(64).optional(),
+    protocol: textToSpeechProtocolSchema.optional(),
+    baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+    apiKey: z.string().max(MAX_BODY_BYTES).optional(),
+    model: z.string().trim().max(128).optional(),
+    voice: z.string().trim().max(128).optional(),
+    format: z.string().trim().max(16).optional(),
+    timeoutMs: z.number().int().positive().max(900_000).optional()
+  }).strict().optional(),
+  musicGeneration: z.object({
+    enabled: z.boolean().optional(),
+    providerId: z.string().trim().max(64).optional(),
+    protocol: musicGenerationProtocolSchema.optional(),
+    baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+    apiKey: z.string().max(MAX_BODY_BYTES).optional(),
+    model: z.string().trim().max(128).optional(),
+    format: z.string().trim().max(16).optional(),
+    timeoutMs: z.number().int().positive().max(1_800_000).optional()
+  }).strict().optional(),
+  videoGeneration: z.object({
+    enabled: z.boolean().optional(),
+    providerId: z.string().trim().max(64).optional(),
+    protocol: videoGenerationProtocolSchema.optional(),
+    baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
+    apiKey: z.string().max(MAX_BODY_BYTES).optional(),
+    model: z.string().trim().max(128).optional(),
+    defaultDuration: z.number().int().positive().max(30).optional(),
+    defaultResolution: z.string().trim().max(32).optional(),
+    timeoutMs: z.number().int().positive().max(3_600_000).optional(),
+    pollIntervalMs: z.number().int().positive().max(120_000).optional()
+  }).strict().optional(),
+  modelProfiles: z.record(
+    z.string().trim().min(1).max(128),
+    modelProfilePatchSchema.nullable()
+  ).optional()
 }).strict()
 
 const logPatchSchema = z.object({
@@ -277,6 +422,8 @@ const writeInlineCompletionPatchSchema = z.object({
   enabled: z.boolean().optional(),
   retrievalEnabled: z.boolean().optional(),
   longCompletionEnabled: z.boolean().optional(),
+  inheritProvider: z.boolean().optional(),
+  providerId: z.string().trim().max(64).optional(),
   apiKey: z.string().max(MAX_BODY_BYTES).optional(),
   baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
   inheritModel: z.boolean().optional(),
@@ -289,11 +436,26 @@ const writeInlineCompletionPatchSchema = z.object({
   longMaxTokens: z.number().int().min(64).max(1_024).optional()
 }).strict()
 
+const writeQuickActionSchema = z.object({
+  id: trimmedString(64),
+  label: z.string().max(64).optional(),
+  prompt: z.string().max(4_000).optional(),
+  mode: z.enum(['edit', 'chat']).optional()
+}).strict()
+
+const writeSelectionAssistPatchSchema = z.object({
+  infographicPrompt: z.string().max(4_000).optional(),
+  designDraftPrompt: z.string().max(4_000).optional(),
+  prototypePrompt: z.string().max(4_000).optional(),
+  quickActions: z.array(writeQuickActionSchema).max(24).optional()
+}).strict()
+
 const writeSettingsPatchSchema = z.object({
   defaultWorkspaceRoot: defaultPathSchema,
   activeWorkspaceRoot: defaultPathSchema,
   workspaces: z.array(trimmedString(MAX_PATH_LENGTH)).max(256).optional(),
-  inlineCompletion: writeInlineCompletionPatchSchema.optional()
+  inlineCompletion: writeInlineCompletionPatchSchema.optional(),
+  selectionAssist: writeSelectionAssistPatchSchema.optional()
 }).strict()
 
 const clawSkillPatchSchema = z.object({
@@ -311,6 +473,7 @@ const clawImPatchSchema = z.object({
   weixinBridgeUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
   openClawGatewayUrl: z.string().trim().max(MAX_URL_LENGTH).optional(),
   workspaceRoot: defaultPathSchema,
+  providerId: z.string().trim().max(64).optional(),
   model: z.string().trim().min(1).max(128).optional(),
   mode: clawRunModeSchema.optional(),
   responseTimeoutMs: z.number().int().min(5_000).max(600_000).optional()
@@ -368,6 +531,7 @@ const clawImChannelPatchSchema = z.object({
   provider: clawImProviderSchema.optional(),
   label: z.string().max(512).optional(),
   enabled: z.boolean().optional(),
+  providerId: z.string().trim().max(64).optional(),
   model: z.string().trim().min(1).max(128).optional(),
   threadId: z.string().max(MAX_ID_LENGTH).optional(),
   workspaceRoot: defaultPathSchema,
@@ -375,6 +539,7 @@ const clawImChannelPatchSchema = z.object({
   platformCredential: clawImPlatformCredentialPatchSchema.optional(),
   remoteSession: clawImRemoteSessionPatchSchema.optional(),
   conversations: z.array(clawImConversationPatchSchema).max(512).optional(),
+  welcomeSentAt: z.string().max(128).optional(),
   createdAt: z.string().max(128).optional(),
   updatedAt: z.string().max(128).optional()
 }).strict()
@@ -392,6 +557,8 @@ const clawTaskPatchSchema = z.object({
   enabled: z.boolean().optional(),
   prompt: z.string().max(MAX_CHANNEL_TEXT_LENGTH).optional(),
   workspaceRoot: defaultPathSchema,
+  clawChannelId: z.string().trim().max(MAX_ID_LENGTH).optional(),
+  providerId: z.string().trim().max(64).optional(),
   model: z.string().trim().min(1).max(128).optional(),
   reasoningEffort: scheduleReasoningEffortSchema.optional(),
   mode: clawRunModeSchema.optional(),
@@ -436,6 +603,8 @@ const scheduledTaskPatchSchema = z.object({
   enabled: z.boolean().optional(),
   prompt: z.string().max(MAX_CHANNEL_TEXT_LENGTH).optional(),
   workspaceRoot: defaultPathSchema,
+  clawChannelId: z.string().trim().max(MAX_ID_LENGTH).optional(),
+  providerId: z.string().trim().max(64).optional(),
   model: z.string().trim().min(1).max(128).optional(),
   reasoningEffort: scheduleReasoningEffortSchema.optional(),
   mode: clawRunModeSchema.optional(),
@@ -452,6 +621,7 @@ const scheduledTaskPatchSchema = z.object({
 const scheduleSettingsPatchSchema = z.object({
   enabled: z.boolean().optional(),
   defaultWorkspaceRoot: defaultPathSchema,
+  providerId: z.string().trim().max(64).optional(),
   model: z.union([z.enum(SCHEDULE_MODEL_IDS), trimmedString(128)]).optional(),
   mode: clawRunModeSchema.optional(),
   promptPrefix: z.string().max(MAX_CHANNEL_TEXT_LENGTH).optional(),
@@ -566,6 +736,19 @@ export const workspaceFileWritePayloadSchema = z
   })
   .strict()
 
+export const workspaceFileSaveAsPayloadSchema = z
+  .object({
+    suggestedName: optionalTrimmedString(255),
+    sourcePath: optionalTrimmedString(MAX_PATH_LENGTH),
+    workspaceRoot: optionalTrimmedString(MAX_PATH_LENGTH),
+    dataBase64: z.string().max(MAX_SAVE_FILE_BASE64_BYTES).optional(),
+    mimeType: optionalTrimmedString(255)
+  })
+  .strict()
+  .refine((payload) => Boolean(payload.sourcePath || payload.dataBase64), {
+    message: 'Either sourcePath or dataBase64 is required.'
+  })
+
 export const workspaceFileCreatePayloadSchema = z
   .object({
     path: trimmedString(MAX_PATH_LENGTH),
@@ -611,6 +794,16 @@ export const workspaceFileWatchPayloadSchema = z
   })
   .strict()
 
+export const writeRetrievalPayloadSchema = z
+  .object({
+    workspaceRoot: defaultPathSchema,
+    currentFilePath: defaultPathSchema,
+    query: z.string().trim().min(1).max(MAX_CHANNEL_TEXT_LENGTH),
+    maxSnippets: z.number().int().min(1).max(8).optional(),
+    includeCurrentFile: z.boolean().optional()
+  })
+  .strict()
+
 export const writeExportPayloadSchema = z
   .object({
     path: trimmedString(MAX_PATH_LENGTH),
@@ -721,6 +914,33 @@ export const writeInlineCompletionPayloadSchema = z
   })
   .strict()
 
+export const writeInfographicPayloadSchema = z
+  .object({
+    text: trimmedString(WRITE_INFOGRAPHIC_MAX_TEXT_CHARS),
+    filePath: trimmedString(MAX_PATH_LENGTH),
+    workspaceRoot: trimmedString(MAX_PATH_LENGTH),
+    imageDir: optionalTrimmedString(MAX_PATH_LENGTH),
+    kind: z.enum(['infographic', 'design']).optional(),
+    referenceImagePath: optionalTrimmedString(MAX_PATH_LENGTH)
+  })
+  .strict()
+
+export const writePrototypeFilePayloadSchema = z
+  .object({
+    path: trimmedString(MAX_PATH_LENGTH),
+    workspaceRoot: trimmedString(MAX_PATH_LENGTH)
+  })
+  .strict()
+
+export const speechTranscribePayloadSchema = z
+  .object({
+    audioBase64: z.string().min(1).max(SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS),
+    mimeType: trimmedString(64),
+    durationMs: z.number().int().positive().max(SPEECH_TRANSCRIPTION_MAX_DURATION_MS).optional(),
+    speechToText: speechToTextSettingsSchema.optional()
+  })
+  .strict()
+
 export const shellOpenExternalUrlSchema = trimmedString(MAX_URL_LENGTH).refine(
   isSafeOpenExternalUrl,
   { message: 'Only http, https, and mailto URLs are allowed.' }
@@ -759,7 +979,9 @@ export const clawTaskFromTextPayloadSchema = z
   .object({
     text: z.string().trim().min(1).max(MAX_CHANNEL_TEXT_LENGTH),
     channelId: z.string().trim().min(1).max(MAX_ID_LENGTH).nullable().optional(),
+    providerId: z.string().trim().max(64).nullable().optional(),
     modelHint: z.string().trim().min(1).max(128).nullable().optional(),
+    reasoningEffort: scheduleReasoningEffortSchema.nullable().optional(),
     mode: z.enum(['agent', 'plan']).nullable().optional()
   })
   .strict()
@@ -768,7 +990,10 @@ export const scheduleTaskFromTextPayloadSchema = z
   .object({
     text: z.string().trim().min(1).max(MAX_CHANNEL_TEXT_LENGTH),
     workspaceRoot: defaultPathSchema,
+    clawChannelId: z.string().trim().min(1).max(MAX_ID_LENGTH).nullable().optional(),
+    providerId: z.string().trim().max(64).nullable().optional(),
     modelHint: z.string().trim().min(1).max(128).nullable().optional(),
+    reasoningEffort: scheduleReasoningEffortSchema.nullable().optional(),
     mode: z.enum(['agent', 'plan']).nullable().optional()
   })
   .strict()
@@ -789,3 +1014,9 @@ export const sseStartPayloadSchema = z
   .strict()
 
 export const streamIdSchema = trimmedString(MAX_ID_LENGTH)
+
+export const uiPluginIdPayloadSchema = z
+  .object({
+    id: z.string().trim().regex(/^[a-z0-9][a-z0-9-]{1,39}$/)
+  })
+  .strict()
diff --git a/src/main/ipc/register-app-ipc-handlers.test.ts b/src/main/ipc/register-app-ipc-handlers.test.ts
index 0a0d68ec..0d34f657 100644
--- a/src/main/ipc/register-app-ipc-handlers.test.ts
+++ b/src/main/ipc/register-app-ipc-handlers.test.ts
@@ -1,5 +1,5 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import {
@@ -54,11 +54,14 @@ function settings(): AppSettingsV1 {
 
 function registerOptions(overrides: Partial[0]> = {}) {
   const applySettingsPatch = vi.fn(async () => settings())
+  const saveSettingsPatch = vi.fn(async () => settings())
   return {
     store: { load: vi.fn(async () => settings()) } as never,
     getMainWindow: () => null,
     applySettingsPatch,
+    saveSettingsPatch,
     runtimeRequest: vi.fn() as never,
+    restartRuntime: vi.fn(async () => undefined),
     fetchUpstreamModels: vi.fn() as never,
     getClawRuntime: () => null,
     getScheduleRuntime: () => null,
@@ -115,6 +118,43 @@ describe('registerAppIpcHandlers', () => {
     expect(applySettingsPatch).toHaveBeenCalledWith(payload)
   })
 
+  it('restarts the managed runtime through the restart IPC handler', async () => {
+    const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers')
+    const restartRuntime = vi.fn(async () => undefined)
+
+    registerAppIpcHandlers(registerOptions({ restartRuntime }))
+
+    await expect(handlers.get('runtime:restart')?.({})).resolves.toBeUndefined()
+    expect(restartRuntime).toHaveBeenCalledTimes(1)
+  })
+
+  it('saves generated files to a user-selected path', async () => {
+    const { dialog } = await import('electron')
+    const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers')
+    const temp = mkdtempSync(join(tmpdir(), 'kun-save-as-'))
+    const source = join(temp, 'source.png')
+    const target = join(temp, 'downloaded.png')
+    writeFileSync(source, 'generated-image')
+    ;(dialog as unknown as { showSaveDialog: ReturnType }).showSaveDialog = vi.fn(async () => ({
+      canceled: false,
+      filePath: target
+    }))
+
+    try {
+      registerAppIpcHandlers(registerOptions())
+
+      const handler = handlers.get('file:save-as')
+      await expect(handler?.({}, {
+        sourcePath: source,
+        suggestedName: 'source.png',
+        mimeType: 'image/png'
+      })).resolves.toEqual({ ok: true, path: target })
+      expect(readFileSync(target, 'utf8')).toBe('generated-image')
+    } finally {
+      rmSync(temp, { recursive: true, force: true })
+    }
+  })
+
   it('accepts the full settings snapshot emitted by SettingsView auto-apply', async () => {
     const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers')
     const applySettingsPatch = vi.fn(async () => settings())
@@ -180,7 +220,7 @@ describe('registerAppIpcHandlers', () => {
         onKunMcpConfigWritten
       }))
 
-      await expect(handlers.get('deepseek:config:write')?.({}, content)).resolves.toEqual({
+      await expect(handlers.get('kun:config:write')?.({}, content)).resolves.toEqual({
         ok: true,
         path: configPath
       })
@@ -203,10 +243,10 @@ describe('registerAppIpcHandlers', () => {
         onKunMcpConfigWritten
       }))
 
-      await expect(handlers.get('deepseek:config:write')?.({}, '{')).rejects.toThrow(
+      await expect(handlers.get('kun:config:write')?.({}, '{')).rejects.toThrow(
         /MCP config must be JSON/
       )
-      await expect(handlers.get('deepseek:config:write')?.({}, '[]')).rejects.toThrow(
+      await expect(handlers.get('kun:config:write')?.({}, '[]')).rejects.toThrow(
         /MCP config must be a JSON object/
       )
       expect(existsSync(configPath)).toBe(false)
@@ -279,6 +319,7 @@ describe('registerAppIpcHandlers', () => {
       handlers.get('schedule:task:create-from-text')?.({}, {
         text: 'Remind me tomorrow.',
         workspaceRoot: '/tmp/schedule',
+        clawChannelId: 'channel-1',
         modelHint: 'deepseek-v4-flash',
         mode: 'plan'
       })
@@ -290,6 +331,7 @@ describe('registerAppIpcHandlers', () => {
     expect(scheduleRuntime.runTask).toHaveBeenCalledWith('task-1')
     expect(scheduleRuntime.createScheduledTaskFromText).toHaveBeenCalledWith('Remind me tomorrow.', {
       workspaceRoot: '/tmp/schedule',
+      clawChannelId: 'channel-1',
       modelHint: 'deepseek-v4-flash',
       mode: 'plan'
     })
diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts
index 246f8e7f..94887005 100644
--- a/src/main/ipc/register-app-ipc-handlers.ts
+++ b/src/main/ipc/register-app-ipc-handlers.ts
@@ -1,8 +1,9 @@
 import { app, dialog, ipcMain, shell, type BrowserWindow, type WebContents } from 'electron'
 import { watch, type FSWatcher } from 'node:fs'
 import { randomUUID } from 'node:crypto'
-import { dirname, join } from 'node:path'
-import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { basename, dirname, extname, join, resolve } from 'node:path'
+import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
 import { z } from 'zod'
 import {
   type AppSettingsPatch,
@@ -23,11 +24,13 @@ import type {
   TurnCompleteNotificationPayload,
   UpstreamModelsResult,
   WorkspacePickResult
-} from '../../shared/ds-gui-api'
+} from '../../shared/kun-gui-api'
+import type { WorkspaceFileSaveAsResult } from '../../shared/workspace-file'
 import type { GuiUpdateDownloadResult, GuiUpdateInfo, GuiUpdateInstallResult, GuiUpdateState } from '../../shared/gui-update'
 import {
   clawMirrorPayloadSchema,
   clawImInstallPollPayloadSchema,
+  confirmDialogPayloadSchema,
   clawTaskFromTextPayloadSchema,
   deepseekConfigContentSchema,
   desktopCommandSchema,
@@ -37,6 +40,7 @@ import {
   logErrorPayloadSchema,
   notificationPayloadSchema,
   openEditorPathPayloadSchema,
+  providerProbePayloadSchema,
   rootPathSchema,
   runtimeRequestPayloadSchema,
   scheduleTaskFromTextPayloadSchema,
@@ -45,24 +49,38 @@ import {
   skillSaveFilePayloadSchema,
   settingsPatchSchema,
   streamIdSchema,
+  uiPluginIdPayloadSchema,
   workspaceDirectoryCreatePayloadSchema,
   workspaceClipboardImageSavePayloadSchema,
   workspaceDirectoryTargetPayloadSchema,
   workspaceEntryDeletePayloadSchema,
   workspaceEntryRenamePayloadSchema,
   workspaceFileCreatePayloadSchema,
+  workspaceFileSaveAsPayloadSchema,
   workspaceFileTargetPayloadSchema,
   workspaceFileWatchPayloadSchema,
   workspaceFileWritePayloadSchema,
+  speechTranscribePayloadSchema,
   writeExportPayloadSchema,
   writeRichClipboardPayloadSchema,
+  writeInfographicPayloadSchema,
   writeInlineCompletionPayloadSchema,
+  writePrototypeFilePayloadSchema,
+  writeRetrievalPayloadSchema,
   workspaceRootSchema
 } from './app-ipc-schemas'
 import type { JsonSettingsStore } from '../settings-store'
+import { probeModelProvider } from '../provider-connection'
 import type { ClawRuntime } from '../claw-runtime'
 import type { ScheduleRuntime } from '../schedule-runtime'
 import { createAndSwitchGitBranch, getGitBranches, switchGitBranch } from '../services/git-service'
+import {
+  installUiPluginFromDirectory,
+  listUiPlugins,
+  loadUiPluginFigures,
+  removeUiPlugin
+} from '../services/ui-plugin-service'
+import { ensureBundledUiPlugins } from '../ui-plugin-bundled'
 import {
   createWorkspaceDirectory,
   createWorkspaceFile,
@@ -76,7 +94,9 @@ import {
   readClipboardImage,
   readWorkspaceImage,
   readWorkspaceFile,
+  readWorkspacePdf,
   renameWorkspaceEntry,
+  resolveOpenTargetPath,
   resolveWorkspaceFile,
   saveWorkspaceClipboardImage,
   writeWorkspaceFile
@@ -86,6 +106,10 @@ import {
   listWriteInlineCompletionDebugEntries,
   requestWriteInlineCompletion
 } from '../services/write-inline-completion-service'
+import { retrieveWriteContext } from '../services/write-retrieval-service'
+import { requestWriteInfographic } from '../services/write-infographic-service'
+import { authorizePrototypePath } from '../services/prototype-embed-registry'
+import { requestSpeechTranscription } from '../services/speech-to-text-service'
 import { copyWriteDocumentAsRichText, exportWriteDocument } from '../services/write-export-service'
 import { listGuiSkills } from '../services/skill-service'
 
@@ -103,11 +127,13 @@ type RegisterAppIpcHandlersOptions = {
   store: JsonSettingsStore
   getMainWindow: () => BrowserWindow | null
   applySettingsPatch: (partial: AppSettingsPatch) => Promise
+  saveSettingsPatch: (partial: AppSettingsPatch) => Promise
   runtimeRequest: (
     path: string,
     method?: string,
     body?: string
   ) => Promise
+  restartRuntime: () => Promise
   fetchUpstreamModels: () => Promise
   getClawRuntime: () => ClawRuntime | null
   getScheduleRuntime: () => ScheduleRuntime | null
@@ -134,6 +160,71 @@ function parseIpcPayload(channel: string, schema: z.ZodType, payload: unkn
   throw new Error(`Invalid payload for ${channel}: ${issue?.message ?? 'Bad request.'}`)
 }
 
+function safeSaveAsFileName(input: string | undefined, fallback = 'generated-file'): string {
+  const candidate = (input ?? '').trim().replace(/\0/g, '')
+  const name = basename(candidate) || fallback
+  if (name === '.' || name === '..') return fallback
+  return name
+}
+
+function saveDialogFilters(fileName: string, mimeType: string | undefined): Electron.FileFilter[] {
+  const ext = extname(fileName).replace(/^\./, '').trim()
+  const mime = mimeType?.toLowerCase().trim() ?? ''
+  const filters: Electron.FileFilter[] = []
+  if (mime.startsWith('image/')) {
+    filters.push({ name: 'Images', extensions: ext ? [ext] : ['png', 'jpg', 'jpeg', 'webp', 'gif'] })
+  } else if (mime.startsWith('video/')) {
+    filters.push({ name: 'Videos', extensions: ext ? [ext] : ['mp4', 'webm', 'mov', 'm4v'] })
+  } else if (ext) {
+    filters.push({ name: `${ext.toUpperCase()} file`, extensions: [ext] })
+  }
+  filters.push({ name: 'All Files', extensions: ['*'] })
+  return filters
+}
+
+async function saveWorkspaceFileAs(
+  payload: unknown,
+  getMainWindow: () => BrowserWindow | null
+): Promise {
+  const request = parseIpcPayload('file:save-as', workspaceFileSaveAsPayloadSchema, payload)
+  try {
+    const sourcePath = request.sourcePath
+      ? await resolveOpenTargetPath(request.sourcePath, request.workspaceRoot, { allowBasenameFallback: false })
+      : ''
+    const fileName = safeSaveAsFileName(request.suggestedName || (sourcePath ? basename(sourcePath) : undefined))
+    const defaultPath = request.workspaceRoot?.trim()
+      ? join(expandHomePath(request.workspaceRoot), fileName)
+      : fileName
+    const options: Electron.SaveDialogOptions = {
+      title: 'Save generated file',
+      defaultPath,
+      filters: saveDialogFilters(fileName, request.mimeType)
+    }
+    const mainWindow = getMainWindow()
+    const result = mainWindow
+      ? await dialog.showSaveDialog(mainWindow, options)
+      : await dialog.showSaveDialog(options)
+    if (result.canceled || !result.filePath) {
+      return { ok: false, canceled: true, message: 'Save cancelled.' }
+    }
+
+    const targetPath = resolve(result.filePath)
+    await mkdir(dirname(targetPath), { recursive: true })
+    if (sourcePath) {
+      if (resolve(sourcePath) !== targetPath) {
+        await copyFile(sourcePath, targetPath)
+      }
+    } else if (request.dataBase64) {
+      await writeFile(targetPath, Buffer.from(request.dataBase64, 'base64'))
+    } else {
+      return { ok: false, message: 'No file data was available to save.' }
+    }
+    return { ok: true, path: targetPath }
+  } catch (error) {
+    return { ok: false, message: error instanceof Error ? error.message : String(error) }
+  }
+}
+
 function validateMcpConfigContent(content: string): void {
   const trimmed = content.trim()
   if (!trimmed) return
@@ -216,7 +307,9 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
     store,
     getMainWindow,
     applySettingsPatch,
+    saveSettingsPatch,
     runtimeRequest,
+    restartRuntime,
     fetchUpstreamModels,
     getClawRuntime,
     getScheduleRuntime,
@@ -323,14 +416,26 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
       parseIpcPayload('settings:set', settingsPatchSchema, partial) as AppSettingsPatch
     )
   )
+  ipcMain.handle('settings:save-silent', async (_, partial: unknown) =>
+    saveSettingsPatch(
+      parseIpcPayload('settings:save-silent', settingsPatchSchema, partial) as AppSettingsPatch
+    )
+  )
 
   ipcMain.handle('runtime:request', async (_, payload: unknown) => {
     const request = parseIpcPayload('runtime:request', runtimeRequestPayloadSchema, payload)
     return runtimeRequest(request.path, request.method, request.body)
   })
 
+  ipcMain.handle('runtime:restart', async () => restartRuntime())
+
   ipcMain.handle('upstream:models', async () => fetchUpstreamModels())
 
+  ipcMain.handle('provider:probe', async (_, payload: unknown) => {
+    const request = parseIpcPayload('provider:probe', providerProbePayloadSchema, payload)
+    return probeModelProvider(request)
+  })
+
   ipcMain.handle('claw:status', async (): Promise =>
     getClawRuntime()?.status() ?? {
       imServerRunning: false,
@@ -406,7 +511,10 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
         : undefined
       return scheduleRuntime.createScheduledTaskFromText(request.text, {
         workspaceRoot: channel?.workspaceRoot || settings.schedule.defaultWorkspaceRoot || settings.workspaceRoot,
+        clawChannelId: channel?.id ?? request.channelId,
+        providerId: request.providerId,
         modelHint: request.modelHint,
+        reasoningEffort: request.reasoningEffort,
         mode: request.mode
       })
     }
@@ -424,7 +532,10 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
       if (!scheduleRuntime) return { kind: 'error', message: 'Schedule runtime is not initialized.' }
       return scheduleRuntime.createScheduledTaskFromText(request.text, {
         workspaceRoot: request.workspaceRoot,
+        clawChannelId: request.clawChannelId,
+        providerId: request.providerId,
         modelHint: request.modelHint,
+        reasoningEffort: request.reasoningEffort,
         mode: request.mode
       })
     }
@@ -477,6 +588,27 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
     }
   })
 
+  // Replaces window.confirm in the renderer: the synchronous native confirm
+  // leaves the WebContents unable to focus inputs after it closes
+  // (electron/electron#19977), which froze the composer after deleting threads.
+  ipcMain.handle('dialog:confirm', async (_, payload: unknown): Promise => {
+    const request = parseIpcPayload('dialog:confirm', confirmDialogPayloadSchema, payload)
+    const options: Electron.MessageBoxOptions = {
+      type: 'warning',
+      buttons: [request.confirmLabel ?? 'OK', request.cancelLabel ?? 'Cancel'],
+      defaultId: 0,
+      cancelId: 1,
+      message: request.message,
+      detail: request.detail,
+      noLink: true
+    }
+    const mainWindow = getMainWindow()
+    const result = mainWindow
+      ? await dialog.showMessageBox(mainWindow, options)
+      : await dialog.showMessageBox(options)
+    return result.response === 0
+  })
+
   ipcMain.handle(
     'skill:save-file',
     async (_, payload: unknown) => {
@@ -524,7 +656,45 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
     }
   })
 
-  ipcMain.handle('deepseek:config:read', async () => {
+  ipcMain.handle('ui-plugin:list', async () => {
+    const kunHomeDir = join(homedir(), '.kun')
+    await ensureBundledUiPlugins(kunHomeDir)
+    return { plugins: await listUiPlugins(kunHomeDir) }
+  })
+
+  ipcMain.handle('ui-plugin:install', async () => {
+    const mainWindow = getMainWindow()
+    const options: Electron.OpenDialogOptions = {
+      title: 'Select a UI plugin folder',
+      properties: ['openDirectory', 'dontAddToRecent']
+    }
+    const picked = mainWindow
+      ? await dialog.showOpenDialog(mainWindow, options)
+      : await dialog.showOpenDialog(options)
+    const sourceDir = picked.filePaths[0]
+    if (picked.canceled || !sourceDir) {
+      return { canceled: true as const }
+    }
+    const result = await installUiPluginFromDirectory(join(homedir(), '.kun'), sourceDir)
+    if (!result.ok) {
+      return { canceled: false as const, ok: false as const, errors: result.errors }
+    }
+    return { canceled: false as const, ok: true as const, plugin: result.plugin }
+  })
+
+  ipcMain.handle('ui-plugin:remove', async (_, payload: unknown) => {
+    const request = parseIpcPayload('ui-plugin:remove', uiPluginIdPayloadSchema, payload)
+    return { ok: await removeUiPlugin(join(homedir(), '.kun'), request.id) }
+  })
+
+  ipcMain.handle('ui-plugin:load', async (_, payload: unknown) => {
+    const request = parseIpcPayload('ui-plugin:load', uiPluginIdPayloadSchema, payload)
+    const kunHomeDir = join(homedir(), '.kun')
+    await ensureBundledUiPlugins(kunHomeDir)
+    return loadUiPluginFigures(kunHomeDir, request.id)
+  })
+
+  ipcMain.handle('kun:config:read', async () => {
     const path = resolveKunConfigPath()
     try {
       const content = await readFile(path, 'utf8')
@@ -537,9 +707,9 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
     }
   })
 
-  ipcMain.handle('deepseek:config:write', async (_, content: unknown) => {
+  ipcMain.handle('kun:config:write', async (_, content: unknown) => {
     const validatedContent = parseIpcPayload(
-      'deepseek:config:write',
+      'kun:config:write',
       deepseekConfigContentSchema,
       content
     )
@@ -558,7 +728,7 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
     return { ok: true as const, path }
   })
 
-  ipcMain.handle('deepseek:config:open-dir', async () => {
+  ipcMain.handle('kun:config:open-dir', async () => {
     try {
       const path = resolveKunConfigPath()
       const dirPath = dirname(path)
@@ -619,6 +789,14 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
       parseIpcPayload('file:read-workspace-image', workspaceFileTargetPayloadSchema, payload)
     )
   )
+  ipcMain.handle('file:read-workspace-pdf', async (_, payload: unknown) =>
+    readWorkspacePdf(
+      parseIpcPayload('file:read-workspace-pdf', workspaceFileTargetPayloadSchema, payload)
+    )
+  )
+  ipcMain.handle('file:save-as', async (_, payload: unknown) =>
+    saveWorkspaceFileAs(payload, getMainWindow)
+  )
   ipcMain.handle('file:write-workspace', async (_, payload: unknown) =>
     writeWorkspaceFile(
       parseIpcPayload('file:write-workspace', workspaceFileWritePayloadSchema, payload)
@@ -724,6 +902,41 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
       parseIpcPayload('write:inline-completion', writeInlineCompletionPayloadSchema, payload)
     )
   )
+  ipcMain.handle('write:retrieve-context', async (_, payload: unknown) => {
+    try {
+      const context = await retrieveWriteContext(
+        parseIpcPayload('write:retrieve-context', writeRetrievalPayloadSchema, payload)
+      )
+      return { ok: true as const, context }
+    } catch (error) {
+      return {
+        ok: false as const,
+        message: error instanceof Error ? error.message : String(error)
+      }
+    }
+  })
+  ipcMain.handle('write:generate-infographic', async (_, payload: unknown) =>
+    requestWriteInfographic(
+      await store.load(),
+      parseIpcPayload('write:generate-infographic', writeInfographicPayloadSchema, payload)
+    )
+  )
+  ipcMain.handle('write:authorize-prototype', async (_, payload: unknown) => {
+    const request = parseIpcPayload('write:authorize-prototype', writePrototypeFilePayloadSchema, payload)
+    return authorizePrototypePath(request.path, request.workspaceRoot)
+  })
+  ipcMain.handle('write:open-prototype', async (_, payload: unknown) => {
+    const request = parseIpcPayload('write:open-prototype', writePrototypeFilePayloadSchema, payload)
+    const authorized = await authorizePrototypePath(request.path, request.workspaceRoot)
+    if (!authorized.ok) return authorized
+    return openPathWithShell(authorized.absolutePath)
+  })
+  ipcMain.handle('speech:transcribe', async (_, payload: unknown) =>
+    requestSpeechTranscription(
+      await store.load(),
+      parseIpcPayload('speech:transcribe', speechTranscribePayloadSchema, payload)
+    )
+  )
   ipcMain.handle('write:inline-completion-debug:list', async () => listWriteInlineCompletionDebugEntries())
   ipcMain.handle('write:inline-completion-debug:clear', async () => {
     clearWriteInlineCompletionDebugEntries()
diff --git a/src/main/kun-process.test.ts b/src/main/kun-process.test.ts
index 33efc2bb..f3c95e58 100644
--- a/src/main/kun-process.test.ts
+++ b/src/main/kun-process.test.ts
@@ -1,4 +1,4 @@
-import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
 import { createServer, type AddressInfo } from 'node:net'
 import { homedir, tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -104,6 +104,46 @@ describe('startKunChild', () => {
     expect(logText).toContain('ready marker received on port 8899')
   })
 
+  it('shares the startup promise while Kun is spawned but not ready', async () => {
+    if (!tempRoot) throw new Error('temp root not initialized')
+    const readySignalPath = join(tempRoot, 'allow-ready')
+    const script = writeScript(
+      'delayed-ready-child.js',
+      [
+        "const { existsSync } = require('node:fs')",
+        `const readySignalPath = ${JSON.stringify(readySignalPath)}`,
+        'let sentReady = false',
+        'setInterval(() => {',
+        '  if (sentReady || !existsSync(readySignalPath)) return',
+        '  sentReady = true',
+        "  process.stdout.write('KUN_READY ' + JSON.stringify({ service: 'kun', mode: 'serve', port: 8899 }) + '\\n')",
+        '}, 10)',
+        'setInterval(() => {}, 1_000)'
+      ].join('\n')
+    )
+    const module = await import('./kun-process')
+    const settings = createSettings(script)
+    const first = module.startKunChild(settings)
+
+    for (let attempt = 0; attempt < 100 && !module.isKunChildRunning(); attempt += 1) {
+      await new Promise((resolve) => setTimeout(resolve, 10))
+    }
+    expect(module.isKunChildRunning()).toBe(true)
+
+    let secondResolved = false
+    const second = module.startKunChild(settings).then(() => {
+      secondResolved = true
+    })
+    await new Promise((resolve) => setTimeout(resolve, 50))
+
+    expect(secondResolved).toBe(false)
+
+    writeFileSync(readySignalPath, 'ready', 'utf8')
+    await first
+    await second
+    expect(secondResolved).toBe(true)
+  })
+
   it('rejects when the child exits before reporting ready', async () => {
     const script = writeScript(
       'exit-child.js',
@@ -149,6 +189,27 @@ describe('reclaimKunPort', () => {
 
     await expect(module.reclaimKunPort(0)).resolves.toEqual({ ok: true })
   })
+
+  it('resolves an available fallback port when the preferred port is unavailable', async () => {
+    const server = createServer()
+    await new Promise((resolve, reject) => {
+      server.once('error', reject)
+      server.listen(0, '127.0.0.1', () => resolve())
+    })
+    try {
+      const address = server.address() as AddressInfo
+      const module = await import('./kun-process')
+
+      const resolved = await module.resolveAvailableKunPort(address.port)
+
+      expect(resolved.changed).toBe(true)
+      expect(resolved.message).toBe(`port ${address.port} is in use`)
+      expect(resolved.port).not.toBe(address.port)
+      await expect(module.reclaimKunPort(resolved.port)).resolves.toEqual({ ok: true })
+    } finally {
+      await new Promise((resolve) => server.close(() => resolve()))
+    }
+  })
 })
 
 describe('resolveKunDataDir', () => {
@@ -165,6 +226,37 @@ describe('resolveKunDataDir', () => {
   })
 })
 
+describe('parseListeningPidsFromNetstat', () => {
+  it('extracts the listening TCP PIDs for the port across IPv4/IPv6, ignoring everything else', async () => {
+    const { parseListeningPidsFromNetstat } = await import('./kun-process')
+    const output = [
+      '',
+      'Active Connections',
+      '',
+      '  Proto  Local Address          Foreign Address        State           PID',
+      '  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       1010',
+      '  TCP    127.0.0.1:8899         0.0.0.0:0              LISTENING       6789',
+      '  TCP    [::1]:8899             [::]:0                 LISTENING       6789',
+      '  TCP    127.0.0.1:8899         127.0.0.1:51000        ESTABLISHED     7000',
+      '  TCP    127.0.0.1:18899        0.0.0.0:0              LISTENING       8000',
+      '  UDP    0.0.0.0:8899           *:*                                    9000',
+      `  TCP    127.0.0.1:8899         0.0.0.0:0              LISTENING       ${process.pid}`,
+      ''
+    ].join('\r\n')
+
+    // Dedups IPv4+IPv6 rows for the same PID; excludes the :135 listener, the
+    // ESTABLISHED row, the :18899 suffix collision, the UDP row, and our own PID.
+    expect(parseListeningPidsFromNetstat(output, 8899)).toEqual([6789])
+  })
+
+  it('returns no PIDs when nothing listens on the port', async () => {
+    const { parseListeningPidsFromNetstat } = await import('./kun-process')
+    const output = '  TCP    127.0.0.1:8899         0.0.0.0:0              LISTENING       6789'
+
+    expect(parseListeningPidsFromNetstat(output, 9999)).toEqual([])
+  })
+})
+
 describe('syncGuiManagedKunConfig', () => {
   it('creates GUI-managed config with attachments enabled for image paste/upload', async () => {
     if (!tempRoot) throw new Error('temp root not initialized')
@@ -214,6 +306,186 @@ describe('syncGuiManagedKunConfig', () => {
     expect(parsed.capabilities.attachments).toMatchObject({ enabled: true })
     expect(parsed.capabilities.web).toMatchObject({ enabled: true, fetchEnabled: true })
     expect(parsed.capabilities.mcp.search).toMatchObject({ enabled: false, mode: 'auto' })
+    expect(parsed.capabilities.imageGen).toEqual({
+      enabled: false,
+      protocol: 'openai-images',
+      timeoutMs: 180000
+    })
+    expect(parsed.capabilities.speechGen).toEqual({
+      enabled: false,
+      protocol: 'openai-speech',
+      timeoutMs: 120000,
+      format: 'mp3'
+    })
+    expect(parsed.capabilities.musicGen).toEqual({
+      enabled: false,
+      protocol: 'minimax-music',
+      timeoutMs: 300000,
+      format: 'mp3'
+    })
+    expect(parsed.capabilities.videoGen).toEqual({
+      enabled: false,
+      protocol: 'minimax-video',
+      defaultDuration: 6,
+      defaultResolution: '1080P',
+      timeoutMs: 900000,
+      pollIntervalMs: 10000
+    })
+  })
+
+  it('writes the image generation capability and omits cleared fields', async () => {
+    if (!tempRoot) throw new Error('temp root not initialized')
+    const configPath = join(tempRoot, 'config.json')
+    const module = await import('./kun-process')
+    const runtime = {
+      ...defaultKunRuntimeSettings(),
+      imageGeneration: {
+        enabled: true,
+        providerId: '',
+        protocol: 'openai-images' as const,
+        baseUrl: 'https://api.siliconflow.cn/v1',
+        apiKey: 'sk-image-test',
+        model: 'Kwai-Kolors/Kolors',
+        defaultSize: '',
+        timeoutMs: 240000
+      }
+    }
+
+    await module.syncGuiManagedKunConfig(tempRoot, runtime)
+
+    const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as any
+    expect(parsed.capabilities.imageGen).toEqual({
+      enabled: true,
+      protocol: 'openai-images',
+      baseUrl: 'https://api.siliconflow.cn/v1',
+      apiKey: 'sk-image-test',
+      model: 'Kwai-Kolors/Kolors',
+      timeoutMs: 240000
+    })
+    expect(KunConfigSchema.safeParse(parsed).success).toBe(true)
+
+    // Clearing the key in GUI settings must remove it from config.json.
+    await module.syncGuiManagedKunConfig(tempRoot, {
+      ...runtime,
+      imageGeneration: { ...runtime.imageGeneration, apiKey: '' }
+    })
+    const cleared = JSON.parse(readFileSync(configPath, 'utf8')) as any
+    expect('apiKey' in cleared.capabilities.imageGen).toBe(false)
+  })
+
+  it('keeps the config stable across repeated syncs with imageGen configured', async () => {
+    if (!tempRoot) throw new Error('temp root not initialized')
+    const configPath = join(tempRoot, 'config.json')
+    const module = await import('./kun-process')
+    const runtime = {
+      ...defaultKunRuntimeSettings(),
+      imageGeneration: {
+        enabled: true,
+        providerId: '',
+        protocol: 'openai-images' as const,
+        baseUrl: 'https://api.siliconflow.cn/v1',
+        apiKey: 'sk-image-test',
+        model: 'Kwai-Kolors/Kolors',
+        defaultSize: '1024x1024',
+        timeoutMs: 180000
+      }
+    }
+
+    await module.syncGuiManagedKunConfig(tempRoot, runtime)
+    const firstText = readFileSync(configPath, 'utf8')
+    const firstMtime = statSync(configPath).mtimeMs
+    await new Promise((resolve) => setTimeout(resolve, 25))
+
+    // If the capability sanitizer strips imageGen from the existing config,
+    // every sync rewrites the file and restarts Kun in a loop.
+    await module.syncGuiManagedKunConfig(tempRoot, runtime)
+    expect(readFileSync(configPath, 'utf8')).toBe(firstText)
+    expect(statSync(configPath).mtimeMs).toBe(firstMtime)
+  })
+
+  it('writes media generation capabilities and omits cleared optional fields', async () => {
+    if (!tempRoot) throw new Error('temp root not initialized')
+    const configPath = join(tempRoot, 'config.json')
+    const module = await import('./kun-process')
+    const runtime = {
+      ...defaultKunRuntimeSettings(),
+      textToSpeech: {
+        enabled: true,
+        providerId: '',
+        protocol: 'minimax-t2a' as const,
+        baseUrl: 'https://api.minimax.io',
+        apiKey: 'sk-tts-test',
+        model: 'speech-2.8-hd',
+        voice: 'male-qn-qingse',
+        format: 'mp3',
+        timeoutMs: 120000
+      },
+      musicGeneration: {
+        enabled: true,
+        providerId: '',
+        protocol: 'minimax-music' as const,
+        baseUrl: 'https://api.minimax.io',
+        apiKey: 'sk-music-test',
+        model: 'music-2.6',
+        format: 'mp3',
+        timeoutMs: 300000
+      },
+      videoGeneration: {
+        enabled: true,
+        providerId: '',
+        protocol: 'minimax-video' as const,
+        baseUrl: 'https://api.minimax.io',
+        apiKey: 'sk-video-test',
+        model: 'MiniMax-Hailuo-2.3',
+        defaultDuration: 6,
+        defaultResolution: '1080P',
+        timeoutMs: 900000,
+        pollIntervalMs: 10000
+      }
+    }
+
+    await module.syncGuiManagedKunConfig(tempRoot, runtime)
+
+    const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as any
+    expect(parsed.capabilities.speechGen).toEqual({
+      enabled: true,
+      protocol: 'minimax-t2a',
+      baseUrl: 'https://api.minimax.io',
+      apiKey: 'sk-tts-test',
+      model: 'speech-2.8-hd',
+      voice: 'male-qn-qingse',
+      format: 'mp3',
+      timeoutMs: 120000
+    })
+    expect(parsed.capabilities.musicGen).toEqual({
+      enabled: true,
+      protocol: 'minimax-music',
+      baseUrl: 'https://api.minimax.io',
+      apiKey: 'sk-music-test',
+      model: 'music-2.6',
+      format: 'mp3',
+      timeoutMs: 300000
+    })
+    expect(parsed.capabilities.videoGen).toEqual({
+      enabled: true,
+      protocol: 'minimax-video',
+      baseUrl: 'https://api.minimax.io',
+      apiKey: 'sk-video-test',
+      model: 'MiniMax-Hailuo-2.3',
+      defaultDuration: 6,
+      defaultResolution: '1080P',
+      timeoutMs: 900000,
+      pollIntervalMs: 10000
+    })
+    expect(KunConfigSchema.safeParse(parsed).success).toBe(true)
+
+    await module.syncGuiManagedKunConfig(tempRoot, {
+      ...runtime,
+      textToSpeech: { ...runtime.textToSpeech, apiKey: '', voice: '' }
+    })
+    const cleared = JSON.parse(readFileSync(configPath, 'utf8')) as any
+    expect('apiKey' in cleared.capabilities.speechGen).toBe(false)
+    expect('voice' in cleared.capabilities.speechGen).toBe(false)
   })
 
   it('adds the built-in schedule MCP server to Kun runtime capabilities', async () => {
diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts
index 4b169b23..c204abd3 100644
--- a/src/main/kun-process.ts
+++ b/src/main/kun-process.ts
@@ -1,14 +1,16 @@
 import { app } from 'electron'
-import { spawn, type ChildProcess } from 'node:child_process'
+import { execFile, spawn, type ChildProcess } from 'node:child_process'
 import { existsSync } from 'node:fs'
 import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import { createServer } from 'node:net'
 import { homedir } from 'node:os'
 import { dirname, join } from 'node:path'
+import { promisify } from 'node:util'
 import {
   defaultKunTokenEconomySettings,
   isKunRuntimeInsecure,
   resolveKunRuntimeSettings,
+  type ModelProviderModelProfileV1,
   type KunRuntimeSettingsV1,
   type AppSettingsV1
 } from '../shared/app-settings'
@@ -25,11 +27,15 @@ import {
 } from '../../kun/src/config/kun-config.js'
 import {
   AttachmentsCapabilityConfig,
+  ImageGenCapabilityConfig,
   McpCapabilityConfig,
   McpServerConfig,
   MemoryCapabilityConfig,
+  MusicGenCapabilityConfig,
   SkillsCapabilityConfig,
+  SpeechGenCapabilityConfig,
   SubagentsCapabilityConfig,
+  VideoGenCapabilityConfig,
   WebCapabilityConfig
 } from '../../kun/src/contracts/capabilities.js'
 import {
@@ -40,17 +46,50 @@ import {
   type ClawScheduleMcpLaunchConfig
 } from './claw-schedule-mcp-config'
 import { defaultKunDataDir } from './runtime/kun-adapter'
+import { isKunHealthResponseBody } from './kun-health'
 import { appendManagedLogLine } from './logger'
 import { guiSkillRootsForRuntime, normalizeSkillRootPath } from './services/skill-service'
 
 let child: ChildProcess | null = null
 let childLogCapture: KunChildLogCapture | null = null
 let lastResolvedBinary: string | null = null
+let kunStartPromise: Promise | null = null
+let childStderrTail = ''
+/** Children killed on purpose (stop/quit/settings restart) — their exit is not a crash. */
+const intentionalStops = new WeakSet()
+/** Children that completed the ready handshake — only their exits count as runtime crashes. */
+const readyChildren = new WeakSet()
+
+export type KunUnexpectedExitInfo = {
+  code: number | null
+  signal: NodeJS.Signals | null
+  stderrTail: string
+}
+
+let onUnexpectedKunExit: ((info: KunUnexpectedExitInfo) => void) | null = null
+
+/**
+ * Called when a READY kun child exits without the GUI asking for it.
+ * Startup failures are excluded: those are already reported to the
+ * caller of startKunChild via the thrown error.
+ */
+export function setKunUnexpectedExitHandler(
+  handler: ((info: KunUnexpectedExitInfo) => void) | null
+): void {
+  onUnexpectedKunExit = handler
+}
+
+const execFileAsync = promisify(execFile)
 const KUN_READY_PREFIX = 'KUN_READY '
-const KUN_STARTUP_TIMEOUT_MS = 15_000
+// Cold starts on slow disks (Windows + antivirus scans, sqlite rebuilds,
+// MCP server connects) routinely exceed 15s; killing kun that early left
+// fresh installs permanently "unable to connect" (#188).
+const KUN_STARTUP_TIMEOUT_MS = 45_000
+const KUN_STARTUP_HEALTH_POLL_MS = 500
+const KUN_STARTUP_HEALTH_REQUEST_TIMEOUT_MS = 1_000
 const KUN_STOP_GRACE_MS = 5_000
 const KUN_STOP_FORCE_MS = 1_000
-const STDERR_TAIL_MAX_CHARS = 4_000
+const STDERR_TAIL_MAX_CHARS = 32_768
 const GUI_SCHEDULE_MCP_TIMEOUT_MS = 5_000
 const DEFAULT_KUN_MODEL_PROFILES: Record> = {
   'deepseek-v4-pro': {
@@ -194,10 +233,23 @@ export function isKunChildRunning(): boolean {
   return child !== null && child.exitCode === null && child.signalCode === null
 }
 
-export async function startKunChild(settings: AppSettingsV1): Promise {
+export function startKunChild(settings: AppSettingsV1): Promise {
+  if (kunStartPromise) return kunStartPromise
   const runtime = resolveKunRuntimeSettings(settings)
-  if (isKunChildRunning()) return
-  if (!runtime.autoStart) return
+  if (isKunChildRunning()) return Promise.resolve()
+  if (!runtime.autoStart) return Promise.resolve()
+  let promise: Promise
+  promise = startKunChildOnce(settings, runtime).finally(() => {
+    if (kunStartPromise === promise) kunStartPromise = null
+  })
+  kunStartPromise = promise
+  return promise
+}
+
+async function startKunChildOnce(
+  settings: AppSettingsV1,
+  runtime: KunRuntimeSettingsV1
+): Promise {
   if (childLogCapture) {
     await childLogCapture.close()
     childLogCapture = null
@@ -249,9 +301,13 @@ export async function startKunChild(settings: AppSettingsV1): Promise {
   const startedChild = child
   const startedLogCapture = createKunChildLogCapture(startedChild.pid)
   childLogCapture = startedLogCapture
+  childStderrTail = ''
   startedLogCapture.logLifecycle(`spawned on port ${runtime.port} using data dir ${dataDir}`)
   startedChild.stdout?.on('data', startedLogCapture.captureStdout)
-  startedChild.stderr?.on('data', startedLogCapture.captureStderr)
+  startedChild.stderr?.on('data', (chunk: Buffer | string) => {
+    childStderrTail = appendTail(childStderrTail, normalizeCapturedChunk(chunk))
+    startedLogCapture.captureStderr(chunk)
+  })
   child.on('exit', (code, signal) => {
     startedLogCapture.logLifecycle(
       signal
@@ -260,6 +316,13 @@ export async function startKunChild(settings: AppSettingsV1): Promise {
     )
     void startedLogCapture.close()
     if (child === startedChild) child = null
+    if (readyChildren.has(startedChild) && !intentionalStops.has(startedChild)) {
+      onUnexpectedKunExit?.({
+        code: code ?? null,
+        signal: signal ?? null,
+        stderrTail: childStderrTail
+      })
+    }
   })
   child.on('error', (error) => {
     startedLogCapture.logLifecycle(
@@ -267,7 +330,7 @@ export async function startKunChild(settings: AppSettingsV1): Promise {
     )
   })
   try {
-    await waitForKunStartup(startedChild)
+    await waitForKunStartup(startedChild, runtime.port)
   } catch (error) {
     const message = error instanceof Error ? error.message : String(error)
     startedLogCapture.logLifecycle(`startup failed before ready: ${message}`)
@@ -276,6 +339,7 @@ export async function startKunChild(settings: AppSettingsV1): Promise {
     }
     throw error
   }
+  readyChildren.add(startedChild)
   startedLogCapture.logLifecycle(`ready marker received on port ${runtime.port}`)
 }
 
@@ -283,7 +347,16 @@ export async function syncGuiManagedKunConfig(
   dataDir: string,
   runtime: Pick<
     KunRuntimeSettingsV1,
-    'mcpSearch' | 'tokenEconomy' | 'storage' | 'contextCompaction' | 'runtimeTuning'
+    | 'mcpSearch'
+    | 'tokenEconomy'
+    | 'storage'
+    | 'contextCompaction'
+    | 'runtimeTuning'
+    | 'imageGeneration'
+    | 'textToSpeech'
+    | 'musicGeneration'
+    | 'videoGeneration'
+    | 'modelProfiles'
   >,
   options?: {
     scheduleMcp?: {
@@ -313,6 +386,10 @@ export async function syncGuiManagedKunConfig(
   const attachments = objectValue(capabilities.attachments)
   const web = objectValue(capabilities.web)
   const skills = objectValue(capabilities.skills)
+  const imageGen = objectValue(capabilities.imageGen)
+  const speechGen = objectValue(capabilities.speechGen)
+  const musicGen = objectValue(capabilities.musicGen)
+  const videoGen = objectValue(capabilities.videoGen)
   const storage = storageConfigForRuntime(runtime.storage)
   const mcpSearch = runtime.mcpSearch
   const skillCapability = await skillCapabilityConfigForRuntime(skills, options?.scheduleMcp?.settings)
@@ -322,7 +399,7 @@ export async function syncGuiManagedKunConfig(
       storage,
       tokenEconomy: tokenEconomyConfigForRuntime(runtime.tokenEconomy, existingTokenEconomy)
     },
-    models: modelConfigForRuntime(existingModels),
+    models: modelConfigForRuntime(existingModels, runtime.modelProfiles),
     contextCompaction: contextCompactionConfigForRuntime(runtime.contextCompaction, existingContextCompaction),
     runtime: runtimeTuningConfigForRuntime(runtime.runtimeTuning, existingRuntimeTuning),
     capabilities: {
@@ -337,6 +414,10 @@ export async function syncGuiManagedKunConfig(
         fetchEnabled: web.fetchEnabled === false ? false : true
       },
       skills: skillCapability,
+      imageGen: imageGenConfigForRuntime(runtime.imageGeneration, imageGen),
+      speechGen: speechGenConfigForRuntime(runtime.textToSpeech, speechGen),
+      musicGen: musicGenConfigForRuntime(runtime.musicGeneration, musicGen),
+      videoGen: videoGenConfigForRuntime(runtime.videoGeneration, videoGen),
       mcp: {
         ...mcp,
         ...(options?.scheduleMcp || mcpSearch.enabled || hasImportedEnabledMcpServer
@@ -524,18 +605,32 @@ function positiveIntegerValue(value: unknown): number | undefined {
   return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined
 }
 
-function modelConfigForRuntime(existing: Record): Record {
+function modelConfigForRuntime(
+  existing: Record,
+  guiModelProfiles: Record = {}
+): Record {
   const existingProfiles = objectValue(existing.profiles)
-  const profiles: Record = { ...DEFAULT_KUN_MODEL_PROFILES }
-  for (const [modelId, profile] of Object.entries(existingProfiles)) {
-    const defaultProfile = objectValue(DEFAULT_KUN_MODEL_PROFILES[modelId])
-    const existingProfile = objectValue(profile)
+  const guiProfiles = modelConfigProfilesFromProviderProfiles(guiModelProfiles)
+  const profileDefaults = {
+    ...DEFAULT_KUN_MODEL_PROFILES,
+    ...guiProfiles
+  }
+  const profiles: Record = {}
+  for (const modelId of new Set([
+    ...Object.keys(profileDefaults),
+    ...Object.keys(existingProfiles)
+  ])) {
+    const defaultProfile = objectValue(profileDefaults[modelId])
+    const existingProfile = objectValue(existingProfiles[modelId])
+    const guiProfile = objectValue(guiProfiles[modelId])
     profiles[modelId] = {
       ...defaultProfile,
       ...existingProfile,
+      ...guiProfile,
       contextCompaction: {
         ...objectValue(defaultProfile.contextCompaction),
-        ...objectValue(existingProfile.contextCompaction)
+        ...objectValue(existingProfile.contextCompaction),
+        ...objectValue(guiProfile.contextCompaction)
       }
     }
   }
@@ -545,6 +640,26 @@ function modelConfigForRuntime(existing: Record): Record
+): Record {
+  const out: Record = {}
+  for (const [modelId, profile] of Object.entries(profiles)) {
+    const trimmed = modelId.trim()
+    if (!trimmed) continue
+    out[trimmed] = {
+      ...(profile.aliases?.length ? { aliases: profile.aliases } : {}),
+      ...(profile.contextWindowTokens ? { contextWindowTokens: profile.contextWindowTokens } : {}),
+      inputModalities: profile.inputModalities,
+      outputModalities: profile.outputModalities,
+      supportsToolCalling: profile.supportsToolCalling,
+      messageParts: profile.messageParts,
+      ...(profile.reasoning ? { reasoning: profile.reasoning } : {})
+    }
+  }
+  return out
+}
+
 function tokenEconomyConfigForRuntime(
   tokenEconomy: Pick['tokenEconomy'] | undefined,
   existing: Record
@@ -602,6 +717,108 @@ function contextCompactionConfigForRuntime(
   }
 }
 
+function imageGenConfigForRuntime(
+  imageGeneration: Pick['imageGeneration'],
+  existing: Record
+): Record {
+  // GUI settings own these fields: cleared values must be removed from the
+  // config (the zod schema rejects empty strings), while unknown hand-edited
+  // keys like maxReferenceImages are preserved via the spread.
+  const next: Record = {
+    ...existing,
+    enabled: imageGeneration.enabled,
+    timeoutMs: imageGeneration.timeoutMs
+  }
+  const fields = {
+    protocol: imageGeneration.protocol,
+    baseUrl: imageGeneration.baseUrl,
+    apiKey: imageGeneration.apiKey,
+    model: imageGeneration.model,
+    defaultSize: imageGeneration.defaultSize
+  }
+  for (const [key, value] of Object.entries(fields)) {
+    const trimmed = value.trim()
+    if (trimmed) next[key] = trimmed
+    else delete next[key]
+  }
+  return next
+}
+
+function speechGenConfigForRuntime(
+  textToSpeech: Pick['textToSpeech'],
+  existing: Record
+): Record {
+  const next: Record = {
+    ...existing,
+    enabled: textToSpeech.enabled,
+    timeoutMs: textToSpeech.timeoutMs,
+    format: textToSpeech.format
+  }
+  const fields = {
+    protocol: textToSpeech.protocol,
+    baseUrl: textToSpeech.baseUrl,
+    apiKey: textToSpeech.apiKey,
+    model: textToSpeech.model,
+    voice: textToSpeech.voice
+  }
+  for (const [key, value] of Object.entries(fields)) {
+    const trimmed = value.trim()
+    if (trimmed) next[key] = trimmed
+    else delete next[key]
+  }
+  return next
+}
+
+function musicGenConfigForRuntime(
+  musicGeneration: Pick['musicGeneration'],
+  existing: Record
+): Record {
+  const next: Record = {
+    ...existing,
+    enabled: musicGeneration.enabled,
+    timeoutMs: musicGeneration.timeoutMs,
+    format: musicGeneration.format
+  }
+  const fields = {
+    protocol: musicGeneration.protocol,
+    baseUrl: musicGeneration.baseUrl,
+    apiKey: musicGeneration.apiKey,
+    model: musicGeneration.model
+  }
+  for (const [key, value] of Object.entries(fields)) {
+    const trimmed = value.trim()
+    if (trimmed) next[key] = trimmed
+    else delete next[key]
+  }
+  return next
+}
+
+function videoGenConfigForRuntime(
+  videoGeneration: Pick['videoGeneration'],
+  existing: Record
+): Record {
+  const next: Record = {
+    ...existing,
+    enabled: videoGeneration.enabled,
+    defaultDuration: videoGeneration.defaultDuration,
+    timeoutMs: videoGeneration.timeoutMs,
+    pollIntervalMs: videoGeneration.pollIntervalMs
+  }
+  const fields = {
+    protocol: videoGeneration.protocol,
+    baseUrl: videoGeneration.baseUrl,
+    apiKey: videoGeneration.apiKey,
+    model: videoGeneration.model,
+    defaultResolution: videoGeneration.defaultResolution
+  }
+  for (const [key, value] of Object.entries(fields)) {
+    const trimmed = value.trim()
+    if (trimmed) next[key] = trimmed
+    else delete next[key]
+  }
+  return next
+}
+
 function runtimeTuningConfigForRuntime(
   runtimeTuning: Pick['runtimeTuning'],
   existing: Record
@@ -662,6 +879,10 @@ function sanitizeKunCapabilitiesConfig(value: unknown): Record
     next.attachments = parseKunConfigSection(AttachmentsCapabilityConfig, raw.attachments)
   }
   if ('memory' in raw) next.memory = parseKunConfigSection(MemoryCapabilityConfig, raw.memory)
+  if ('imageGen' in raw) next.imageGen = parseKunConfigSection(ImageGenCapabilityConfig, raw.imageGen)
+  if ('speechGen' in raw) next.speechGen = parseKunConfigSection(SpeechGenCapabilityConfig, raw.speechGen)
+  if ('musicGen' in raw) next.musicGen = parseKunConfigSection(MusicGenCapabilityConfig, raw.musicGen)
+  if ('videoGen' in raw) next.videoGen = parseKunConfigSection(VideoGenCapabilityConfig, raw.videoGen)
   return next
 }
 
@@ -697,6 +918,7 @@ export async function stopKunChildAndWait(): Promise {
     return
   }
   const stoppingChild = child
+  intentionalStops.add(stoppingChild)
   const pid = child.pid
   const capture = childLogCapture
   if (stoppingChild.exitCode === null && stoppingChild.signalCode === null) {
@@ -746,10 +968,175 @@ export async function reclaimKunPort(
   port: number
 ): Promise<{ ok: true } | { ok: false; message: string }> {
   if (port <= 0) return { ok: true }
-  const available = await canBindTcpPort(port, '127.0.0.1')
-  return available
-    ? { ok: true }
-    : { ok: false, message: `port ${port} is in use` }
+  if (await canBindTcpPort(port, '127.0.0.1')) return { ok: true }
+  if (await killStaleKunOnPort(port) && await canBindTcpPort(port, '127.0.0.1')) {
+    return { ok: true }
+  }
+  return { ok: false, message: `port ${port} is in use` }
+}
+
+export async function resolveAvailableKunPort(
+  preferredPort: number
+): Promise<{ port: number; changed: boolean; message?: string }> {
+  if (preferredPort > 0) {
+    if (await canBindTcpPort(preferredPort, '127.0.0.1')) {
+      return { port: preferredPort, changed: false }
+    }
+    // Prefer reclaiming the configured port from a stale kun left by a
+    // crashed previous app run over silently moving to a new port.
+    if (
+      await killStaleKunOnPort(preferredPort) &&
+      await canBindTcpPort(preferredPort, '127.0.0.1')
+    ) {
+      return { port: preferredPort, changed: false }
+    }
+  }
+  const port = await allocateTcpPort('127.0.0.1')
+  return {
+    port,
+    changed: true,
+    ...(preferredPort > 0 ? { message: `port ${preferredPort} is in use` } : {})
+  }
+}
+
+/**
+ * Kill a stale kun serve process from a previous app run that is still
+ * holding the configured port. Only processes whose command line looks
+ * like our serve entry are touched; anything else keeps the port and we
+ * fall back to allocating a different one.
+ *
+ * Safe by construction on every platform: any failure to positively
+ * identify the holder as our own serve-entry leaves it untouched and the
+ * caller allocates a different port instead.
+ */
+async function killStaleKunOnPort(port: number): Promise {
+  const pids = await listListeningPidsOnPort(port)
+  let reclaimed = false
+  for (const pid of pids) {
+    let command = ''
+    try {
+      command = await processCommandLine(pid)
+    } catch {
+      continue
+    }
+    if (!command.includes('serve-entry')) continue
+    void appendManagedLogLine(
+      'kun',
+      formatKunLogLine('lifecycle', pid, `killing stale kun process holding port ${port}`)
+    )
+    if (await terminateStalePid(pid)) reclaimed = true
+  }
+  return reclaimed
+}
+
+/**
+ * PIDs listening on `port`, excluding our own process. Uses `lsof` on
+ * macOS/Linux and `netstat -ano` on Windows.
+ */
+async function listListeningPidsOnPort(port: number): Promise {
+  if (process.platform === 'win32') {
+    try {
+      const { stdout } = await execFileAsync('netstat', ['-ano'], {
+        windowsHide: true,
+        timeout: 5_000,
+        maxBuffer: 8 * 1024 * 1024
+      })
+      return parseListeningPidsFromNetstat(stdout, port)
+    } catch {
+      return []
+    }
+  }
+  try {
+    const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'])
+    return stdout
+      .split('\n')
+      .map((line) => Number(line.trim()))
+      .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid)
+  } catch {
+    return []
+  }
+}
+
+/**
+ * Parse `netstat -ano` output into the PIDs holding a LISTENING TCP socket
+ * on `port`. Columns are `Proto  Local  Foreign  State  PID`; UDP rows
+ * (no State column) and non-matching ports are ignored. Matches both IPv4
+ * (`127.0.0.1:`) and IPv6 (`[::1]:`) local addresses.
+ */
+export function parseListeningPidsFromNetstat(stdout: string, port: number): number[] {
+  const pids = new Set()
+  for (const raw of stdout.split(/\r?\n/)) {
+    const cols = raw.trim().split(/\s+/)
+    if (cols.length < 5 || cols[0].toUpperCase() !== 'TCP') continue
+    if (cols[3].toUpperCase() !== 'LISTENING') continue
+    if (!cols[1].endsWith(`:${port}`)) continue
+    const pid = Number(cols[cols.length - 1])
+    if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) pids.add(pid)
+  }
+  return [...pids]
+}
+
+/** Read a process's full command line (best effort, platform-specific). */
+async function processCommandLine(pid: number): Promise {
+  if (process.platform === 'win32') {
+    const { stdout } = await execFileAsync(
+      'powershell',
+      [
+        '-NoProfile',
+        '-NonInteractive',
+        '-Command',
+        `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`
+      ],
+      { windowsHide: true, timeout: 5_000 }
+    )
+    return stdout.trim()
+  }
+  const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'command='])
+  return stdout.trim()
+}
+
+/** Terminate a positively-identified stale kun process. */
+async function terminateStalePid(pid: number): Promise {
+  if (process.platform === 'win32') {
+    try {
+      await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
+        windowsHide: true,
+        timeout: 5_000
+      })
+      return true
+    } catch {
+      // taskkill exits non-zero when the PID is already gone — treat the
+      // port as reclaimed only if the process really is no longer alive.
+      return await waitForPidExit(pid, 0)
+    }
+  }
+  try {
+    process.kill(pid, 'SIGTERM')
+  } catch {
+    return false
+  }
+  if (!(await waitForPidExit(pid, 2_000))) {
+    try {
+      process.kill(pid, 'SIGKILL')
+    } catch {
+      /* already gone */
+    }
+    await waitForPidExit(pid, 1_000)
+  }
+  return true
+}
+
+async function waitForPidExit(pid: number, timeoutMs: number): Promise {
+  const deadline = Date.now() + timeoutMs
+  for (;;) {
+    try {
+      process.kill(pid, 0)
+    } catch {
+      return true
+    }
+    if (Date.now() >= deadline) return false
+    await sleep(100)
+  }
 }
 
 function canBindTcpPort(port: number, host: string): Promise {
@@ -770,7 +1157,32 @@ function canBindTcpPort(port: number, host: string): Promise {
   })
 }
 
-async function waitForKunStartup(startedChild: ChildProcess): Promise {
+function allocateTcpPort(host: string): Promise {
+  return new Promise((resolve, reject) => {
+    const server = createServer()
+    const cleanup = (): void => {
+      server.removeAllListeners('error')
+      server.removeAllListeners('listening')
+    }
+    server.unref()
+    server.once('error', (error) => {
+      cleanup()
+      reject(error)
+    })
+    server.listen({ port: 0, host, exclusive: true }, () => {
+      const address = server.address()
+      const port = typeof address === 'object' && address ? address.port : 0
+      server.close((error) => {
+        cleanup()
+        if (error) reject(error)
+        else if (port > 0) resolve(port)
+        else reject(new Error('failed to allocate an available Kun port'))
+      })
+    })
+  })
+}
+
+async function waitForKunStartup(startedChild: ChildProcess, port?: number): Promise {
   if (startedChild.exitCode !== null) {
     throw new Error(describeKunExit(startedChild.exitCode, null))
   }
@@ -778,14 +1190,32 @@ async function waitForKunStartup(startedChild: ChildProcess): Promise {
     let settled = false
     let stdoutBuffer = ''
     let stderrTail = ''
+    let healthProbeInFlight = false
     const timer = setTimeout(() => {
       if (settled) return
       settled = true
       cleanup()
       reject(new Error(describeKunStartupTimeout(stderrTail)))
     }, KUN_STARTUP_TIMEOUT_MS)
+    // The stdout ready marker can lag behind the actual server (pipe
+    // buffering) or get lost in unusual spawn environments; the HTTP
+    // health endpoint is the ground truth, so poll it in parallel.
+    const healthTimer = port
+      ? setInterval(() => {
+          if (settled || healthProbeInFlight) return
+          healthProbeInFlight = true
+          void probeKunHealth(port)
+            .then((healthy) => {
+              if (healthy) settleReady()
+            })
+            .finally(() => {
+              healthProbeInFlight = false
+            })
+        }, KUN_STARTUP_HEALTH_POLL_MS)
+      : null
     const cleanup = (): void => {
       clearTimeout(timer)
+      if (healthTimer) clearInterval(healthTimer)
       startedChild.removeListener('exit', onExit)
       startedChild.removeListener('error', onError)
       startedChild.stdout?.removeListener('data', onStdout)
@@ -853,3 +1283,15 @@ function describeKunStartupTimeout(stderrTail: string): string {
   const suffix = stderrTail.trim() ? `\n${stderrTail.trim()}` : ''
   return `Kun did not report ready within ${KUN_STARTUP_TIMEOUT_MS}ms${suffix}`
 }
+
+async function probeKunHealth(port: number): Promise {
+  try {
+    const response = await fetch(`http://127.0.0.1:${port}/health`, {
+      signal: AbortSignal.timeout(KUN_STARTUP_HEALTH_REQUEST_TIMEOUT_MS)
+    })
+    if (!response.ok) return false
+    return isKunHealthResponseBody(await response.text())
+  } catch {
+    return false
+  }
+}
diff --git a/src/main/kun-runtime-supervisor.test.ts b/src/main/kun-runtime-supervisor.test.ts
new file mode 100644
index 00000000..e0984ab4
--- /dev/null
+++ b/src/main/kun-runtime-supervisor.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest'
+import { RestartBudget } from './kun-runtime-supervisor'
+
+function budgetAt(times: { value: number }): RestartBudget {
+  return new RestartBudget({
+    windowMs: 60_000,
+    maxRestarts: 3,
+    baseDelayMs: 1_000,
+    delayFactor: 3,
+    now: () => times.value
+  })
+}
+
+describe('RestartBudget', () => {
+  it('allows up to maxRestarts attempts with exponential backoff delays', () => {
+    const clock = { value: 0 }
+    const budget = budgetAt(clock)
+
+    expect(budget.note()).toEqual({ allowed: true, attempt: 1, delayMs: 1_000 })
+    clock.value += 1_000
+    expect(budget.note()).toEqual({ allowed: true, attempt: 2, delayMs: 3_000 })
+    clock.value += 1_000
+    expect(budget.note()).toEqual({ allowed: true, attempt: 3, delayMs: 9_000 })
+  })
+
+  it('circuit-breaks once the window is saturated', () => {
+    const clock = { value: 0 }
+    const budget = budgetAt(clock)
+    budget.note()
+    budget.note()
+    budget.note()
+
+    const verdict = budget.note()
+    expect(verdict.allowed).toBe(false)
+    expect(verdict.delayMs).toBe(0)
+  })
+
+  it('frees attempts as they age out of the sliding window', () => {
+    const clock = { value: 0 }
+    const budget = budgetAt(clock)
+    budget.note()
+    budget.note()
+    budget.note()
+    expect(budget.note().allowed).toBe(false)
+
+    clock.value = 60_001
+    const verdict = budget.note()
+    expect(verdict.allowed).toBe(true)
+    expect(verdict.attempt).toBe(1)
+    expect(verdict.delayMs).toBe(1_000)
+  })
+
+  it('reset() clears the window so the next crash starts fresh', () => {
+    const clock = { value: 0 }
+    const budget = budgetAt(clock)
+    budget.note()
+    budget.note()
+    budget.reset()
+
+    const verdict = budget.note()
+    expect(verdict).toEqual({ allowed: true, attempt: 1, delayMs: 1_000 })
+  })
+})
diff --git a/src/main/kun-runtime-supervisor.ts b/src/main/kun-runtime-supervisor.ts
new file mode 100644
index 00000000..330c67c5
--- /dev/null
+++ b/src/main/kun-runtime-supervisor.ts
@@ -0,0 +1,67 @@
+/**
+ * Crash-loop budget and status contract for the GUI-managed Kun
+ * runtime. The supervisor in index.ts consumes these to auto-restart a
+ * crashed runtime with backoff, and to stop retrying (circuit break)
+ * when the runtime is crashing faster than it can recover.
+ */
+
+import type { KunRuntimeStatusPayload } from '../shared/kun-gui-api'
+
+/** Shared with preload/renderer; the payload travels over `runtime:status`. */
+export type KunRuntimeStatus = KunRuntimeStatusPayload
+
+export type RestartVerdict =
+  | { allowed: true; attempt: number; delayMs: number }
+  | { allowed: false; attempt: number; delayMs: 0 }
+
+export type RestartBudgetOptions = {
+  windowMs: number
+  maxRestarts: number
+  baseDelayMs?: number
+  delayFactor?: number
+  now?: () => number
+}
+
+/**
+ * Sliding-window restart budget: allows up to `maxRestarts` attempts per
+ * `windowMs`, with exponential backoff delays (base, base*factor, ...).
+ * Once the window is saturated the caller should circuit-break and wait
+ * for a manual restart instead of burning CPU on a crash loop.
+ */
+export class RestartBudget {
+  private readonly windowMs: number
+  private readonly maxRestarts: number
+  private readonly baseDelayMs: number
+  private readonly delayFactor: number
+  private readonly now: () => number
+  private attempts: number[] = []
+
+  constructor(options: RestartBudgetOptions) {
+    this.windowMs = Math.max(1, options.windowMs)
+    this.maxRestarts = Math.max(1, options.maxRestarts)
+    this.baseDelayMs = Math.max(0, options.baseDelayMs ?? 1_000)
+    this.delayFactor = Math.max(1, options.delayFactor ?? 3)
+    this.now = options.now ?? (() => Date.now())
+  }
+
+  /** Ask for one restart attempt; records it when allowed. */
+  note(): RestartVerdict {
+    const at = this.now()
+    this.attempts = this.attempts.filter((t) => at - t < this.windowMs)
+    if (this.attempts.length >= this.maxRestarts) {
+      return { allowed: false, attempt: this.attempts.length, delayMs: 0 }
+    }
+    this.attempts.push(at)
+    const attempt = this.attempts.length
+    return {
+      allowed: true,
+      attempt,
+      delayMs: Math.round(this.baseDelayMs * Math.pow(this.delayFactor, attempt - 1))
+    }
+  }
+
+  /** Forget past attempts after the runtime proved stable again. */
+  reset(): void {
+    this.attempts = []
+  }
+}
diff --git a/src/main/legacy-data-migration.test.ts b/src/main/legacy-data-migration.test.ts
new file mode 100644
index 00000000..ab1c03a3
--- /dev/null
+++ b/src/main/legacy-data-migration.test.ts
@@ -0,0 +1,303 @@
+import { lstat, mkdir, mkdtemp, readFile, readlink, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  HOME_DATA_MIGRATION_MAPPINGS,
+  migrateLegacyHomeDataDirs,
+  migrateLegacyUserDataDir,
+  rewriteLegacyPathsInSettingsFile,
+  runLegacyKunDataMigration,
+  USER_DATA_MIGRATION_MARKER
+} from './legacy-data-migration'
+
+const tempRoots: string[] = []
+
+async function makeTempRoot(): Promise {
+  const root = await mkdtemp(join(tmpdir(), 'kun-migration-'))
+  tempRoots.push(root)
+  return root
+}
+
+afterEach(async () => {
+  while (tempRoots.length > 0) {
+    const root = tempRoots.pop()
+    if (root) await rm(root, { recursive: true, force: true })
+  }
+})
+
+async function isSymlinkTo(path: string, target: string): Promise {
+  const stats = await lstat(path)
+  if (!stats.isSymbolicLink()) return false
+  return (await readlink(path)) === target
+}
+
+describe('migrateLegacyUserDataDir', () => {
+  it('renames the legacy dir to the new name and leaves a compatibility link', async () => {
+    const appData = await makeTempRoot()
+    const legacy = join(appData, 'DeepSeek GUI')
+    await mkdir(join(legacy, 'Local Storage'), { recursive: true })
+    await writeFile(join(legacy, 'deepseek-gui-settings.json'), '{"version":1}', 'utf8')
+
+    const result = migrateLegacyUserDataDir({ userDataPath: join(appData, 'Kun') })
+
+    expect(result).toEqual({ userDataPath: join(appData, 'Kun'), migrated: true, usedLegacyFallback: false })
+    expect(await readFile(join(appData, 'Kun', 'deepseek-gui-settings.json'), 'utf8')).toBe('{"version":1}')
+    expect(await isSymlinkTo(legacy, join(appData, 'Kun'))).toBe(true)
+    const marker = JSON.parse(await readFile(join(appData, 'Kun', USER_DATA_MIGRATION_MARKER), 'utf8'))
+    expect(marker.from).toBe(legacy)
+  })
+
+  it('prefers the newer legacy name and leaves the ancient one untouched', async () => {
+    const appData = await makeTempRoot()
+    await mkdir(join(appData, 'DeepSeek GUI'), { recursive: true })
+    await writeFile(join(appData, 'DeepSeek GUI', 'a.txt'), 'recent', 'utf8')
+    await mkdir(join(appData, 'deepseek-gui'), { recursive: true })
+    await writeFile(join(appData, 'deepseek-gui', 'a.txt'), 'ancient', 'utf8')
+
+    const result = migrateLegacyUserDataDir({ userDataPath: join(appData, 'Kun') })
+
+    expect(result.migrated).toBe(true)
+    expect(await readFile(join(appData, 'Kun', 'a.txt'), 'utf8')).toBe('recent')
+    expect((await lstat(join(appData, 'deepseek-gui'))).isDirectory()).toBe(true)
+    expect((await lstat(join(appData, 'deepseek-gui'))).isSymbolicLink()).toBe(false)
+  })
+
+  it('keeps an existing non-empty new dir and does not touch legacy data', async () => {
+    const appData = await makeTempRoot()
+    await mkdir(join(appData, 'Kun'), { recursive: true })
+    await writeFile(join(appData, 'Kun', 'kun-settings.json'), '{}', 'utf8')
+    await mkdir(join(appData, 'DeepSeek GUI'), { recursive: true })
+
+    const result = migrateLegacyUserDataDir({ userDataPath: join(appData, 'Kun') })
+
+    expect(result).toEqual({ userDataPath: join(appData, 'Kun'), migrated: false, usedLegacyFallback: false })
+    expect((await lstat(join(appData, 'DeepSeek GUI'))).isDirectory()).toBe(true)
+  })
+
+  it('replaces an empty new dir left behind by a previous run', async () => {
+    const appData = await makeTempRoot()
+    await mkdir(join(appData, 'Kun'), { recursive: true })
+    const legacy = join(appData, 'DeepSeek GUI')
+    await mkdir(legacy, { recursive: true })
+    await writeFile(join(legacy, 'a.txt'), 'data', 'utf8')
+
+    const result = migrateLegacyUserDataDir({ userDataPath: join(appData, 'Kun') })
+
+    expect(result.migrated).toBe(true)
+    expect(await readFile(join(appData, 'Kun', 'a.txt'), 'utf8')).toBe('data')
+  })
+
+  it('is a no-op on fresh installs', async () => {
+    const appData = await makeTempRoot()
+    const result = migrateLegacyUserDataDir({ userDataPath: join(appData, 'Kun') })
+    expect(result).toEqual({ userDataPath: join(appData, 'Kun'), migrated: false, usedLegacyFallback: false })
+  })
+
+  it('does not migrate twice once the legacy path is a link', async () => {
+    const appData = await makeTempRoot()
+    const legacy = join(appData, 'DeepSeek GUI')
+    await mkdir(legacy, { recursive: true })
+    await writeFile(join(legacy, 'a.txt'), 'data', 'utf8')
+
+    const first = migrateLegacyUserDataDir({ userDataPath: join(appData, 'Kun') })
+    expect(first.migrated).toBe(true)
+    const second = migrateLegacyUserDataDir({ userDataPath: join(appData, 'Kun') })
+    expect(second.migrated).toBe(false)
+    expect(second.userDataPath).toBe(join(appData, 'Kun'))
+  })
+})
+
+describe('migrateLegacyHomeDataDirs', () => {
+  it('moves all known legacy dirs under ~/.kun and links the old locations', async () => {
+    const home = await makeTempRoot()
+    for (const child of ['kun', 'default_workspace', 'claw', 'write_workspace']) {
+      await mkdir(join(home, '.deepseekgui', child), { recursive: true })
+      await writeFile(join(home, '.deepseekgui', child, 'marker.txt'), child, 'utf8')
+    }
+
+    const results = migrateLegacyHomeDataDirs({ homeDir: home })
+
+    expect(results.map((r) => r.outcome)).toEqual(['migrated', 'migrated', 'migrated', 'migrated'])
+    expect(await readFile(join(home, '.kun', 'data', 'marker.txt'), 'utf8')).toBe('kun')
+    expect(await readFile(join(home, '.kun', 'claw', 'marker.txt'), 'utf8')).toBe('claw')
+    expect(await isSymlinkTo(join(home, '.deepseekgui', 'kun'), join(home, '.kun', 'data'))).toBe(true)
+    expect(await isSymlinkTo(join(home, '.deepseekgui', 'claw'), join(home, '.kun', 'claw'))).toBe(true)
+    // 旧路径透过链接仍然可读(老版本回滚、sqlite 里的旧绝对路径都靠它)。
+    expect(await readFile(join(home, '.deepseekgui', 'kun', 'marker.txt'), 'utf8')).toBe('kun')
+    expect(await readFile(join(home, '.deepseekgui', 'MIGRATED.txt'), 'utf8')).toContain('.kun')
+  })
+
+  it('merges into an existing ~/.kun without touching its other children', async () => {
+    const home = await makeTempRoot()
+    await mkdir(join(home, '.kun', 'skills'), { recursive: true })
+    await writeFile(join(home, '.kun', 'config.toml'), 'x = 1', 'utf8')
+    await mkdir(join(home, '.deepseekgui', 'kun'), { recursive: true })
+    await writeFile(join(home, '.deepseekgui', 'kun', 'db.sqlite'), 'db', 'utf8')
+
+    const results = migrateLegacyHomeDataDirs({ homeDir: home })
+
+    const kunMapping = results.find((r) => r.nextPath === join(home, '.kun', 'data'))
+    expect(kunMapping?.outcome).toBe('migrated')
+    expect(await readFile(join(home, '.kun', 'data', 'db.sqlite'), 'utf8')).toBe('db')
+    expect(await readFile(join(home, '.kun', 'config.toml'), 'utf8')).toBe('x = 1')
+  })
+
+  it('leaves both dirs alone when old and new both contain data', async () => {
+    const home = await makeTempRoot()
+    await mkdir(join(home, '.deepseekgui', 'claw'), { recursive: true })
+    await writeFile(join(home, '.deepseekgui', 'claw', 'old.txt'), 'old', 'utf8')
+    await mkdir(join(home, '.kun', 'claw'), { recursive: true })
+    await writeFile(join(home, '.kun', 'claw', 'new.txt'), 'new', 'utf8')
+
+    const results = migrateLegacyHomeDataDirs({ homeDir: home })
+
+    const clawMapping = results.find((r) => r.nextPath === join(home, '.kun', 'claw'))
+    expect(clawMapping?.outcome).toBe('next-exists')
+    expect(clawMapping?.rewriteSafe).toBe(false)
+    expect(await readFile(join(home, '.deepseekgui', 'claw', 'old.txt'), 'utf8')).toBe('old')
+    expect(await readFile(join(home, '.kun', 'claw', 'new.txt'), 'utf8')).toBe('new')
+  })
+
+  it('replaces an empty new home dir with migrated legacy data', async () => {
+    const home = await makeTempRoot()
+    await mkdir(join(home, '.deepseekgui', 'kun'), { recursive: true })
+    await writeFile(join(home, '.deepseekgui', 'kun', 'db.sqlite'), 'db', 'utf8')
+    await mkdir(join(home, '.kun', 'data'), { recursive: true })
+
+    const results = migrateLegacyHomeDataDirs({ homeDir: home })
+
+    const kunMapping = results.find((r) => r.nextPath === join(home, '.kun', 'data'))
+    expect(kunMapping?.outcome).toBe('migrated')
+    expect(kunMapping?.rewriteSafe).toBe(true)
+    expect(await readFile(join(home, '.kun', 'data', 'db.sqlite'), 'utf8')).toBe('db')
+    expect(await isSymlinkTo(join(home, '.deepseekgui', 'kun'), join(home, '.kun', 'data'))).toBe(true)
+  })
+
+  it('reports missing legacy dirs as rewrite-safe no-ops', async () => {
+    const home = await makeTempRoot()
+    const results = migrateLegacyHomeDataDirs({ homeDir: home })
+    expect(results.every((r) => r.outcome === 'skipped-missing' && r.rewriteSafe)).toBe(true)
+  })
+})
+
+describe('rewriteLegacyPathsInSettingsFile', () => {
+  it('rewrites absolute and tilde paths only for the given mappings', async () => {
+    const home = await makeTempRoot()
+    const userData = join(home, 'userData')
+    await mkdir(userData, { recursive: true })
+    const settings = {
+      workspaceRoot: join(home, '.deepseekgui', 'default_workspace'),
+      agents: { kun: { dataDir: '~/.deepseekgui/kun' } },
+      write: {
+        workspaces: [
+          join(home, '.deepseekgui', 'write_workspace'),
+          join(home, '.deepseekgui', 'custom_dir')
+        ]
+      },
+      claw: {
+        channels: [
+          { workspaceRoot: join(home, '.deepseekgui', 'claw', 'feishu', 'app1') }
+        ]
+      },
+      codePromptPrefix: 'mentions /.deepseekgui/kun casually'
+    }
+    await writeFile(join(userData, 'deepseek-gui-settings.json'), JSON.stringify(settings), 'utf8')
+
+    const rewritten = rewriteLegacyPathsInSettingsFile({
+      userDataPath: userData,
+      homeDir: home,
+      mappings: HOME_DATA_MIGRATION_MAPPINGS.filter(
+        (m) => m.legacySegments[1] !== 'write_workspace'
+      )
+    })
+
+    expect(rewritten).toBe(true)
+    const updated = JSON.parse(await readFile(join(userData, 'deepseek-gui-settings.json'), 'utf8'))
+    expect(updated.workspaceRoot).toBe(join(home, '.kun', 'default_workspace'))
+    expect(updated.agents.kun.dataDir).toBe('~/.kun/data')
+    expect(updated.claw.channels[0].workspaceRoot).toBe(join(home, '.kun', 'claw', 'feishu', 'app1'))
+    // write_workspace 映射没有传入 → 不重写;未知子目录永远不重写。
+    expect(updated.write.workspaces[0]).toBe(join(home, '.deepseekgui', 'write_workspace'))
+    expect(updated.write.workspaces[1]).toBe(join(home, '.deepseekgui', 'custom_dir'))
+    // 非路径文本(前缀不是完整映射路径)不受影响。
+    expect(updated.codePromptPrefix).toBe('mentions /.deepseekgui/kun casually')
+  })
+
+  it('leaves invalid JSON untouched', async () => {
+    const home = await makeTempRoot()
+    const userData = join(home, 'userData')
+    await mkdir(userData, { recursive: true })
+    await writeFile(join(userData, 'kun-settings.json'), '{not json', 'utf8')
+
+    const rewritten = rewriteLegacyPathsInSettingsFile({
+      userDataPath: userData,
+      homeDir: home,
+      mappings: HOME_DATA_MIGRATION_MAPPINGS
+    })
+
+    expect(rewritten).toBe(false)
+    expect(await readFile(join(userData, 'kun-settings.json'), 'utf8')).toBe('{not json')
+  })
+
+  it('rewrites absolute paths even when separators differ', async () => {
+    const home = await makeTempRoot()
+    const userData = join(home, 'userData')
+    await mkdir(userData, { recursive: true })
+    const legacyWindowsish = join(home, '.deepseekgui', 'kun').replace(/\//g, '\\')
+    await writeFile(
+      join(userData, 'kun-settings.json'),
+      JSON.stringify({ agents: { kun: { dataDir: `${legacyWindowsish}\\sessions` } } }),
+      'utf8'
+    )
+
+    const rewritten = rewriteLegacyPathsInSettingsFile({
+      userDataPath: userData,
+      homeDir: home,
+      mappings: HOME_DATA_MIGRATION_MAPPINGS
+    })
+
+    expect(rewritten).toBe(true)
+    const updated = JSON.parse(await readFile(join(userData, 'kun-settings.json'), 'utf8'))
+    expect(updated.agents.kun.dataDir).toBe(`${join(home, '.kun', 'data').replace(/\//g, '\\')}\\sessions`)
+  })
+})
+
+describe('runLegacyKunDataMigration', () => {
+  it('migrates userData, home dirs, and rewrites settings in one pass', async () => {
+    const root = await makeTempRoot()
+    const home = join(root, 'home')
+    const appData = join(root, 'appData')
+    const legacyUserData = join(appData, 'DeepSeek GUI')
+    await mkdir(legacyUserData, { recursive: true })
+    await mkdir(join(home, '.deepseekgui', 'kun'), { recursive: true })
+    await writeFile(join(home, '.deepseekgui', 'kun', 'db.sqlite'), 'db', 'utf8')
+    await writeFile(
+      join(legacyUserData, 'deepseek-gui-settings.json'),
+      JSON.stringify({
+        version: 1,
+        workspaceRoot: join(home, '.deepseekgui', 'default_workspace'),
+        agents: { kun: { dataDir: '~/.deepseekgui/kun' } }
+      }),
+      'utf8'
+    )
+
+    const result = runLegacyKunDataMigration({ userDataPath: join(appData, 'Kun'), homeDir: home })
+
+    expect(result.userData.migrated).toBe(true)
+    expect(result.userData.usedLegacyFallback).toBe(false)
+    expect(result.settingsRewritten).toBe(true)
+    const settings = JSON.parse(
+      await readFile(join(appData, 'Kun', 'deepseek-gui-settings.json'), 'utf8')
+    )
+    expect(settings.agents.kun.dataDir).toBe('~/.kun/data')
+    expect(settings.workspaceRoot).toBe(join(home, '.kun', 'default_workspace'))
+    expect(await readFile(join(home, '.kun', 'data', 'db.sqlite'), 'utf8')).toBe('db')
+  })
+
+  it('never throws even when nothing exists', () => {
+    expect(() =>
+      runLegacyKunDataMigration({ userDataPath: join('/nonexistent-root', 'Kun'), homeDir: '/nonexistent-home' })
+    ).not.toThrow()
+  })
+})
diff --git a/src/main/legacy-data-migration.ts b/src/main/legacy-data-migration.ts
new file mode 100644
index 00000000..7cec8d12
--- /dev/null
+++ b/src/main/legacy-data-migration.ts
@@ -0,0 +1,486 @@
+import {
+  lstatSync,
+  mkdirSync,
+  readdirSync,
+  readFileSync,
+  renameSync,
+  rmdirSync,
+  symlinkSync,
+  writeFileSync
+} from 'node:fs'
+import { basename, dirname, join } from 'node:path'
+
+/**
+ * 一次性把 “DeepSeek GUI” 时代的本地数据搬到 Kun 的新命名下。
+ *
+ * 设计约束(都来自“老版本必须无痛升级、还要能回滚”):
+ *   1. 整目录 rename 而不是逐文件拷贝 —— userData 里有 Chromium 的
+ *      Local Storage / IndexedDB / Partitions,半拷贝状态比不迁移更糟。
+ *   2. 旧路径留符号链接(Windows 用 junction,无需管理员权限)。这样:
+ *        - 设置 / kun sqlite 里残留的旧绝对路径仍然可以解析;
+ *        - 用户回滚到老版本时,老版本透过链接复用同一份数据;
+ *        - 老版本和新版本透过同一个 userData 抢同一把单实例锁,
+ *          不会出现两个进程同时写一份 sqlite。
+ *   3. 任何一步失败都要降级成“继续用旧路径”,绝不能让启动失败
+ *      或让数据看起来消失。settings 里的路径只在对应目录确实搬走
+ *      之后才重写(rewriteSafe 把关)。
+ *
+ * 这个模块刻意不 import electron,方便在 vitest 里直接注入临时目录测试。
+ */
+
+export type MigrationLogger = (message: string, detail?: unknown) => void
+
+/** 旧版 userData 目录名。顺序即优先级:先匹配近期版本用的名字。 */
+export const LEGACY_USER_DATA_DIR_NAMES = ['DeepSeek GUI', 'deepseek-gui'] as const
+
+export const LEGACY_HOME_DATA_ROOT = '.deepseekgui'
+export const NEW_HOME_DATA_ROOT = '.kun'
+
+export type HomeDataMigrationMapping = {
+  /** 相对 home 的旧路径段,如 ['.deepseekgui', 'kun'] */
+  legacySegments: readonly string[]
+  /** 相对 home 的新路径段,如 ['.kun', 'data'] */
+  nextSegments: readonly string[]
+}
+
+/**
+ * 家目录数据的搬迁映射。只搬这几个我们自己创建的已知目录;
+ * 用户手工放进 ~/.deepseekgui 的其它内容原地保留,settings 里
+ * 指向它们的路径也不会被重写。
+ */
+export const HOME_DATA_MIGRATION_MAPPINGS: readonly HomeDataMigrationMapping[] = [
+  // kun 运行时数据(sqlite、线程、config.json)。新家叫 data,避免 ~/.kun/kun。
+  { legacySegments: [LEGACY_HOME_DATA_ROOT, 'kun'], nextSegments: [NEW_HOME_DATA_ROOT, 'data'] },
+  { legacySegments: [LEGACY_HOME_DATA_ROOT, 'default_workspace'], nextSegments: [NEW_HOME_DATA_ROOT, 'default_workspace'] },
+  { legacySegments: [LEGACY_HOME_DATA_ROOT, 'claw'], nextSegments: [NEW_HOME_DATA_ROOT, 'claw'] },
+  { legacySegments: [LEGACY_HOME_DATA_ROOT, 'write_workspace'], nextSegments: [NEW_HOME_DATA_ROOT, 'write_workspace'] }
+] as const
+
+export type HomeMappingOutcome =
+  | 'migrated'
+  | 'already-linked'
+  | 'next-exists'
+  | 'skipped-missing'
+  | 'failed'
+
+export type HomeDataMigrationResult = {
+  mapping: HomeDataMigrationMapping
+  legacyPath: string
+  nextPath: string
+  outcome: HomeMappingOutcome
+  /** true = settings 中指向 legacyPath 的字符串可以安全改写成 nextPath。 */
+  rewriteSafe: boolean
+}
+
+export type UserDataMigrationResult = {
+  /** 迁移后应当使用的 userData 路径。 */
+  userDataPath: string
+  /** 本次启动真的把旧目录搬过来了。 */
+  migrated: boolean
+  /** rename 失败,需要 app.setPath('userData', userDataPath) 退回旧目录。 */
+  usedLegacyFallback: boolean
+}
+
+export type LegacyDataMigrationResult = {
+  userData: UserDataMigrationResult
+  home: HomeDataMigrationResult[]
+  settingsRewritten: boolean
+}
+
+/** 迁移完成后写进新 userData 的标记文件,只用于排障。 */
+export const USER_DATA_MIGRATION_MARKER = '.migrated-from-deepseek-gui.json'
+
+const SETTINGS_FILE_NAME_NEW = 'kun-settings.json'
+const SETTINGS_FILE_NAME_LEGACY = 'deepseek-gui-settings.json'
+
+type PathState = 'missing' | 'symlink' | 'dir' | 'other'
+
+function pathState(path: string): PathState {
+  try {
+    const stats = lstatSync(path)
+    if (stats.isSymbolicLink()) return 'symlink'
+    if (stats.isDirectory()) return 'dir'
+    return 'other'
+  } catch {
+    return 'missing'
+  }
+}
+
+function noopLog(): void {}
+
+function removeEmptyDirIfPresent(path: string, log: MigrationLogger): boolean {
+  if (pathState(path) !== 'dir') return false
+  let entries: string[]
+  try {
+    entries = readdirSync(path)
+  } catch {
+    return false
+  }
+  if (entries.length > 0) return false
+  try {
+    rmdirSync(path)
+    return true
+  } catch (error) {
+    log('legacy-migration: could not remove empty new dir', {
+      path,
+      message: error instanceof Error ? error.message : String(error)
+    })
+    return false
+  }
+}
+
+/**
+ * 在 legacyPath 留一个指向 targetPath 的目录链接。
+ * Windows 上用 junction:普通用户就能创建,且对目录语义等价。
+ */
+function tryLinkLegacyPath(legacyPath: string, targetPath: string, log: MigrationLogger): boolean {
+  try {
+    symlinkSync(targetPath, legacyPath, process.platform === 'win32' ? 'junction' : 'dir')
+    return true
+  } catch (error) {
+    log('legacy-migration: failed to create compatibility link', {
+      legacyPath,
+      targetPath,
+      message: error instanceof Error ? error.message : String(error)
+    })
+    return false
+  }
+}
+
+export function migrateLegacyUserDataDir(input: {
+  /** electron app.getPath('userData') —— 已经是新名字(…/Kun)。 */
+  userDataPath: string
+  legacyDirNames?: readonly string[]
+  log?: MigrationLogger
+}): UserDataMigrationResult {
+  const log = input.log ?? noopLog
+  const newPath = input.userDataPath
+  const appDataDir = dirname(newPath)
+  const newDirName = basename(newPath)
+  const legacyNames = (input.legacyDirNames ?? LEGACY_USER_DATA_DIR_NAMES).filter(
+    (name) => name !== newDirName
+  )
+
+  const keepNew: UserDataMigrationResult = { userDataPath: newPath, migrated: false, usedLegacyFallback: false }
+
+  const newState = pathState(newPath)
+  if (newState === 'dir') {
+    let entries: string[]
+    try {
+      entries = readdirSync(newPath)
+    } catch {
+      return keepNew
+    }
+    if (entries.length > 0) return keepNew
+    // 空目录可能是早先某次启动留下的壳,移掉后允许迁移真正的数据进来。
+    try {
+      rmdirSync(newPath)
+    } catch (error) {
+      log('legacy-migration: could not remove empty new userData dir', {
+        newPath,
+        message: error instanceof Error ? error.message : String(error)
+      })
+      return keepNew
+    }
+  } else if (newState !== 'missing') {
+    // symlink / 文件:用户自己布置过,尊重现状。
+    return keepNew
+  }
+
+  for (const legacyName of legacyNames) {
+    const legacyPath = join(appDataDir, legacyName)
+    if (pathState(legacyPath) !== 'dir') continue
+
+    try {
+      renameSync(legacyPath, newPath)
+    } catch (error) {
+      // 最常见原因:老版本还在运行(Windows 文件锁)。退回旧目录,
+      // 一切照旧工作,下次启动再尝试迁移。
+      log('legacy-migration: rename of legacy userData failed; staying on legacy dir', {
+        legacyPath,
+        newPath,
+        message: error instanceof Error ? error.message : String(error)
+      })
+      return { userDataPath: legacyPath, migrated: false, usedLegacyFallback: true }
+    }
+
+    try {
+      writeFileSync(
+        join(newPath, USER_DATA_MIGRATION_MARKER),
+        JSON.stringify({ from: legacyPath, at: new Date().toISOString() }, null, 2),
+        'utf8'
+      )
+    } catch {
+      // 标记文件只是排障辅助,写失败不影响迁移结果。
+    }
+
+    tryLinkLegacyPath(legacyPath, newPath, log)
+    log('legacy-migration: migrated userData dir', { from: legacyPath, to: newPath })
+    return { userDataPath: newPath, migrated: true, usedLegacyFallback: false }
+  }
+
+  return keepNew
+}
+
+export function migrateLegacyHomeDataDirs(input: {
+  homeDir: string
+  mappings?: readonly HomeDataMigrationMapping[]
+  log?: MigrationLogger
+}): HomeDataMigrationResult[] {
+  const log = input.log ?? noopLog
+  const mappings = input.mappings ?? HOME_DATA_MIGRATION_MAPPINGS
+  const results: HomeDataMigrationResult[] = []
+
+  for (const mapping of mappings) {
+    const legacyPath = join(input.homeDir, ...mapping.legacySegments)
+    const nextPath = join(input.homeDir, ...mapping.nextSegments)
+    const base = { mapping, legacyPath, nextPath }
+
+    const legacyState = pathState(legacyPath)
+    if (legacyState === 'symlink') {
+      // 之前某次启动已经搬过并留了链接。
+      results.push({ ...base, outcome: 'already-linked', rewriteSafe: true })
+      continue
+    }
+    if (legacyState === 'missing' || legacyState === 'other') {
+      // 没有旧数据要搬;旧路径即便残留在 settings 里,重写后也只是
+      // 让后续 mkdir 落到新位置,不会丢任何东西。
+      results.push({ ...base, outcome: 'skipped-missing', rewriteSafe: true })
+      continue
+    }
+
+    if (pathState(nextPath) === 'dir') {
+      removeEmptyDirIfPresent(nextPath, log)
+    }
+
+    if (pathState(nextPath) !== 'missing') {
+      // 新旧同时存在,无法判断哪份是权威数据,原地都保留。
+      // settings 里指向旧路径的字符串保持原样,数据继续可用。
+      log('legacy-migration: both legacy and new home dir exist; leaving both untouched', base)
+      results.push({ ...base, outcome: 'next-exists', rewriteSafe: false })
+      continue
+    }
+
+    try {
+      mkdirSync(dirname(nextPath), { recursive: true })
+      renameSync(legacyPath, nextPath)
+    } catch (error) {
+      log('legacy-migration: failed to move legacy home dir; keeping legacy path', {
+        ...base,
+        message: error instanceof Error ? error.message : String(error)
+      })
+      results.push({ ...base, outcome: 'failed', rewriteSafe: false })
+      continue
+    }
+
+    if (!tryLinkLegacyPath(legacyPath, nextPath, log)) {
+      // 链接建不起来时优先保证旧绝对路径(kun sqlite 里的线程 cwd、
+      // 同步出去的 config.json 等)继续有效:把目录搬回去,本机放弃改名。
+      try {
+        renameSync(nextPath, legacyPath)
+        results.push({ ...base, outcome: 'failed', rewriteSafe: false })
+        continue
+      } catch (error) {
+        // 搬不回去:数据已经在新位置,只能靠 settings 重写把引用修正过来。
+        log('legacy-migration: could not restore legacy dir after link failure', {
+          ...base,
+          message: error instanceof Error ? error.message : String(error)
+        })
+      }
+    }
+
+    log('legacy-migration: migrated home data dir', { from: legacyPath, to: nextPath })
+    results.push({ ...base, outcome: 'migrated', rewriteSafe: true })
+  }
+
+  const legacyRoot = join(input.homeDir, LEGACY_HOME_DATA_ROOT)
+  if (results.some((r) => r.outcome === 'migrated') && pathState(legacyRoot) === 'dir') {
+    try {
+      writeFileSync(
+        join(legacyRoot, 'MIGRATED.txt'),
+        `This data has moved to ~/${NEW_HOME_DATA_ROOT}. The remaining entries are\n` +
+          'compatibility links kept so older app versions and stored absolute\n' +
+          'paths keep working. Safe to delete once you no longer run the old\n' +
+          'DeepSeek GUI builds.\n',
+        'utf8'
+      )
+    } catch {
+      // 说明文件写失败无关紧要。
+    }
+  }
+
+  return results
+}
+
+type ReplacementPair = { from: string; to: string }
+
+function addReplacementPair(pairs: ReplacementPair[], from: string, to: string): void {
+  if (!from || pairs.some((pair) => pair.from === from && pair.to === to)) return
+  pairs.push({ from, to })
+}
+
+function buildReplacementPairs(homeDir: string, mappings: readonly HomeDataMigrationMapping[]): ReplacementPair[] {
+  const pairs: ReplacementPair[] = []
+  for (const mapping of mappings) {
+    const legacyAbs = join(homeDir, ...mapping.legacySegments)
+    const nextAbs = join(homeDir, ...mapping.nextSegments)
+    addReplacementPair(pairs, legacyAbs, nextAbs)
+    addReplacementPair(pairs, legacyAbs.replace(/\\/g, '/'), nextAbs.replace(/\\/g, '/'))
+    addReplacementPair(pairs, legacyAbs.replace(/\//g, '\\'), nextAbs.replace(/\//g, '\\'))
+    // settings 里也可能存的是 ~ 前缀形式(例如 dataDir 的默认值)。
+    addReplacementPair(pairs, `~/${mapping.legacySegments.join('/')}`, `~/${mapping.nextSegments.join('/')}`)
+    addReplacementPair(pairs, `~\\${mapping.legacySegments.join('\\')}`, `~\\${mapping.nextSegments.join('\\')}`)
+  }
+  return pairs
+}
+
+function rewriteStringValue(value: string, pairs: readonly ReplacementPair[]): string {
+  for (const pair of pairs) {
+    if (value === pair.from) return pair.to
+    if (value.startsWith(pair.from)) {
+      const boundary = value.charAt(pair.from.length)
+      if (boundary === '/' || boundary === '\\') {
+        return pair.to + value.slice(pair.from.length)
+      }
+    }
+  }
+  return value
+}
+
+function rewriteDeep(value: unknown, pairs: readonly ReplacementPair[]): { value: unknown; changed: boolean } {
+  if (typeof value === 'string') {
+    const next = rewriteStringValue(value, pairs)
+    return { value: next, changed: next !== value }
+  }
+  if (Array.isArray(value)) {
+    let changed = false
+    const next = value.map((item) => {
+      const result = rewriteDeep(item, pairs)
+      changed = changed || result.changed
+      return result.value
+    })
+    return { value: changed ? next : value, changed }
+  }
+  if (typeof value === 'object' && value !== null) {
+    let changed = false
+    const next: Record = {}
+    for (const [key, item] of Object.entries(value)) {
+      const result = rewriteDeep(item, pairs)
+      changed = changed || result.changed
+      next[key] = result.value
+    }
+    return { value: changed ? next : value, changed }
+  }
+  return { value, changed: false }
+}
+
+/**
+ * 把 settings JSON 里指向已迁移目录的旧路径(绝对路径和 ~ 形式)改写
+ * 成新路径。只处理传入的 rewriteSafe 映射;解析失败时不动文件,交给
+ * settings-store 既有的 invalid-backup 流程。
+ */
+export function rewriteLegacyPathsInSettingsFile(input: {
+  userDataPath: string
+  homeDir: string
+  mappings: readonly HomeDataMigrationMapping[]
+  log?: MigrationLogger
+}): boolean {
+  const log = input.log ?? noopLog
+  if (input.mappings.length === 0) return false
+
+  const candidates = [
+    join(input.userDataPath, SETTINGS_FILE_NAME_NEW),
+    join(input.userDataPath, SETTINGS_FILE_NAME_LEGACY)
+  ]
+  const pairs = buildReplacementPairs(input.homeDir, input.mappings)
+
+  let rewrote = false
+  for (const settingsPath of candidates) {
+    // lstat 对常规文件返回 'other';missing/dir 跳过,符号链接跟随读取。
+    const state = pathState(settingsPath)
+    if (state !== 'other' && state !== 'symlink') continue
+
+    let raw: string
+    try {
+      raw = readFileSync(settingsPath, 'utf8')
+    } catch {
+      continue
+    }
+
+    let parsed: unknown
+    try {
+      parsed = JSON.parse(raw)
+    } catch {
+      log('legacy-migration: settings file is not valid JSON; skipping path rewrite', { settingsPath })
+      continue
+    }
+
+    const result = rewriteDeep(parsed, pairs)
+    if (!result.changed) continue
+
+    try {
+      const tmpPath = `${settingsPath}.migration-tmp`
+      writeFileSync(tmpPath, JSON.stringify(result.value, null, 2), 'utf8')
+      renameSync(tmpPath, settingsPath)
+      rewrote = true
+      log('legacy-migration: rewrote legacy paths in settings file', { settingsPath })
+    } catch (error) {
+      log('legacy-migration: failed to write rewritten settings file', {
+        settingsPath,
+        message: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+  return rewrote
+}
+
+/**
+ * 启动期一次性迁移入口。必须在 requestSingleInstanceLock() 和一切读写
+ * userData 的代码之前调用;内部任何失败都被吞掉并降级,绝不抛出。
+ */
+export function runLegacyKunDataMigration(input: {
+  /** electron 的 app.getPath('userData'),即新命名目录。 */
+  userDataPath: string
+  homeDir: string
+  log?: MigrationLogger
+}): LegacyDataMigrationResult {
+  const log = input.log ?? noopLog
+
+  let userData: UserDataMigrationResult = {
+    userDataPath: input.userDataPath,
+    migrated: false,
+    usedLegacyFallback: false
+  }
+  try {
+    userData = migrateLegacyUserDataDir({ userDataPath: input.userDataPath, log })
+  } catch (error) {
+    log('legacy-migration: unexpected userData migration failure', {
+      message: error instanceof Error ? error.message : String(error)
+    })
+  }
+
+  let home: HomeDataMigrationResult[] = []
+  try {
+    home = migrateLegacyHomeDataDirs({ homeDir: input.homeDir, log })
+  } catch (error) {
+    log('legacy-migration: unexpected home migration failure', {
+      message: error instanceof Error ? error.message : String(error)
+    })
+  }
+
+  let settingsRewritten = false
+  try {
+    settingsRewritten = rewriteLegacyPathsInSettingsFile({
+      userDataPath: userData.userDataPath,
+      homeDir: input.homeDir,
+      mappings: home.filter((entry) => entry.rewriteSafe).map((entry) => entry.mapping),
+      log
+    })
+  } catch (error) {
+    log('legacy-migration: unexpected settings rewrite failure', {
+      message: error instanceof Error ? error.message : String(error)
+    })
+  }
+
+  return { userData, home, settingsRewritten }
+}
diff --git a/src/main/logger.ts b/src/main/logger.ts
index 802c3f35..bce27ef8 100644
--- a/src/main/logger.ts
+++ b/src/main/logger.ts
@@ -76,7 +76,7 @@ export async function appendManagedLogLine(
 async function writeLogLine(level: LogLevel, category: string, message: string): Promise {
   const stamp = new Date().toISOString()
   const line = `[${stamp}] [${level.toUpperCase()}] [${category}] ${message}\n`
-  await appendManagedLogLine('deepseek-gui', line)
+  await appendManagedLogLine('kun', line)
 }
 
 export function logError(category: string, message: string, detail?: unknown): void {
diff --git a/src/main/openclaw-weixin-media.d.ts b/src/main/openclaw-weixin-media.d.ts
new file mode 100644
index 00000000..405e3263
--- /dev/null
+++ b/src/main/openclaw-weixin-media.d.ts
@@ -0,0 +1,19 @@
+/**
+ * Minimal typings for the bundled WeChat plugin's media send helper.
+ * The package ships compiled JS under dist/ without type definitions; this
+ * mirrors the signature of src/messaging/send-media.ts (v2.4.3).
+ */
+declare module '@tencent-weixin/openclaw-weixin/dist/src/messaging/send-media.js' {
+  export function sendWeixinMediaFile(params: {
+    filePath: string
+    to: string
+    text: string
+    opts: {
+      baseUrl: string
+      token?: string
+      timeoutMs?: number
+      contextToken?: string
+    }
+    cdnBaseUrl: string
+  }): Promise<{ messageId: string }>
+}
diff --git a/src/main/packaging-config.test.ts b/src/main/packaging-config.test.ts
index 36b3d605..ebcd0182 100644
--- a/src/main/packaging-config.test.ts
+++ b/src/main/packaging-config.test.ts
@@ -60,7 +60,7 @@ function createMacPackContext(root: string): {
     electronPlatformName: 'darwin',
     packager: {
       appInfo: {
-        productFilename: 'DeepSeek GUI'
+        productFilename: 'Kun'
       }
     }
   }
@@ -93,7 +93,10 @@ describe('electron-builder Kun packaging', () => {
       '**/node_modules/openclaw/**/*',
       '**/node_modules/@tencent-weixin/openclaw-weixin/**/*'
     ]))
-    expect(builderConfig.files).toEqual(expect.arrayContaining([
+    // The openclaw shim (vendor/openclaw-shim) must ship: the WeChat bridge
+    // imports the bundled plugin's dist at runtime to send media, and that
+    // import chain resolves openclaw/plugin-sdk/*.
+    expect(builderConfig.files).not.toEqual(expect.arrayContaining([
       '!**/node_modules/openclaw/**/*'
     ]))
   })
@@ -141,8 +144,8 @@ describe('electron-builder Kun packaging', () => {
 
   it('checks timestamp candidates across nested macOS signed code', () => {
     const root = tempRoot()
-    const appBundle = join(root, 'DeepSeek GUI.app')
-    const mainExecutable = join(appBundle, 'Contents/MacOS/DeepSeek GUI')
+    const appBundle = join(root, 'Kun.app')
+    const mainExecutable = join(appBundle, 'Contents/MacOS/Kun')
     const framework = join(appBundle, 'Contents/Frameworks/Electron Framework.framework')
     const nativeAddon = join(
       appBundle,
diff --git a/src/main/provider-connection.test.ts b/src/main/provider-connection.test.ts
new file mode 100644
index 00000000..c7129890
--- /dev/null
+++ b/src/main/provider-connection.test.ts
@@ -0,0 +1,103 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { probeModelProvider, providerProbeHeaders } from './provider-connection'
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe('providerProbeHeaders', () => {
+  it('uses bearer auth for OpenAI-compatible formats', () => {
+    expect(providerProbeHeaders('chat_completions', ' sk-test ')).toEqual({
+      Accept: 'application/json',
+      Authorization: 'Bearer sk-test'
+    })
+  })
+
+  it('uses anthropic headers for the messages format', () => {
+    expect(providerProbeHeaders('messages', 'sk-test')).toEqual({
+      Accept: 'application/json',
+      'anthropic-version': '2023-06-01',
+      'x-api-key': 'sk-test'
+    })
+  })
+
+  it('omits auth headers without a key', () => {
+    expect(providerProbeHeaders('chat_completions', '')).toEqual({ Accept: 'application/json' })
+    expect(providerProbeHeaders('messages', '')).toEqual({
+      Accept: 'application/json',
+      'anthropic-version': '2023-06-01'
+    })
+  })
+})
+
+describe('probeModelProvider', () => {
+  it('rejects non-http base urls without fetching', async () => {
+    const fetchMock = vi.fn()
+    vi.stubGlobal('fetch', fetchMock)
+
+    const result = await probeModelProvider({
+      baseUrl: 'ftp://example.com',
+      apiKey: '',
+      endpointFormat: 'chat_completions'
+    })
+
+    expect(result.ok).toBe(false)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('lists deduplicated models from the versioned models endpoint', async () => {
+    const fetchMock = vi.fn(async () =>
+      new Response(
+        JSON.stringify({ data: [{ id: 'model-b' }, { id: ' model-a ' }, { id: 'model-b' }, { id: '' }] }),
+        { status: 200 }
+      )
+    )
+    vi.stubGlobal('fetch', fetchMock)
+
+    const result = await probeModelProvider({
+      baseUrl: 'https://api.example.com',
+      apiKey: 'sk-x',
+      endpointFormat: 'chat_completions'
+    })
+
+    expect(result).toEqual({
+      ok: true,
+      latencyMs: expect.any(Number),
+      modelIds: ['model-b', 'model-a']
+    })
+    expect(fetchMock).toHaveBeenCalledWith(
+      'https://api.example.com/v1/models',
+      expect.objectContaining({ method: 'GET' })
+    )
+  })
+
+  it('reports http errors with status and body excerpt', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response('unauthorized', { status: 401 })))
+
+    const result = await probeModelProvider({
+      baseUrl: 'https://api.example.com/v1',
+      apiKey: 'bad-key',
+      endpointFormat: 'messages'
+    })
+
+    expect(result.ok).toBe(false)
+    if (!result.ok) {
+      expect(result.message).toContain('401')
+      expect(result.message).toContain('unauthorized')
+    }
+  })
+
+  it('reports network failures as messages', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => {
+      throw new Error('socket hang up')
+    }))
+
+    const result = await probeModelProvider({
+      baseUrl: 'https://api.example.com/v1',
+      apiKey: '',
+      endpointFormat: 'responses'
+    })
+
+    expect(result).toEqual({ ok: false, message: 'socket hang up' })
+  })
+})
diff --git a/src/main/provider-connection.ts b/src/main/provider-connection.ts
new file mode 100644
index 00000000..54a51b53
--- /dev/null
+++ b/src/main/provider-connection.ts
@@ -0,0 +1,76 @@
+import { normalizeModelEndpointFormat, type ModelEndpointFormat } from '../shared/app-settings'
+import type { ModelProviderProbeRequest, ModelProviderProbeResult } from '../shared/kun-gui-api'
+import { upstreamOpenAiModelsUrl } from '../shared/openai-compat-url'
+
+const PROBE_TIMEOUT_MS = 10_000
+const ANTHROPIC_VERSION = '2023-06-01'
+
+export function providerProbeHeaders(
+  endpointFormat: ModelEndpointFormat,
+  apiKey: string
+): Record {
+  const headers: Record = { Accept: 'application/json' }
+  const key = apiKey.trim()
+  if (endpointFormat === 'messages') {
+    headers['anthropic-version'] = ANTHROPIC_VERSION
+    if (key) headers['x-api-key'] = key
+    return headers
+  }
+  if (key) headers.Authorization = `Bearer ${key}`
+  return headers
+}
+
+/**
+ * Probe a model provider by listing its models endpoint. Runs in the main
+ * process so the API key never leaves it and renderer CORS does not apply.
+ */
+export async function probeModelProvider(
+  request: ModelProviderProbeRequest
+): Promise {
+  const baseUrl = request.baseUrl.trim()
+  if (!/^https?:\/\//i.test(baseUrl)) {
+    return { ok: false, message: 'Base URL must start with http:// or https://.' }
+  }
+  const url = upstreamOpenAiModelsUrl(baseUrl)
+  const endpointFormat = normalizeModelEndpointFormat(request.endpointFormat)
+  const startedAt = Date.now()
+  let res: Response
+  let text: string
+  try {
+    res = await fetch(url, {
+      method: 'GET',
+      headers: providerProbeHeaders(endpointFormat, request.apiKey),
+      signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
+    })
+    text = await res.text()
+  } catch (e) {
+    const message = e instanceof Error && e.name === 'TimeoutError'
+      ? `Request to ${url} timed out after ${PROBE_TIMEOUT_MS / 1_000}s.`
+      : e instanceof Error ? e.message : String(e)
+    return { ok: false, message }
+  }
+  const latencyMs = Date.now() - startedAt
+  if (!res.ok) {
+    return { ok: false, message: `${url} responded ${res.status}: ${text.slice(0, 300)}` }
+  }
+  return { ok: true, latencyMs, modelIds: parseModelIds(text) }
+}
+
+function parseModelIds(body: string): string[] {
+  let parsed: unknown
+  try {
+    parsed = JSON.parse(body) as unknown
+  } catch {
+    return []
+  }
+  const data = (parsed as { data?: unknown }).data
+  if (!Array.isArray(data)) return []
+  const ids = new Set()
+  for (const row of data) {
+    if (row && typeof row === 'object' && typeof (row as { id?: unknown }).id === 'string') {
+      const id = (row as { id: string }).id.trim()
+      if (id) ids.add(id)
+    }
+  }
+  return [...ids]
+}
diff --git a/src/main/resolve-kun-binary.ts b/src/main/resolve-kun-binary.ts
index ad2ecba5..a917b773 100644
--- a/src/main/resolve-kun-binary.ts
+++ b/src/main/resolve-kun-binary.ts
@@ -3,7 +3,7 @@ import { join } from 'node:path'
 
 /**
  * Resolve the Kun executable. Kun ships as a TypeScript
- * package inside the DeepSeek-GUI workspace (`kun/`) and is
+ * package inside the Kun workspace (`kun/`) and is
  * executed through the bundled Node.js runtime that Electron carries.
  *
  * Resolution order:
diff --git a/src/main/runtime-sse-ipc.ts b/src/main/runtime-sse-ipc.ts
index 8d074ff2..c075c9a3 100644
--- a/src/main/runtime-sse-ipc.ts
+++ b/src/main/runtime-sse-ipc.ts
@@ -133,14 +133,15 @@ async function fetchSseWithStartTimeout(
 export function registerRuntimeSseIpc(options: {
   ipcMain: IpcMain
   store: JsonSettingsStore
-  ensureRuntime: (settings: AppSettingsV1) => Promise
+  ensureRuntime: (settings: AppSettingsV1) => Promise
   logError: (category: string, message: string, detail?: unknown) => void
 }): void {
   const { ipcMain, store, ensureRuntime, logError } = options
   ipcMain.handle('runtime:sse:start', async (event, args: unknown) => {
     const request = sseStartPayloadSchema.parse(args)
-    const s = await store.load()
-    await ensureRuntime(s)
+    const loadedSettings = await store.load()
+    const ensuredSettings = await ensureRuntime(loadedSettings)
+    const s = ensuredSettings ?? loadedSettings
     const requestedId = request.streamId?.trim() ?? ''
     const id = requestedId || randomUUID()
     const existing = sseControllers.get(id)
@@ -195,6 +196,10 @@ export function registerRuntimeSseIpc(options: {
               const { done, value } = await reader.read()
               if (done) break
               buffer += dec.decode(value, { stream: true })
+              // Batch every event parsed from this network chunk into one IPC
+              // message — streaming turns otherwise pay a structured-clone
+              // send per token delta.
+              const batch: Record[] = []
               let next: { block: string; rest: string } | null
               while ((next = takeSseBlock(buffer)) !== null) {
                 const block = next.block
@@ -205,9 +210,12 @@ export function registerRuntimeSseIpc(options: {
                   if (typeof payload.seq === 'number') {
                     nextSinceSeq = Math.max(nextSinceSeq, payload.seq)
                   }
-                  wc.send('runtime:sse-event', { streamId: id, data: payload })
+                  batch.push(payload)
                 }
               }
+              if (batch.length > 0) {
+                wc.send('runtime:sse-event', { streamId: id, events: batch })
+              }
             }
             buffer += dec.decode()
             const trailing = buffer.trim()
@@ -218,7 +226,7 @@ export function registerRuntimeSseIpc(options: {
                 if (typeof payload.seq === 'number') {
                   nextSinceSeq = Math.max(nextSinceSeq, payload.seq)
                 }
-                wc.send('runtime:sse-event', { streamId: id, data: payload })
+                wc.send('runtime:sse-event', { streamId: id, events: [payload] })
               }
             }
           } catch (e) {
diff --git a/src/main/runtime/kun-adapter.test.ts b/src/main/runtime/kun-adapter.test.ts
index 89733309..2cff1032 100644
--- a/src/main/runtime/kun-adapter.test.ts
+++ b/src/main/runtime/kun-adapter.test.ts
@@ -109,4 +109,24 @@ describe('runtimeRequestViaHost', () => {
     expect(seenUrl).toBe('/v1/usage?group_by=day&from=2026-06-01&to=2026-06-02&timezone=Asia%2FShanghai')
     expect(seenAuthorization).toBe('Bearer usage-token')
   })
+
+  it('uses settings returned by ensureRuntime when the managed port changes', async () => {
+    let seenUrl = ''
+    const port = await listen((req, res) => {
+      seenUrl = req.url ?? ''
+      res.setHeader('Content-Type', 'application/json')
+      res.end(JSON.stringify({ ok: true }))
+    })
+
+    const response = await runtimeRequestViaHost(
+      settingsForPort(1),
+      '/v1/threads?limit=1',
+      { method: 'GET' },
+      async () => settingsForPort(port)
+    )
+
+    expect(response.ok).toBe(true)
+    expect(response.status).toBe(200)
+    expect(seenUrl).toBe('/v1/threads?limit=1')
+  })
 })
diff --git a/src/main/runtime/kun-adapter.ts b/src/main/runtime/kun-adapter.ts
index efd55911..4c756a87 100644
--- a/src/main/runtime/kun-adapter.ts
+++ b/src/main/runtime/kun-adapter.ts
@@ -13,6 +13,7 @@ import {
 import {
   isKunChildRunning,
   reclaimKunPort,
+  resolveAvailableKunPort,
   startKunChild,
   stopKunChildAndWait
 } from '../kun-process'
@@ -60,6 +61,10 @@ export const kunRuntimeAdapter = {
 
   reclaimPort(port: number): Promise<{ ok: true } | { ok: false; message: string }> {
     return reclaimKunPort(port)
+  },
+
+  resolveAvailablePort(port: number): Promise<{ port: number; changed: boolean; message?: string }> {
+    return resolveAvailableKunPort(port)
   }
 }
 
@@ -87,13 +92,14 @@ export async function runtimeRequestViaHost(
   settings: AppSettingsV1,
   pathAndQuery: string,
   init: RuntimeRequestInit,
-  ensureRuntime: (settings: AppSettingsV1) => Promise
+  ensureRuntime: (settings: AppSettingsV1) => Promise
 ): Promise<{ ok: boolean; status: number; body: string }> {
-  await ensureRuntime(settings)
-  const base = getRuntimeBaseUrlForSettings(settings)
+  const ensuredSettings = await ensureRuntime(settings)
+  const requestSettings = ensuredSettings ?? settings
+  const base = getRuntimeBaseUrlForSettings(requestSettings)
   const pathNorm = pathAndQuery.startsWith('/') ? pathAndQuery : `/${pathAndQuery}`
   const url = `${base}${pathNorm}`
-  const hdrs = runtimeAuthHeaders(settings)
+  const hdrs = runtimeAuthHeaders(requestSettings)
   for (const [key, value] of Object.entries(init.headers ?? {})) {
     hdrs.set(key, value)
   }
diff --git a/src/main/schedule-runtime-helpers.ts b/src/main/schedule-runtime-helpers.ts
index d1220223..614a5039 100644
--- a/src/main/schedule-runtime-helpers.ts
+++ b/src/main/schedule-runtime-helpers.ts
@@ -1,6 +1,7 @@
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import type {
   AppSettingsV1,
+  ClawImChannelV1,
   ScheduleReasoningEffort,
   ScheduleRunMode,
   ScheduledTaskV1
@@ -63,6 +64,7 @@ export type RunPromptOptions = {
   model: string
   reasoningEffort: ScheduleReasoningEffort
   mode: ScheduleRunMode
+  clawChannel?: ClawImChannelV1 | null
   waitForResult: boolean
   responseTimeoutMs: number
 }
@@ -138,7 +140,7 @@ export function sleep(ms: number): Promise {
 
 export function normalizeTaskModel(model: string): string | undefined {
   const trimmed = model.trim()
-  return trimmed || undefined
+  return trimmed && trimmed.toLowerCase() !== 'auto' ? trimmed : undefined
 }
 
 export function summarizeTaskResult(text: string): string {
diff --git a/src/main/schedule-runtime.test.ts b/src/main/schedule-runtime.test.ts
index 7e0c9231..7b8de9e0 100644
--- a/src/main/schedule-runtime.test.ts
+++ b/src/main/schedule-runtime.test.ts
@@ -9,6 +9,7 @@ import {
   mergeScheduleSettings,
   type AppSettingsPatch,
   type AppSettingsV1,
+  type ClawImChannelV1,
   type ScheduledTaskV1
 } from '../shared/app-settings'
 import { ScheduleRuntime, computeScheduleNextRunAt, scheduledThreadTitle } from './schedule-runtime'
@@ -27,6 +28,7 @@ function makeTask(patch: Partial = {}): ScheduledTaskV1 {
     enabled: true,
     prompt: 'Run the task',
     workspaceRoot: '/tmp/workspace',
+    clawChannelId: '',
     model: 'auto',
     reasoningEffort: 'medium',
     mode: 'agent',
@@ -42,6 +44,30 @@ function makeTask(patch: Partial = {}): ScheduledTaskV1 {
   }
 }
 
+function makeClawChannel(patch: Partial = {}): ClawImChannelV1 {
+  return {
+    id: 'channel-1',
+    provider: 'feishu',
+    label: 'Feishu Agent',
+    enabled: true,
+    model: 'deepseek-v4-flash',
+    threadId: '',
+    workspaceRoot: '/tmp/claw-workspace',
+    agentProfile: {
+      name: 'Ops Claw',
+      description: '',
+      identity: 'You are the operations assistant.',
+      personality: '',
+      userContext: '',
+      replyRules: ''
+    },
+    conversations: [],
+    createdAt: '2026-06-02T00:00:00.000Z',
+    updatedAt: '2026-06-02T00:00:00.000Z',
+    ...patch
+  }
+}
+
 function settingsWith(
   tasks: ScheduledTaskV1[] = [],
   schedulePatch: AppSettingsPatch['schedule'] = {}
@@ -164,7 +190,9 @@ describe('ScheduleRuntime', () => {
     expect(store.read().schedule.tasks[0]).toMatchObject({
       title: 'Ship review reminder',
       workspaceRoot: '/tmp/schedule',
+      providerId: 'deepseek',
       model: 'deepseek-v4-flash',
+      reasoningEffort: 'max',
       mode: 'plan',
       schedule: { kind: 'at', atTime: future }
     })
@@ -203,12 +231,14 @@ describe('ScheduleRuntime', () => {
     expect(JSON.parse(String(createRequest))).toMatchObject({
       title: '[Scheduled task] Task',
       workspace: '/tmp/workspace',
-      model: 'auto',
+      model: 'deepseek-v4-flash',
       mode: 'agent'
     })
     expect(JSON.parse(String(turnRequest))).toMatchObject({
-      model: 'auto',
-      reasoningEffort: 'max'
+      model: 'deepseek-v4-flash',
+      reasoningEffort: 'max',
+      // Headless turn: a user_input request would hang until timeout.
+      disableUserInput: true
     })
     expect(store.read().schedule.tasks[0]).toMatchObject({
       lastStatus: 'running',
@@ -217,6 +247,52 @@ describe('ScheduleRuntime', () => {
     })
   })
 
+  it('runs selected Claw channel scheduled tasks with the Claw persona', async () => {
+    const channel = makeClawChannel()
+    const task = makeTask({
+      clawChannelId: channel.id,
+      workspaceRoot: '',
+      model: channel.model
+    })
+    const initial = settingsWith([task])
+    initial.claw.channels = [channel]
+    const runtimeRequest = vi.fn(async (_settings, path, init) => {
+      if (path === '/v1/threads') {
+        return { ok: true, status: 200, body: JSON.stringify({ id: 'thr_claw' }) }
+      }
+      if (path === '/v1/threads/thr_claw' && init?.method === 'PATCH') {
+        return { ok: true, status: 200, body: '{}' }
+      }
+      if (path === '/v1/threads/thr_claw/turns') {
+        return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_claw' }) }
+      }
+      throw new Error(`unexpected path ${path}`)
+    })
+    const { runtime } = createRuntime(initial, runtimeRequest)
+    ;(runtime as unknown as { monitorTaskTurn: () => void }).monitorTaskTurn = vi.fn()
+
+    await expect(runtime.runTask(task.id)).resolves.toMatchObject({
+      ok: true,
+      threadId: 'thr_claw',
+      turnId: 'turn_claw'
+    })
+
+    const createRequest = runtimeRequest.mock.calls.find(([, path, init]) =>
+      path === '/v1/threads' && init?.method === 'POST'
+    )?.[2]?.body
+    const turnRequest = runtimeRequest.mock.calls.find(([, path]) =>
+      path === '/v1/threads/thr_claw/turns'
+    )?.[2]?.body
+    expect(JSON.parse(String(createRequest))).toMatchObject({
+      workspace: '/tmp/claw-workspace',
+      model: 'deepseek-v4-flash'
+    })
+    const turnBody = JSON.parse(String(turnRequest))
+    expect(turnBody.prompt).toContain('[Claw managed instructions]')
+    expect(turnBody.prompt).toContain('[Agent name]\nOps Claw')
+    expect(turnBody.prompt).toContain('Run the task')
+  })
+
   it('reads assistant text from the real Kun thread detail shape', async () => {
     const task = makeTask()
     const runtimeRequest = vi.fn(async (_settings, path, init) => {
diff --git a/src/main/schedule-runtime.ts b/src/main/schedule-runtime.ts
index 4168c220..233ed941 100644
--- a/src/main/schedule-runtime.ts
+++ b/src/main/schedule-runtime.ts
@@ -4,6 +4,8 @@ import { URL } from 'node:url'
 import { mkdir } from 'node:fs/promises'
 import type {
   AppSettingsV1,
+  ClawImChannelV1,
+  ModelProviderProfileV1,
   ScheduleReasoningEffort,
   ScheduleRunMode,
   ScheduleRunResult,
@@ -13,7 +15,12 @@ import type {
 } from '../shared/app-settings'
 import {
   DEFAULT_SCHEDULE_MODEL,
+  DEFAULT_SCHEDULE_REASONING_EFFORT,
+  buildClawRuntimePrompt,
   buildScheduleRuntimePrompt,
+  getKunRuntimeSettings,
+  getModelProviderSettings,
+  modelProviderModelProfile,
   normalizeScheduleReasoningEffort
 } from '../shared/app-settings'
 import {
@@ -52,6 +59,56 @@ export function scheduledThreadTitle(title: string): string {
   return suffix ? `${prefix} ${suffix}` : prefix
 }
 
+const SCHEDULE_REASONING_EFFORT_SET = new Set([
+  'auto',
+  'off',
+  'low',
+  'medium',
+  'high',
+  'max'
+])
+
+type ScheduleModelConfig = {
+  providerId: string
+  model: string
+  reasoningEffort: ScheduleReasoningEffort
+}
+
+function modelIdsMatch(left: string, right: string): boolean {
+  return left.trim().toLowerCase() === right.trim().toLowerCase()
+}
+
+function isScheduleReasoningEffort(value: string): value is ScheduleReasoningEffort {
+  return SCHEDULE_REASONING_EFFORT_SET.has(value as ScheduleReasoningEffort)
+}
+
+function providerHasModel(provider: Pick, model: string): boolean {
+  return provider.models.some((candidate) => modelIdsMatch(candidate, model))
+}
+
+function firstConcreteProviderModel(provider: Pick | null): string {
+  return provider?.models.find((model) => normalizeTaskModel(model))?.trim() ?? DEFAULT_SCHEDULE_MODEL
+}
+
+function resolveReasoningForModel(
+  provider: Pick | null,
+  model: string,
+  reasoningEffort: ScheduleReasoningEffort | string | null | undefined
+): ScheduleReasoningEffort {
+  const requested = normalizeScheduleReasoningEffort(reasoningEffort)
+  const profile = provider ? modelProviderModelProfile(provider, model) : undefined
+  const supported = profile?.reasoning?.supportedEfforts.filter(isScheduleReasoningEffort) ?? []
+  if (supported.length === 0) return requested
+  if (supported.includes(requested)) return requested
+  const profileDefault = profile?.reasoning?.defaultEffort
+  if (profileDefault && isScheduleReasoningEffort(profileDefault) && supported.includes(profileDefault)) {
+    return profileDefault
+  }
+  return supported.includes(DEFAULT_SCHEDULE_REASONING_EFFORT)
+    ? DEFAULT_SCHEDULE_REASONING_EFFORT
+    : supported[0] ?? DEFAULT_SCHEDULE_REASONING_EFFORT
+}
+
 export class ScheduleRuntime {
   private readonly deps: ScheduleRuntimeDeps
   private scheduler: ReturnType | null = null
@@ -64,6 +121,37 @@ export class ScheduleRuntime {
     this.deps = deps
   }
 
+  private resolveScheduleModelConfig(
+    settings: AppSettingsV1,
+    input: {
+      providerId?: string | null
+      model?: string | null
+      reasoningEffort?: ScheduleReasoningEffort | string | null
+    }
+  ): ScheduleModelConfig {
+    const providers = getModelProviderSettings(settings).providers
+    const requestedProviderId = input.providerId?.trim() || ''
+    const requestedModel = normalizeTaskModel(input.model ?? '')
+    const runtimeProviderId = getKunRuntimeSettings(settings).providerId.trim()
+    const scheduleProviderId = settings.schedule.providerId?.trim() || ''
+    const provider =
+      providers.find((item) => item.id === requestedProviderId) ??
+      (requestedModel ? providers.find((item) => providerHasModel(item, requestedModel)) : undefined) ??
+      providers.find((item) => item.id === runtimeProviderId) ??
+      providers.find((item) => item.id === scheduleProviderId) ??
+      providers[0] ??
+      null
+    const model =
+      requestedModel && (!provider || providerHasModel(provider, requestedModel))
+        ? requestedModel
+        : firstConcreteProviderModel(provider)
+    return {
+      providerId: provider?.id ?? requestedProviderId,
+      model,
+      reasoningEffort: resolveReasoningForModel(provider, model, input.reasoningEffort)
+    }
+  }
+
   sync(settings: AppSettingsV1): void {
     this.syncInternalServer(settings)
     this.startScheduler()
@@ -99,23 +187,41 @@ export class ScheduleRuntime {
 
   async createScheduledTaskFromText(
     text: string,
-    options: { workspaceRoot?: string | null; modelHint?: string | null; mode?: ScheduleRunMode | null } = {}
+    options: {
+      workspaceRoot?: string | null
+      clawChannelId?: string | null
+      providerId?: string | null
+      modelHint?: string | null
+      reasoningEffort?: ScheduleReasoningEffort | null
+      mode?: ScheduleRunMode | null
+    } = {}
   ): Promise {
     const settings = await this.deps.store.load()
     try {
+      const clawChannel = this.resolveClawChannel(settings, options.clawChannelId)
+      const modelConfig = this.resolveScheduleModelConfig(settings, {
+        providerId: options.providerId ?? settings.schedule.providerId,
+        model: options.modelHint?.trim() || clawChannel?.model.trim() || settings.schedule.model || DEFAULT_SCHEDULE_MODEL,
+        reasoningEffort: options.reasoningEffort ?? DEFAULT_SCHEDULE_REASONING_EFFORT
+      })
       const request = await detectClawScheduledTaskRequest(
         settings,
         text,
-        options.modelHint?.trim() || settings.schedule.model || DEFAULT_SCHEDULE_MODEL
+        modelConfig.model
       )
       if (!request) return { kind: 'noop' }
       const task = buildScheduledTaskFromDetectedRequest({
         request,
-        workspaceRoot: options.workspaceRoot?.trim() || this.resolveDefaultWorkspaceRoot(settings),
-        model: options.modelHint?.trim() || settings.schedule.model || DEFAULT_SCHEDULE_MODEL,
+        workspaceRoot:
+          options.workspaceRoot?.trim() ||
+          (clawChannel ? this.resolveClawChannelWorkspaceRoot(settings, clawChannel) : this.resolveDefaultWorkspaceRoot(settings)),
+        providerId: modelConfig.providerId,
+        model: modelConfig.model,
+        reasoningEffort: modelConfig.reasoningEffort,
         mode: options.mode ?? settings.schedule.mode,
         id: randomUUID()
       })
+      task.clawChannelId = clawChannel?.id ?? ''
       const saved = await this.deps.store.patch({
         schedule: {
           enabled: true,
@@ -158,22 +264,34 @@ export class ScheduleRuntime {
     title: string
     prompt: string
     workspaceRoot?: string
+    providerId?: string
     model?: string
     reasoningEffort?: ScheduleReasoningEffort
     mode?: ScheduleRunMode
+    clawChannelId?: string
     enabled?: boolean
     schedule: Partial & { kind: ScheduledTaskV1['schedule']['kind'] }
   }): Promise {
     const settings = await this.deps.store.load()
+    const clawChannel = this.resolveClawChannel(settings, input.clawChannelId)
+    const modelConfig = this.resolveScheduleModelConfig(settings, {
+      providerId: input.providerId ?? settings.schedule.providerId,
+      model: input.model?.trim() || clawChannel?.model.trim() || settings.schedule.model || DEFAULT_SCHEDULE_MODEL,
+      reasoningEffort: input.reasoningEffort ?? DEFAULT_SCHEDULE_REASONING_EFFORT
+    })
     const now = new Date().toISOString()
     const task: ScheduledTaskV1 = {
       id: randomUUID(),
       title: input.title.trim() || 'New scheduled task',
       enabled: input.enabled !== false,
       prompt: input.prompt,
-      workspaceRoot: input.workspaceRoot?.trim() || this.resolveDefaultWorkspaceRoot(settings),
-      model: input.model?.trim() || settings.schedule.model || DEFAULT_SCHEDULE_MODEL,
-      reasoningEffort: normalizeScheduleReasoningEffort(input.reasoningEffort),
+      workspaceRoot:
+        input.workspaceRoot?.trim() ||
+        (clawChannel ? this.resolveClawChannelWorkspaceRoot(settings, clawChannel) : this.resolveDefaultWorkspaceRoot(settings)),
+      clawChannelId: clawChannel?.id ?? '',
+      providerId: modelConfig.providerId,
+      model: modelConfig.model,
+      reasoningEffort: modelConfig.reasoningEffort,
       mode: input.mode ?? settings.schedule.mode,
       schedule: {
         kind: input.schedule.kind,
@@ -329,13 +447,20 @@ export class ScheduleRuntime {
 
     try {
       const settings = await this.deps.store.load()
+      const clawChannel = this.resolveTaskClawChannel(settings, task)
+      const modelConfig = this.resolveScheduleModelConfig(settings, {
+        providerId: task.providerId,
+        model: task.model,
+        reasoningEffort: task.reasoningEffort
+      })
       const result = await this.runPrompt(settings, {
         prompt: task.prompt,
         title: scheduledThreadTitle(task.title),
-        workspaceRoot: task.workspaceRoot || this.resolveDefaultWorkspaceRoot(settings),
-        model: task.model,
-        reasoningEffort: task.reasoningEffort,
+        workspaceRoot: this.resolveTaskWorkspaceRoot(settings, task, clawChannel),
+        model: modelConfig.model,
+        reasoningEffort: modelConfig.reasoningEffort,
         mode: task.mode,
+        clawChannel,
         waitForResult: false,
         responseTimeoutMs: TASK_RESPONSE_TIMEOUT_MS
       })
@@ -440,8 +565,13 @@ export class ScheduleRuntime {
     const thread = JSON.parse(create.body) as ThreadRecordJson
 
     const turnBody: Record = {
-      prompt: buildScheduleRuntimePrompt(settings, options.prompt),
-      mode: options.mode
+      prompt: options.clawChannel
+        ? buildClawRuntimePrompt(settings, options.prompt, { channel: options.clawChannel })
+        : buildScheduleRuntimePrompt(settings, options.prompt),
+      mode: options.mode,
+      // Scheduled turns are headless — nobody can answer a user_input
+      // prompt, and a turn that asks one hangs until the response timeout.
+      disableUserInput: true
     }
     if (model) turnBody.model = model
     if (options.reasoningEffort) {
@@ -508,6 +638,29 @@ export class ScheduleRuntime {
     return settings.schedule.defaultWorkspaceRoot.trim() || settings.workspaceRoot
   }
 
+  private resolveClawChannel(settings: AppSettingsV1, channelId: string | null | undefined): ClawImChannelV1 | null {
+    const id = channelId?.trim()
+    if (!id) return null
+    return settings.claw.channels.find((channel) => channel.id === id) ?? null
+  }
+
+  private resolveTaskClawChannel(settings: AppSettingsV1, task: ScheduledTaskV1): ClawImChannelV1 | null {
+    return this.resolveClawChannel(settings, task.clawChannelId)
+  }
+
+  private resolveClawChannelWorkspaceRoot(settings: AppSettingsV1, channel: ClawImChannelV1): string {
+    return channel.workspaceRoot.trim() || settings.claw.im.workspaceRoot.trim() || this.resolveDefaultWorkspaceRoot(settings)
+  }
+
+  private resolveTaskWorkspaceRoot(
+    settings: AppSettingsV1,
+    task: ScheduledTaskV1,
+    channel: ClawImChannelV1 | null
+  ): string {
+    return task.workspaceRoot.trim() ||
+      (channel ? this.resolveClawChannelWorkspaceRoot(settings, channel) : this.resolveDefaultWorkspaceRoot(settings))
+  }
+
   private syncInternalServer(settings: AppSettingsV1): void {
     const internal = settings.schedule.internal
     const key = `${internal.port}`
@@ -553,9 +706,10 @@ export class ScheduleRuntime {
       const secret = settings.schedule.internal.secret.trim()
       if (secret) {
         const auth = req.headers.authorization ?? ''
-        const headerSecret = Array.isArray(req.headers['x-deepseek-gui-secret'])
-          ? req.headers['x-deepseek-gui-secret'][0]
-          : req.headers['x-deepseek-gui-secret']
+        // 新名字 x-kun-secret 优先;旧名字 x-deepseek-gui-secret 已配置
+        // 在外部系统里,属于对外契约,必须长期兼容。
+        const rawHeaderSecret = req.headers['x-kun-secret'] ?? req.headers['x-deepseek-gui-secret']
+        const headerSecret = Array.isArray(rawHeaderSecret) ? rawHeaderSecret[0] : rawHeaderSecret
         if (auth !== `Bearer ${secret}` && headerSecret !== secret) {
           writeJson(res, 401, { ok: false, message: 'Unauthorized.' })
           return
@@ -593,6 +747,8 @@ export class ScheduleRuntime {
           title,
           prompt,
           workspaceRoot: asString(input.workspaceRoot) || undefined,
+          clawChannelId: asString(input.clawChannelId) || undefined,
+          providerId: asString(input.providerId) || undefined,
           model: asString(input.model) || undefined,
           reasoningEffort: (asString(input.reasoningEffort) as ScheduleReasoningEffort) || undefined,
           mode: (asString(input.mode) as ScheduleRunMode) || undefined,
diff --git a/src/main/services/git-discovery.test.ts b/src/main/services/git-discovery.test.ts
new file mode 100644
index 00000000..990138d0
--- /dev/null
+++ b/src/main/services/git-discovery.test.ts
@@ -0,0 +1,102 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { findNearestGitRoot } from './git-discovery'
+
+let sandbox = ''
+
+beforeEach(async () => {
+  sandbox = await mkdtemp(join(tmpdir(), 'ds-gui-git-discovery-'))
+})
+
+afterEach(async () => {
+  if (sandbox) {
+    await rm(sandbox, { recursive: true, force: true })
+    sandbox = ''
+  }
+})
+
+async function makeRepo(root: string): Promise {
+  await mkdir(join(root, '.git'), { recursive: true })
+}
+
+describe('findNearestGitRoot', () => {
+  it('returns the directory itself when it contains .git', async () => {
+    await makeRepo(sandbox)
+    const result = await findNearestGitRoot(sandbox)
+    expect(result).toBe(sandbox)
+  })
+
+  it('walks up to find .git in an ancestor directory', async () => {
+    await makeRepo(sandbox)
+    const subdir = join(sandbox, 'src', 'components', 'chat')
+    await mkdir(subdir, { recursive: true })
+    const result = await findNearestGitRoot(subdir)
+    expect(result).toBe(sandbox)
+  })
+
+  it('handles a deeply nested subdirectory', async () => {
+    await makeRepo(sandbox)
+    const deep = join(sandbox, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j')
+    await mkdir(deep, { recursive: true })
+    const result = await findNearestGitRoot(deep)
+    expect(result).toBe(sandbox)
+  })
+
+  it('recognizes .git as a file (worktrees / submodules)', async () => {
+    // .git can be a file that points at a gitdir elsewhere. We don't follow
+    // the file — we just need to recognize that the parent is inside a repo.
+    await writeFile(join(sandbox, '.git'), 'gitdir: /tmp/elsewhere\n', 'utf8')
+    const subdir = join(sandbox, 'sub')
+    await mkdir(subdir, { recursive: true })
+    const result = await findNearestGitRoot(subdir)
+    expect(result).toBe(sandbox)
+  })
+
+  it('returns null when no ancestor contains .git', async () => {
+    // sandbox is a fresh tmpdir with no .git anywhere up the chain (the
+    // walker stops at the filesystem root, so we just verify the function
+    // does not crash and returns null for a non-repo path).
+    const result = await findNearestGitRoot(sandbox)
+    expect(result).toBeNull()
+  })
+
+  it('returns null for an empty string', async () => {
+    expect(await findNearestGitRoot('')).toBeNull()
+  })
+
+  it('finds the nearest of multiple nested .git directories', async () => {
+    // Outer repo at sandbox, inner "subrepo" at sandbox/inner/.git
+    await makeRepo(sandbox)
+    const inner = join(sandbox, 'inner')
+    await makeRepo(inner)
+    const innerSub = join(inner, 'src')
+    await mkdir(innerSub, { recursive: true })
+
+    // From a path inside `inner`, the inner .git wins (nearest).
+    expect(await findNearestGitRoot(innerSub)).toBe(inner)
+    // From a path inside `sandbox` (not inside inner), the outer .git wins.
+    const sandboxSibling = join(sandbox, 'sibling')
+    await mkdir(sandboxSibling, { recursive: true })
+    expect(await findNearestGitRoot(sandboxSibling)).toBe(sandbox)
+  })
+
+  it('resolves a relative path against the current working directory', async () => {
+    await makeRepo(sandbox)
+    const subdir = join(sandbox, 'sub')
+    await mkdir(subdir, { recursive: true })
+    // Pass a relative path; the helper should resolve it to an absolute one.
+    const result = await findNearestGitRoot(join(subdir, 'relative.txt'))
+    // The file does not exist — but findNearestGitRoot doesn't care, it just
+    // walks parents of the resolved path.
+    expect(result).toBe(sandbox)
+  })
+
+  it('returns null for a path that walks past the filesystem root', async () => {
+    // /this/path/does/not/exist/anywhere is not a real ancestor of anything
+    // git-ish, so the walker should return null without throwing.
+    const result = await findNearestGitRoot('/this/path/does/not/exist/anywhere')
+    expect(result).toBeNull()
+  })
+})
diff --git a/src/main/services/git-discovery.ts b/src/main/services/git-discovery.ts
new file mode 100644
index 00000000..30818a85
--- /dev/null
+++ b/src/main/services/git-discovery.ts
@@ -0,0 +1,48 @@
+import { stat } from 'node:fs/promises'
+import { dirname, isAbsolute, parse, resolve } from 'node:path'
+
+/**
+ * Walk up the directory tree starting at `start` and return the path of the
+ * first directory that contains a `.git` entry (either a directory or, in the
+ * case of git worktrees/submodules, a file). Returns `null` if the filesystem
+ * root is reached without finding one.
+ *
+ * This mirrors the upward search that `git rev-parse --show-toplevel` performs
+ * internally, but does it in pure Node so we can fall back gracefully when the
+ * git binary is missing, returns a non-matching error, or is too old to support
+ * the sub-commands the rest of `getGitBranches` relies on (e.g. `branch
+ * --format`, which requires git 2.28+).
+ *
+ * The walker is bounded by the filesystem root (`/` on POSIX, the drive root
+ * on Windows), so it cannot escape the host. Symlinks are not resolved here
+ * — `getGitBranches` resolves them before calling this helper, or you can
+ * resolve them yourself before passing the start path in.
+ */
+export async function findNearestGitRoot(start: string): Promise {
+  if (!start) return null
+  const absolute = isAbsolute(start) ? start : resolve(start)
+  const { root } = parse(absolute)
+
+  let current = absolute
+  // Hard upper bound: a real-world deep workspace shouldn't need more than
+  // 64 ancestor hops. If we ever hit it, bail out rather than spin forever.
+  for (let depth = 0; depth < 64; depth += 1) {
+    const candidate = `${current}/.git`
+    try {
+      const info = await stat(candidate)
+      // Both `.git/` (regular repo) and `.git` (file, used by submodules and
+      // worktrees pointing at a gitdir elsewhere) qualify.
+      if (info.isDirectory() || info.isFile()) {
+        return current
+      }
+    } catch {
+      // `.git` not present at this level — keep walking up.
+    }
+
+    if (current === root) return null
+    const parent = dirname(current)
+    if (parent === current) return null
+    current = parent
+  }
+  return null
+}
diff --git a/src/main/services/git-service.test.ts b/src/main/services/git-service.test.ts
new file mode 100644
index 00000000..319308fb
--- /dev/null
+++ b/src/main/services/git-service.test.ts
@@ -0,0 +1,151 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { execFileSync } from 'node:child_process'
+import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'
+import { readdirSync } from 'node:fs'
+import { realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { getGitBranches, switchGitBranch, createAndSwitchGitBranch } from './git-service'
+
+/**
+ * Integration tests for git-service.ts that exercise the real `git` binary
+ * in a temp repository. These complement the unit tests for findNearestGitRoot
+ * (in git-discovery.test.ts) by proving that the public entry points
+ * (`getGitBranches`, `switchGitBranch`, `createAndSwitchGitBranch`) actually
+ * return the right `repositoryRoot` when called with a subdirectory path.
+ *
+ * See issue #98: user reported that GUI showed "未检测到 Git" when the
+ * workspace was a sub-folder of a repo. The fix walks up to find the nearest
+ * `.git` root before calling git, so callers can pass a subdirectory and
+ * still get a usable result.
+ */
+
+let sandbox = ''
+let repoRoot = ''
+
+beforeEach(async () => {
+  sandbox = await mkdtemp(join(tmpdir(), 'ds-gui-git-service-'))
+  repoRoot = await realpath(sandbox)
+  // Initialise a real git repo with one commit on `main` and a few sub-dirs.
+  // `realpath` resolves the macOS /tmp symlink so the returned repositoryRoot
+  // matches what `git rev-parse --show-toplevel` returns (which also resolves
+  // symlinks).
+  execFileSync('git', ['init', '-b', 'main', repoRoot], { stdio: 'pipe' })
+  execFileSync('git', ['-C', repoRoot, 'config', 'user.email', 'test@example.com'], { stdio: 'pipe' })
+  execFileSync('git', ['-C', repoRoot, 'config', 'user.name', 'Test'], { stdio: 'pipe' })
+  await writeFile(join(repoRoot, 'README.md'), 'test')
+  execFileSync('git', ['-C', repoRoot, 'add', 'README.md'], { stdio: 'pipe' })
+  execFileSync('git', ['-C', repoRoot, 'commit', '-m', 'init'], { stdio: 'pipe' })
+})
+
+afterEach(async () => {
+  if (sandbox) {
+    await rm(sandbox, { recursive: true, force: true })
+    sandbox = ''
+    repoRoot = ''
+  }
+})
+
+describe('getGitBranches — integration with real git', () => {
+  it('returns ok with the repo root when called from a nested subdirectory (issue #98)', async () => {
+    // Build a 5-level nested subdirectory inside the repo: /a/b/c/d/e
+    const deep = join(repoRoot, 'a', 'b', 'c', 'd', 'e')
+    await mkdir(deep, { recursive: true })
+
+    const result = await getGitBranches(deep)
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) throw new Error('unreachable: just checked ok')
+
+    // `repositoryRoot` must be the repo root (not the subdirectory we passed in).
+    expect(result.repositoryRoot).toBe(repoRoot)
+    // And we should see the default branch we created.
+    expect(result.currentBranch).toBe('main')
+    expect(result.branches.map((b) => b.name)).toContain('main')
+    // Working tree is clean, no untracked files inside the subdir.
+    expect(result.dirtyCount).toBe(0)
+  })
+
+  it('returns ok when called from the repo root itself', async () => {
+    const result = await getGitBranches(repoRoot)
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) throw new Error('unreachable')
+    expect(result.repositoryRoot).toBe(repoRoot)
+    expect(result.currentBranch).toBe('main')
+  })
+
+  it('reports dirty files inside the workspace subdirectory', async () => {
+    const sub = join(repoRoot, 'src')
+    await mkdir(sub, { recursive: true })
+    await writeFile(join(sub, 'untracked.ts'), 'export const x = 1\n')
+
+    const result = await getGitBranches(sub)
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) throw new Error('unreachable')
+    expect(result.repositoryRoot).toBe(repoRoot)
+    expect(result.dirtyCount).toBeGreaterThanOrEqual(1)
+  })
+
+  it('returns not_git_repo when the path is outside any repository', async () => {
+    // A fresh tmpdir (no .git anywhere up the chain on this host).
+    const outside = await mkdtemp(join(tmpdir(), 'ds-gui-git-outside-'))
+    try {
+      const result = await getGitBranches(outside)
+      expect(result.ok).toBe(false)
+      if (result.ok) throw new Error('expected not_git_repo, got ok')
+      expect(result.reason).toBe('not_git_repo')
+    } finally {
+      await rm(outside, { recursive: true, force: true })
+    }
+  })
+
+  it('returns no_workspace for an empty workspace root', async () => {
+    const result = await getGitBranches('   ')
+    expect(result.ok).toBe(false)
+    if (result.ok) throw new Error('expected no_workspace, got ok')
+    expect(result.reason).toBe('no_workspace')
+  })
+})
+
+describe('switchGitBranch / createAndSwitchGitBranch — integration with real git', () => {
+  it('switches to an existing branch from a subdirectory', async () => {
+    // Pre-create a feature branch with one commit on top of main.
+    execFileSync('git', ['-C', repoRoot, 'checkout', '-b', 'feature/x'], { stdio: 'pipe' })
+    await writeFile(join(repoRoot, 'feature.txt'), 'feature work')
+    execFileSync('git', ['-C', repoRoot, 'add', 'feature.txt'], { stdio: 'pipe' })
+    execFileSync('git', ['-C', repoRoot, 'commit', '-m', 'feature'], { stdio: 'pipe' })
+    // Back to main so we have something to switch away from.
+    execFileSync('git', ['-C', repoRoot, 'checkout', 'main'], { stdio: 'pipe' })
+
+    const sub = join(repoRoot, 'src', 'components')
+    await mkdir(sub, { recursive: true })
+
+    const result = await switchGitBranch(sub, 'feature/x')
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) throw new Error('unreachable')
+    expect(result.repositoryRoot).toBe(repoRoot)
+    expect(result.currentBranch).toBe('feature/x')
+
+    // Confirm the underlying git state actually changed.
+    const actual = execFileSync('git', ['-C', repoRoot, 'branch', '--show-current'], {
+      encoding: 'utf8'
+    }).trim()
+    expect(actual).toBe('feature/x')
+  })
+
+  it('creates a new branch from a subdirectory and switches to it', async () => {
+    const sub = join(repoRoot, 'src', 'components')
+    await mkdir(sub, { recursive: true })
+
+    const result = await createAndSwitchGitBranch(sub, 'feature/y')
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) throw new Error('unreachable')
+    expect(result.repositoryRoot).toBe(repoRoot)
+    expect(result.currentBranch).toBe('feature/y')
+    expect(readdirSync(join(repoRoot, '.git', 'refs', 'heads'))).toContain('feature')
+  })
+})
diff --git a/src/main/services/git-service.ts b/src/main/services/git-service.ts
index bc35fd2f..5e4de962 100644
--- a/src/main/services/git-service.ts
+++ b/src/main/services/git-service.ts
@@ -1,9 +1,31 @@
 import { execFile } from 'node:child_process'
 import { promisify } from 'node:util'
 import type { GitBranchesResult } from '../../shared/git-branches'
+import { findNearestGitRoot } from './git-discovery'
 
 const execFileAsync = promisify(execFile)
 
+/**
+ * Resolve a workspaceRoot to a directory that sits inside a Git working tree.
+ *
+ * `git rev-parse --show-toplevel` already walks up the directory tree, so it
+ * usually finds the right cwd by itself. However, when the user's workspace
+ * is set to a sub-folder of a repo AND the git binary is older than 2.28
+ * (no `branch --format`) or returns an error string we don't match, the rest
+ * of `getGitBranches` falls through to `gitFailure` and the UI shows
+ * "未检测到 Git" even though we are clearly inside a repo. See issue #98.
+ *
+ * We mitigate that by walking up the tree in pure Node first and passing the
+ * discovered repo root (or the original cwd if none was found) to git. This
+ * is a defensive layer — when git itself works, the result is identical.
+ */
+async function resolveGitCwd(workspaceRoot: string): Promise {
+  const trimmed = workspaceRoot.trim()
+  if (!trimmed) return trimmed
+  const discovered = await findNearestGitRoot(trimmed)
+  return discovered ?? trimmed
+}
+
 async function runGit(
   cwd: string,
   args: string[],
@@ -29,7 +51,7 @@ function gitFailure(error: unknown): GitBranchesResult {
 }
 
 export async function getGitBranches(workspaceRoot: string): Promise {
-  const cwd = workspaceRoot.trim()
+  const cwd = await resolveGitCwd(workspaceRoot)
   if (!cwd) {
     return { ok: false, reason: 'no_workspace', message: 'No working directory selected.' }
   }
@@ -60,7 +82,7 @@ export async function switchGitBranch(
   workspaceRoot: string,
   branchName: string
 ): Promise {
-  const cwd = workspaceRoot.trim()
+  const cwd = await resolveGitCwd(workspaceRoot)
   const branch = branchName.trim()
   if (!cwd) return { ok: false, reason: 'no_workspace', message: 'No working directory selected.' }
   if (!branch) return { ok: false, reason: 'error', message: 'Branch name is required.' }
@@ -80,7 +102,7 @@ export async function createAndSwitchGitBranch(
   workspaceRoot: string,
   branchName: string
 ): Promise {
-  const cwd = workspaceRoot.trim()
+  const cwd = await resolveGitCwd(workspaceRoot)
   const branch = branchName.trim()
   if (!cwd) return { ok: false, reason: 'no_workspace', message: 'No working directory selected.' }
   if (!branch) return { ok: false, reason: 'error', message: 'Branch name is required.' }
diff --git a/src/main/services/prototype-embed-registry.test.ts b/src/main/services/prototype-embed-registry.test.ts
new file mode 100644
index 00000000..7a5d5d90
--- /dev/null
+++ b/src/main/services/prototype-embed-registry.test.ts
@@ -0,0 +1,62 @@
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+  authorizePrototypePath,
+  clearAuthorizedPrototypes,
+  isAuthorizedPrototypeFileUrl
+} from './prototype-embed-registry'
+
+let workspace: string
+
+describe('prototype embed registry', () => {
+  beforeEach(() => {
+    workspace = realpathSync(mkdtempSync(join(tmpdir(), 'proto-registry-')))
+    mkdirSync(join(workspace, '.kunsdd', 'proto'), { recursive: true })
+    writeFileSync(join(workspace, '.kunsdd', 'proto', 'page.html'), '')
+  })
+
+  afterEach(() => {
+    clearAuthorizedPrototypes()
+    rmSync(workspace, { recursive: true, force: true })
+  })
+
+  it('authorizes a contained prototype and allow-lists its file url', async () => {
+    const result = await authorizePrototypePath(
+      join(workspace, '.kunsdd', 'proto', 'page.html'),
+      workspace
+    )
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+    expect(result.fileUrl.startsWith('file://')).toBe(true)
+    expect(isAuthorizedPrototypeFileUrl(result.fileUrl)).toBe(true)
+  })
+
+  it('rejects non-html files and files outside a proto directory', async () => {
+    writeFileSync(join(workspace, '.kunsdd', 'proto', 'notes.txt'), 'x')
+    writeFileSync(join(workspace, 'loose.html'), '')
+
+    const txt = await authorizePrototypePath(join(workspace, '.kunsdd', 'proto', 'notes.txt'), workspace)
+    expect(txt.ok).toBe(false)
+
+    const loose = await authorizePrototypePath(join(workspace, 'loose.html'), workspace)
+    expect(loose).toMatchObject({ ok: false, message: expect.stringContaining('proto directory') })
+  })
+
+  it('rejects paths escaping the workspace and missing files', async () => {
+    const escaped = await authorizePrototypePath('/tmp/.kunsdd/proto/evil.html', workspace)
+    expect(escaped.ok).toBe(false)
+
+    const missing = await authorizePrototypePath(
+      join(workspace, '.kunsdd', 'proto', 'gone.html'),
+      workspace
+    )
+    expect(missing).toMatchObject({ ok: false, message: expect.stringContaining('not found') })
+  })
+
+  it('only admits exact previously-authorized file urls', () => {
+    expect(isAuthorizedPrototypeFileUrl('file:///anything.html')).toBe(false)
+    expect(isAuthorizedPrototypeFileUrl('https://example.com')).toBe(false)
+  })
+})
diff --git a/src/main/services/prototype-embed-registry.ts b/src/main/services/prototype-embed-registry.ts
new file mode 100644
index 00000000..ea49320a
--- /dev/null
+++ b/src/main/services/prototype-embed-registry.ts
@@ -0,0 +1,68 @@
+import { stat } from 'node:fs/promises'
+import { resolveTargetPathWithinWorkspace } from './workspace-paths'
+import { writePathToFileUrl } from '../../shared/write-markdown-resource'
+
+/**
+ * Authorization gate for embedded prototype webviews.
+ *
+ * `will-attach-webview` rejects every webview src except dev-preview URLs, so
+ * file:// prototypes must be allow-listed first: the renderer asks to
+ * authorize a concrete document path, the main process validates it
+ * (workspace containment with symlink canonicalization, a `proto` directory
+ * segment, an .html extension) and records the resulting file URL. The guard
+ * then admits exactly those URLs for the rest of the session.
+ */
+
+const MAX_AUTHORIZED_PROTOTYPES = 256
+
+const authorizedFileUrls = new Set()
+
+export type AuthorizePrototypeResult =
+  | { ok: true; absolutePath: string; fileUrl: string }
+  | { ok: false; message: string }
+
+function hasPrototypeDirSegment(path: string): boolean {
+  return path.replaceAll('\\', '/').split('/').includes('proto')
+}
+
+export async function authorizePrototypePath(
+  path: string,
+  workspaceRoot: string
+): Promise {
+  if (!/\.html?$/i.test(path.trim())) {
+    return { ok: false, message: 'only .html prototypes can be embedded' }
+  }
+  let absolutePath: string
+  try {
+    // Canonicalizes (symlinks resolved) and throws when the target escapes
+    // the workspace root.
+    absolutePath = await resolveTargetPathWithinWorkspace(path, workspaceRoot)
+  } catch (error) {
+    return { ok: false, message: error instanceof Error ? error.message : String(error) }
+  }
+  if (!hasPrototypeDirSegment(absolutePath)) {
+    return { ok: false, message: 'prototypes must live in a proto directory' }
+  }
+  try {
+    const info = await stat(absolutePath)
+    if (!info.isFile()) return { ok: false, message: 'prototype file not found' }
+  } catch {
+    return { ok: false, message: 'prototype file not found' }
+  }
+  const fileUrl = writePathToFileUrl(absolutePath)
+  if (!authorizedFileUrls.has(fileUrl) && authorizedFileUrls.size >= MAX_AUTHORIZED_PROTOTYPES) {
+    const oldest = authorizedFileUrls.values().next().value
+    if (oldest) authorizedFileUrls.delete(oldest)
+  }
+  authorizedFileUrls.add(fileUrl)
+  return { ok: true, absolutePath, fileUrl }
+}
+
+export function isAuthorizedPrototypeFileUrl(src: string): boolean {
+  if (!src.startsWith('file://')) return false
+  return authorizedFileUrls.has(src.split(/[?#]/, 1)[0])
+}
+
+export function clearAuthorizedPrototypes(): void {
+  authorizedFileUrls.clear()
+}
diff --git a/src/main/services/speech-to-text-service.test.ts b/src/main/services/speech-to-text-service.test.ts
new file mode 100644
index 00000000..39b700f4
--- /dev/null
+++ b/src/main/services/speech-to-text-service.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from 'vitest'
+import type { AppSettingsV1 } from '../../shared/app-settings'
+import { isSpeechToTextConfigured, requestSpeechTranscription } from './speech-to-text-service'
+
+const AUDIO_BASE64 = Buffer.from('fake-wav-bytes').toString('base64')
+
+function settingsWithSpeech(overrides: Record = {}): AppSettingsV1 {
+  return {
+    agents: {
+      kun: {
+        speechToText: {
+          enabled: true,
+          providerId: '',
+          protocol: 'mimo-asr',
+          baseUrl: 'https://speech.example.test/v1',
+          apiKey: 'sk-speech',
+          model: 'mimo-v2.5-asr',
+          language: '',
+          timeoutMs: 30000,
+          ...overrides
+        }
+      }
+    }
+  } as unknown as AppSettingsV1
+}
+
+type RecordedRequest = { url: string; init: RequestInit }
+
+function fakeFetch(body: unknown, status = 200): { fetchImpl: typeof fetch; requests: RecordedRequest[] } {
+  const requests: RecordedRequest[] = []
+  const fetchImpl = (async (url: unknown, init?: RequestInit) => {
+    requests.push({ url: String(url), init: init ?? {} })
+    return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status })
+  }) as typeof fetch
+  return { fetchImpl, requests }
+}
+
+describe('speech-to-text service', () => {
+  it('rejects when the speech provider is not configured', async () => {
+    const result = await requestSpeechTranscription(settingsWithSpeech({ apiKey: '' }), {
+      audioBase64: AUDIO_BASE64,
+      mimeType: 'audio/wav'
+    })
+    expect(result).toMatchObject({ ok: false, message: expect.stringContaining('not configured') })
+  })
+
+  it('reports configuration state from enabled/baseUrl/apiKey/model', () => {
+    expect(isSpeechToTextConfigured({ enabled: true, baseUrl: 'x', apiKey: 'y', model: 'z' })).toBe(true)
+    expect(isSpeechToTextConfigured({ enabled: false, baseUrl: 'x', apiKey: 'y', model: 'z' })).toBe(false)
+    expect(isSpeechToTextConfigured({ enabled: true, baseUrl: '', apiKey: 'y', model: 'z' })).toBe(false)
+  })
+
+  it('transcribes via MiMo ASR chat completions with a base64 data URI', async () => {
+    const { fetchImpl, requests } = fakeFetch({
+      choices: [{ message: { content: ' 你好,世界 ' } }]
+    })
+    const result = await requestSpeechTranscription(
+      settingsWithSpeech(),
+      { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav', durationMs: 1200 },
+      { fetchImpl }
+    )
+
+    expect(result).toEqual({ ok: true, text: '你好,世界' })
+    expect(requests).toHaveLength(1)
+    expect(requests[0].url).toBe('https://speech.example.test/v1/chat/completions')
+    const payload = JSON.parse(String(requests[0].init.body))
+    expect(payload.model).toBe('mimo-v2.5-asr')
+    expect(payload.asr_options).toEqual({ language: 'auto' })
+    expect(payload.messages[0].content[0]).toEqual({
+      type: 'input_audio',
+      input_audio: { data: `data:audio/wav;base64,${AUDIO_BASE64}` }
+    })
+    const headers = requests[0].init.headers as Record
+    expect(headers.Authorization).toBe('Bearer sk-speech')
+  })
+
+  it('uses renderer-provided resolved speech settings with inherited provider credentials', async () => {
+    const { fetchImpl, requests } = fakeFetch({
+      choices: [{ message: { content: ' 你好 ' } }]
+    })
+    const result = await requestSpeechTranscription(
+      settingsWithSpeech({ enabled: false, apiKey: '' }),
+      {
+        audioBase64: AUDIO_BASE64,
+        mimeType: 'audio/wav',
+        speechToText: {
+          enabled: true,
+          providerId: 'xiaomi-token-plan',
+          protocol: 'mimo-asr',
+          baseUrl: 'https://token-plan-cn.xiaomimimo.com/v1',
+          apiKey: 'tp-provider',
+          model: 'mimo-v2.5-asr',
+          language: 'zh',
+          timeoutMs: 30000
+        }
+      },
+      { fetchImpl }
+    )
+
+    expect(result).toEqual({ ok: true, text: '你好' })
+    expect(requests[0].url).toBe('https://token-plan-cn.xiaomimimo.com/v1/chat/completions')
+    const headers = requests[0].init.headers as Record
+    expect(headers.Authorization).toBe('Bearer tp-provider')
+    expect(headers['api-key']).toBe('tp-provider')
+    expect(JSON.parse(String(requests[0].init.body)).model).toBe('mimo-v2.5-asr')
+  })
+
+  it('passes the configured language hint to MiMo ASR', async () => {
+    const { fetchImpl, requests } = fakeFetch({ choices: [{ message: { content: 'hi' } }] })
+    await requestSpeechTranscription(
+      settingsWithSpeech({ language: 'zh' }),
+      { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' },
+      { fetchImpl }
+    )
+    expect(JSON.parse(String(requests[0].init.body)).asr_options).toEqual({ language: 'zh' })
+  })
+
+  it('transcribes via OpenAI-compatible audio/transcriptions multipart upload', async () => {
+    const { fetchImpl, requests } = fakeFetch({ text: 'hello world' })
+    const result = await requestSpeechTranscription(
+      settingsWithSpeech({ protocol: 'openai-transcriptions', model: 'whisper-1' }),
+      { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' },
+      { fetchImpl }
+    )
+
+    expect(result).toEqual({ ok: true, text: 'hello world' })
+    expect(requests[0].url).toBe('https://speech.example.test/v1/audio/transcriptions')
+    const form = requests[0].init.body as FormData
+    expect(form.get('model')).toBe('whisper-1')
+    expect(form.get('file')).toBeInstanceOf(Blob)
+  })
+
+  it('surfaces upstream HTTP errors as failure messages', async () => {
+    const { fetchImpl } = fakeFetch({ error: { message: 'invalid api key' } }, 401)
+    const result = await requestSpeechTranscription(
+      settingsWithSpeech(),
+      { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' },
+      { fetchImpl }
+    )
+    expect(result).toMatchObject({ ok: false, message: expect.stringContaining('HTTP 401') })
+  })
+
+  it('rejects an empty transcription result', async () => {
+    const { fetchImpl } = fakeFetch({ choices: [{ message: { content: '   ' } }] })
+    const result = await requestSpeechTranscription(
+      settingsWithSpeech(),
+      { audioBase64: AUDIO_BASE64, mimeType: 'audio/wav' },
+      { fetchImpl }
+    )
+    expect(result).toMatchObject({ ok: false, message: expect.stringContaining('empty') })
+  })
+})
diff --git a/src/main/services/speech-to-text-service.ts b/src/main/services/speech-to-text-service.ts
new file mode 100644
index 00000000..c657c169
--- /dev/null
+++ b/src/main/services/speech-to-text-service.ts
@@ -0,0 +1,167 @@
+import {
+  resolveKunSpeechToTextSettings,
+  type AppSettingsV1,
+  type KunSpeechToTextSettingsV1
+} from '../../shared/app-settings'
+import {
+  SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS,
+  type SpeechTranscriptionRequest,
+  type SpeechTranscriptionResult
+} from '../../shared/speech-to-text'
+import { describeNetworkError } from '../../../kun/src/adapters/tool/image-gen-tool-provider.js'
+
+const FILE_EXTENSION_BY_MIME: Record = {
+  'audio/wav': 'wav',
+  'audio/x-wav': 'wav',
+  'audio/mpeg': 'mp3',
+  'audio/mp4': 'm4a',
+  'audio/webm': 'webm',
+  'audio/ogg': 'ogg',
+  'audio/flac': 'flac'
+}
+
+export function isSpeechToTextConfigured(
+  speechToText: Pick
+): boolean {
+  return (
+    speechToText.enabled &&
+    Boolean(speechToText.baseUrl.trim()) &&
+    Boolean(speechToText.apiKey.trim()) &&
+    Boolean(speechToText.model.trim())
+  )
+}
+
+export async function requestSpeechTranscription(
+  settings: AppSettingsV1,
+  request: SpeechTranscriptionRequest,
+  options: { fetchImpl?: typeof fetch } = {}
+): Promise {
+  const speechToText = request.speechToText ?? resolveKunSpeechToTextSettings(settings)
+  if (!isSpeechToTextConfigured(speechToText)) {
+    return { ok: false, message: 'speech-to-text provider is not configured' }
+  }
+  if (!request.audioBase64 || request.audioBase64.length > SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS) {
+    return { ok: false, message: 'audio payload is empty or too large' }
+  }
+
+  const fetchImpl = options.fetchImpl ?? fetch
+  try {
+    const text = speechToText.protocol === 'mimo-asr'
+      ? await transcribeViaMimoAsr(speechToText, request, fetchImpl)
+      : await transcribeViaOpenAiTranscriptions(speechToText, request, fetchImpl)
+    const trimmed = text.trim()
+    if (!trimmed) return { ok: false, message: 'transcription result is empty' }
+    return { ok: true, text: trimmed }
+  } catch (error) {
+    return { ok: false, message: describeTranscriptionError(error, speechToText.timeoutMs) }
+  }
+}
+
+/**
+ * Xiaomi MiMo ASR rides the OpenAI-compatible chat completions endpoint:
+ * the audio goes in as a base64 data URI inside an `input_audio` content
+ * part and the transcript comes back as the assistant message content.
+ */
+async function transcribeViaMimoAsr(
+  speechToText: KunSpeechToTextSettingsV1,
+  request: SpeechTranscriptionRequest,
+  fetchImpl: typeof fetch
+): Promise {
+  const url = joinSpeechApiUrl(speechToText.baseUrl, 'chat/completions')
+  const response = await fetchImpl(url, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${speechToText.apiKey}`,
+      'api-key': speechToText.apiKey
+    },
+    body: JSON.stringify({
+      model: speechToText.model,
+      messages: [
+        {
+          role: 'user',
+          content: [
+            {
+              type: 'input_audio',
+              input_audio: {
+                data: `data:${request.mimeType};base64,${request.audioBase64}`
+              }
+            }
+          ]
+        }
+      ],
+      asr_options: {
+        language: speechToText.language || 'auto'
+      },
+      stream: false
+    }),
+    signal: AbortSignal.timeout(speechToText.timeoutMs)
+  })
+  const body = await response.text()
+  if (!response.ok) throw new SpeechHttpError(response.status, body)
+  const parsed = JSON.parse(body) as {
+    choices?: Array<{ message?: { content?: unknown } }>
+  }
+  const content = parsed.choices?.[0]?.message?.content
+  if (typeof content === 'string') return content
+  if (Array.isArray(content)) {
+    return content
+      .map((part) => (typeof (part as { text?: unknown })?.text === 'string' ? (part as { text: string }).text : ''))
+      .join('')
+  }
+  throw new Error('speech response has no transcript content')
+}
+
+/** Standard OpenAI-style multipart upload to {baseUrl}/audio/transcriptions. */
+async function transcribeViaOpenAiTranscriptions(
+  speechToText: KunSpeechToTextSettingsV1,
+  request: SpeechTranscriptionRequest,
+  fetchImpl: typeof fetch
+): Promise {
+  const url = joinSpeechApiUrl(speechToText.baseUrl, 'audio/transcriptions')
+  const audio = Buffer.from(request.audioBase64, 'base64')
+  const form = new FormData()
+  const extension = FILE_EXTENSION_BY_MIME[request.mimeType.toLowerCase()] ?? 'wav'
+  form.append('file', new Blob([new Uint8Array(audio)], { type: request.mimeType }), `recording.${extension}`)
+  form.append('model', speechToText.model)
+  form.append('response_format', 'json')
+  if (speechToText.language && speechToText.language !== 'auto') {
+    form.append('language', speechToText.language)
+  }
+  const response = await fetchImpl(url, {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${speechToText.apiKey}` },
+    body: form,
+    signal: AbortSignal.timeout(speechToText.timeoutMs)
+  })
+  const body = await response.text()
+  if (!response.ok) throw new SpeechHttpError(response.status, body)
+  const parsed = JSON.parse(body) as { text?: unknown }
+  if (typeof parsed.text !== 'string') throw new Error('speech response has no transcript text')
+  return parsed.text
+}
+
+export class SpeechHttpError extends Error {
+  constructor(
+    readonly status: number,
+    readonly body: string
+  ) {
+    super(`HTTP ${status}: ${body.slice(0, 500)}`)
+  }
+}
+
+export function joinSpeechApiUrl(baseUrl: string, path: string): string {
+  return `${baseUrl.trim().replace(/\/+$/, '')}/${path}`
+}
+
+function describeTranscriptionError(error: unknown, timeoutMs: number): string {
+  if (error instanceof SpeechHttpError) return error.message
+  if (error instanceof DOMException && error.name === 'TimeoutError') {
+    return `speech request timed out after ${timeoutMs}ms`
+  }
+  if (error instanceof DOMException && error.name === 'AbortError') {
+    return 'speech request was canceled'
+  }
+  if (error instanceof SyntaxError) return 'speech response is not valid JSON'
+  return describeNetworkError(error)
+}
diff --git a/src/main/services/ui-plugin-service.test.ts b/src/main/services/ui-plugin-service.test.ts
new file mode 100644
index 00000000..2327511b
--- /dev/null
+++ b/src/main/services/ui-plugin-service.test.ts
@@ -0,0 +1,177 @@
+import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+  installUiPluginFromDirectory,
+  listUiPlugins,
+  loadUiPluginFigures,
+  removeUiPlugin,
+  seedUiPlugin,
+  uiPluginsRootDir
+} from './ui-plugin-service'
+
+/** 1x1 transparent PNG */
+const PNG_BYTES = Buffer.from(
+  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+  'base64'
+)
+
+let userDataDir = ''
+let sourceDir = ''
+
+async function writeSourcePlugin(manifest: unknown, figures: string[] = ['img/swim.png']): Promise {
+  await mkdir(join(sourceDir, 'img'), { recursive: true })
+  await writeFile(join(sourceDir, 'manifest.json'), JSON.stringify(manifest), 'utf8')
+  for (const figure of figures) {
+    await mkdir(join(sourceDir, figure, '..'), { recursive: true })
+    await writeFile(join(sourceDir, ...figure.split('/')), PNG_BYTES)
+  }
+}
+
+const manifest = {
+  id: 'starlight',
+  name: '星夜',
+  version: '1.0.0',
+  figures: { swim: 'img/swim.png' }
+}
+
+beforeEach(async () => {
+  userDataDir = await mkdtemp(join(tmpdir(), 'kun-ui-plugin-data-'))
+  sourceDir = await mkdtemp(join(tmpdir(), 'kun-ui-plugin-src-'))
+})
+
+afterEach(async () => {
+  await rm(userDataDir, { recursive: true, force: true })
+  await rm(sourceDir, { recursive: true, force: true })
+})
+
+describe('installUiPluginFromDirectory', () => {
+  it('installs a valid plugin by allowlist copy and lists it', async () => {
+    await writeSourcePlugin(manifest)
+    // 源目录里混入不该被复制的文件
+    await writeFile(join(sourceDir, 'evil.js'), 'process.exit(1)', 'utf8')
+    await writeFile(join(sourceDir, 'img', 'unreferenced.png'), PNG_BYTES)
+
+    const result = await installUiPluginFromDirectory(userDataDir, sourceDir)
+    expect(result.ok).toBe(true)
+
+    const installedFiles = await readdir(join(uiPluginsRootDir(userDataDir), 'starlight'), {
+      recursive: true
+    })
+    const flat = installedFiles.map(String).sort()
+    expect(flat).toContain('manifest.json')
+    expect(flat).toContain(join('img', 'swim.png'))
+    expect(flat).not.toContain('evil.js')
+    expect(flat.some((f) => f.includes('unreferenced'))).toBe(false)
+
+    const plugins = await listUiPlugins(userDataDir)
+    expect(plugins).toHaveLength(1)
+    expect(plugins[0]?.manifest.id).toBe('starlight')
+    expect(plugins[0]?.previewDataUrl?.startsWith('data:image/png;base64,')).toBe(true)
+  })
+
+  it('rejects manifests with missing figures or invalid content', async () => {
+    await writeSourcePlugin({ ...manifest, figures: { swim: 'img/missing.png' } }, [])
+    const missing = await installUiPluginFromDirectory(userDataDir, sourceDir)
+    expect(missing.ok).toBe(false)
+
+    await writeFile(join(sourceDir, 'manifest.json'), '{ not json', 'utf8')
+    const invalid = await installUiPluginFromDirectory(userDataDir, sourceDir)
+    expect(invalid.ok).toBe(false)
+  })
+})
+
+describe('loadUiPluginFigures', () => {
+  it('returns data URLs for installed figures', async () => {
+    await writeSourcePlugin(manifest)
+    await installUiPluginFromDirectory(userDataDir, sourceDir)
+
+    const result = await loadUiPluginFigures(userDataDir, 'starlight')
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+    expect(result.figures.swim?.startsWith('data:image/png;base64,')).toBe(true)
+  })
+
+  it('refuses ids that escape the plugins root', async () => {
+    const result = await loadUiPluginFigures(userDataDir, '../outside')
+    expect(result.ok).toBe(false)
+  })
+})
+
+describe('removeUiPlugin', () => {
+  it('removes an installed plugin and refuses traversal ids', async () => {
+    await writeSourcePlugin(manifest)
+    await installUiPluginFromDirectory(userDataDir, sourceDir)
+
+    expect(await removeUiPlugin(userDataDir, '../escape')).toBe(false)
+    expect(await removeUiPlugin(userDataDir, 'starlight')).toBe(true)
+    expect(await listUiPlugins(userDataDir)).toHaveLength(0)
+  })
+})
+
+describe('seedUiPlugin (bundled plugins like ikun)', () => {
+  it('seeds a plugin from in-memory bytes and it lists/loads like any other', async () => {
+    const result = await seedUiPlugin(
+      userDataDir,
+      {
+        id: 'ikun',
+        name: 'iKun 模式',
+        version: '1.0.0',
+        figures: { swim: 'img/dribble.png', greet: 'img/wave.png' },
+        features: { cameos: true }
+      },
+      { swim: PNG_BYTES, greet: PNG_BYTES }
+    )
+    expect(result.ok, JSON.stringify(result)).toBe(true)
+
+    const plugins = await listUiPlugins(userDataDir)
+    expect(plugins.map((p) => p.manifest.id)).toContain('ikun')
+
+    const loaded = await loadUiPluginFigures(userDataDir, 'ikun')
+    expect(loaded.ok).toBe(true)
+    if (!loaded.ok) return
+    expect(loaded.figures.swim?.startsWith('data:image/png;base64,')).toBe(true)
+    expect(loaded.manifest.features?.cameos).toBe(true)
+  })
+
+  it('rejects seeding when figure bytes are missing', async () => {
+    const result = await seedUiPlugin(
+      userDataDir,
+      { id: 'ikun', name: 'x', version: '1.0.0', figures: { swim: 'img/a.png' } },
+      {}
+    )
+    expect(result.ok).toBe(false)
+  })
+})
+
+describe('bundled starlight example', () => {
+  it('installs and loads end to end', async () => {
+    const exampleDir = join(process.cwd(), 'examples', 'ui-plugins', 'starlight')
+    const installed = await installUiPluginFromDirectory(userDataDir, exampleDir)
+    expect(installed.ok, JSON.stringify(installed)).toBe(true)
+
+    const loaded = await loadUiPluginFigures(userDataDir, 'starlight')
+    expect(loaded.ok).toBe(true)
+    if (!loaded.ok) return
+    expect(loaded.manifest.name).toBe('星夜 Kun')
+    expect(loaded.figures.swim?.startsWith('data:image/png;base64,')).toBe(true)
+    expect(loaded.manifest.features?.cameos).toBe(true)
+    expect(loaded.manifest.tokens?.light?.['--ds-accent']).toBe('#7a5fd0')
+  })
+})
+
+describe('listUiPlugins', () => {
+  it('skips directories whose name does not match manifest id', async () => {
+    await writeSourcePlugin(manifest)
+    await installUiPluginFromDirectory(userDataDir, sourceDir)
+    // 手工伪造一个目录名与 id 不一致的插件
+    const fakeDir = join(uiPluginsRootDir(userDataDir), 'impostor')
+    await mkdir(join(fakeDir, 'img'), { recursive: true })
+    await writeFile(join(fakeDir, 'manifest.json'), JSON.stringify(manifest), 'utf8')
+    await writeFile(join(fakeDir, 'img', 'swim.png'), PNG_BYTES)
+
+    const plugins = await listUiPlugins(userDataDir)
+    expect(plugins.map((p) => p.manifest.id)).toEqual(['starlight'])
+  })
+})
diff --git a/src/main/services/ui-plugin-service.ts b/src/main/services/ui-plugin-service.ts
new file mode 100644
index 00000000..c53fb9ea
--- /dev/null
+++ b/src/main/services/ui-plugin-service.ts
@@ -0,0 +1,316 @@
+import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { dirname, join, resolve, sep } from 'node:path'
+import {
+  UI_PLUGIN_LIMITS,
+  UI_PLUGIN_MANIFEST_FILENAME,
+  isSafeUiPluginFigurePath,
+  normalizeUiPluginManifest,
+  type UiPluginFigureSlot,
+  type UiPluginListItem,
+  type UiPluginManifestV1,
+  type UiPluginRuntimeFigures
+} from '../../shared/ui-plugin'
+
+/**
+ * UI 插件落盘服务。插件目录:~/.kun/ui-plugins//
+ * 安装走"白名单复制":只复制 manifest.json 与被 figures 引用到的图片,
+ * 源目录里的其它任何文件(脚本、可执行文件等)一概不进入 Kun 数据目录。
+ */
+
+export type UiPluginInstallResult =
+  | { ok: true; plugin: UiPluginListItem }
+  | { ok: false; errors: string[] }
+
+export type UiPluginLoadResult =
+  | { ok: true; manifest: UiPluginManifestV1; figures: UiPluginRuntimeFigures }
+  | { ok: false; error: string }
+
+const FIGURE_MIME_BY_EXTENSION: Record = {
+  png: 'image/png',
+  webp: 'image/webp',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  gif: 'image/gif'
+}
+
+export function uiPluginsRootDir(kunHomeDir: string): string {
+  return join(kunHomeDir, 'ui-plugins')
+}
+
+function confinedPluginPath(rootDir: string, pluginId: string, relativePath?: string): string {
+  const base = resolve(rootDir)
+  const target = resolve(base, pluginId, ...(relativePath ? relativePath.split('/') : []))
+  if (target !== base && !target.startsWith(base + sep)) {
+    throw new Error(`UI plugin path escapes plugins root: ${pluginId}/${relativePath ?? ''}`)
+  }
+  return target
+}
+
+function figureDataUrl(filePath: string, bytes: Buffer): string {
+  const extension = filePath.split('.').pop()?.toLowerCase() ?? ''
+  const mime = FIGURE_MIME_BY_EXTENSION[extension] ?? 'application/octet-stream'
+  return `data:${mime};base64,${bytes.toString('base64')}`
+}
+
+async function readFigureWithCaps(
+  filePath: string,
+  budget: { remaining: number }
+): Promise<{ ok: true; dataUrl: string } | { ok: false; error: string }> {
+  let size: number
+  try {
+    const info = await stat(filePath)
+    if (!info.isFile()) return { ok: false, error: '不是文件' }
+    size = info.size
+  } catch {
+    return { ok: false, error: '文件不存在' }
+  }
+  if (size > UI_PLUGIN_LIMITS.figureBytes) {
+    return { ok: false, error: `图片超过 ${Math.round(UI_PLUGIN_LIMITS.figureBytes / 1024 / 1024)}MB 上限` }
+  }
+  if (size > budget.remaining) {
+    return { ok: false, error: '插件图片总体积超过上限' }
+  }
+  budget.remaining -= size
+  const bytes = await readFile(filePath)
+  return { ok: true, dataUrl: figureDataUrl(filePath, bytes) }
+}
+
+async function readManifestAt(dir: string): Promise<
+  | { ok: true; manifest: UiPluginManifestV1 }
+  | { ok: false; errors: string[] }
+> {
+  const manifestPath = join(dir, UI_PLUGIN_MANIFEST_FILENAME)
+  let text: string
+  try {
+    const info = await stat(manifestPath)
+    if (info.size > UI_PLUGIN_LIMITS.manifestBytes) {
+      return { ok: false, errors: ['manifest.json 超过 64KB 上限'] }
+    }
+    text = await readFile(manifestPath, 'utf8')
+  } catch {
+    return { ok: false, errors: ['目录里找不到 manifest.json'] }
+  }
+  let raw: unknown
+  try {
+    raw = JSON.parse(text)
+  } catch (error) {
+    return {
+      ok: false,
+      errors: [`manifest.json 不是合法 JSON:${error instanceof Error ? error.message : String(error)}`]
+    }
+  }
+  return normalizeUiPluginManifest(raw)
+}
+
+async function readPluginPreview(
+  pluginDir: string,
+  manifest: UiPluginManifestV1
+): Promise {
+  const previewSlots: UiPluginFigureSlot[] = ['toggleIcon', 'swim', 'greet', 'sit', 'sleep', 'run', 'surf']
+  for (const slot of previewSlots) {
+    const relativePath = manifest.figures[slot]
+    if (!relativePath) continue
+    const budget = { remaining: UI_PLUGIN_LIMITS.figureBytes }
+    const result = await readFigureWithCaps(join(pluginDir, ...relativePath.split('/')), budget)
+    if (result.ok) return result.dataUrl
+  }
+  return null
+}
+
+export async function listUiPlugins(userDataDir: string): Promise {
+  const rootDir = uiPluginsRootDir(userDataDir)
+  let entries: string[]
+  try {
+    entries = (await readdir(rootDir, { withFileTypes: true }))
+      .filter((entry) => entry.isDirectory())
+      .map((entry) => entry.name)
+  } catch {
+    return []
+  }
+
+  const plugins: UiPluginListItem[] = []
+  for (const entry of entries.sort()) {
+    let pluginDir: string
+    try {
+      pluginDir = confinedPluginPath(rootDir, entry)
+    } catch {
+      continue
+    }
+    const manifestResult = await readManifestAt(pluginDir)
+    if (!manifestResult.ok) continue
+    // 目录名必须与 manifest id 一致,避免同一插件多份伪装
+    if (manifestResult.manifest.id !== entry) continue
+    plugins.push({
+      manifest: manifestResult.manifest,
+      previewDataUrl: await readPluginPreview(pluginDir, manifestResult.manifest)
+    })
+  }
+  return plugins
+}
+
+export async function loadUiPluginFigures(
+  userDataDir: string,
+  pluginId: string
+): Promise {
+  const rootDir = uiPluginsRootDir(userDataDir)
+  let pluginDir: string
+  try {
+    pluginDir = confinedPluginPath(rootDir, pluginId)
+  } catch (error) {
+    return { ok: false, error: error instanceof Error ? error.message : String(error) }
+  }
+  const manifestResult = await readManifestAt(pluginDir)
+  if (!manifestResult.ok) {
+    return { ok: false, error: manifestResult.errors.join('; ') }
+  }
+  const manifest = manifestResult.manifest
+  if (manifest.id !== pluginId) {
+    return { ok: false, error: '插件目录与 manifest id 不一致' }
+  }
+
+  const figures: UiPluginRuntimeFigures = {}
+  const budget = { remaining: UI_PLUGIN_LIMITS.totalFigureBytes }
+  for (const [slot, relativePath] of Object.entries(manifest.figures)) {
+    if (!relativePath || !isSafeUiPluginFigurePath(relativePath)) continue
+    let figurePath: string
+    try {
+      figurePath = confinedPluginPath(rootDir, pluginId, relativePath)
+    } catch {
+      continue
+    }
+    const result = await readFigureWithCaps(figurePath, budget)
+    if (!result.ok) {
+      return { ok: false, error: `槽位 ${slot} 加载失败:${result.error}` }
+    }
+    figures[slot as UiPluginFigureSlot] = result.dataUrl
+  }
+  return { ok: true, manifest, figures }
+}
+
+export async function installUiPluginFromDirectory(
+  userDataDir: string,
+  sourceDir: string
+): Promise {
+  const manifestResult = await readManifestAt(sourceDir)
+  if (!manifestResult.ok) return { ok: false, errors: manifestResult.errors }
+  const manifest = manifestResult.manifest
+
+  // 先在源目录核验每张被引用的图片(存在 + 体积)
+  const errors: string[] = []
+  const budget = { remaining: UI_PLUGIN_LIMITS.totalFigureBytes }
+  const figureFiles: Array<{ relativePath: string; bytes: Buffer }> = []
+  for (const [slot, relativePath] of Object.entries(manifest.figures)) {
+    if (!relativePath) continue
+    const sourcePath = resolve(sourceDir, ...relativePath.split('/'))
+    if (sourcePath !== resolve(sourceDir) && !sourcePath.startsWith(resolve(sourceDir) + sep)) {
+      errors.push(`槽位 ${slot} 的路径越界`)
+      continue
+    }
+    const check = await readFigureWithCaps(sourcePath, budget)
+    if (!check.ok) {
+      errors.push(`槽位 ${slot}(${relativePath}):${check.error}`)
+      continue
+    }
+    figureFiles.push({ relativePath, bytes: await readFile(sourcePath) })
+  }
+  if (errors.length > 0) return { ok: false, errors }
+
+  const rootDir = uiPluginsRootDir(userDataDir)
+  const targetDir = confinedPluginPath(rootDir, manifest.id)
+  await rm(targetDir, { recursive: true, force: true })
+  await mkdir(targetDir, { recursive: true })
+  await writeFile(
+    join(targetDir, UI_PLUGIN_MANIFEST_FILENAME),
+    `${JSON.stringify(manifest, null, 2)}\n`,
+    'utf8'
+  )
+  for (const file of figureFiles) {
+    const targetPath = confinedPluginPath(rootDir, manifest.id, file.relativePath)
+    await mkdir(dirname(targetPath), { recursive: true })
+    await writeFile(targetPath, file.bytes)
+  }
+
+  return {
+    ok: true,
+    plugin: {
+      manifest,
+      previewDataUrl: await readPluginPreview(targetDir, manifest)
+    }
+  }
+}
+
+/**
+ * 用内存字节落盘一个插件(预装插件用)。figureBytes 的键是槽位名,
+ * 落盘路径取 manifest.figures 声明的相对路径。
+ */
+export async function seedUiPlugin(
+  userDataDir: string,
+  manifestRaw: unknown,
+  figureBytes: Record
+): Promise {
+  const manifestResult = normalizeUiPluginManifest(manifestRaw)
+  if (!manifestResult.ok) return { ok: false, errors: manifestResult.errors }
+  const manifest = manifestResult.manifest
+
+  const errors: string[] = []
+  let totalBytes = 0
+  for (const [slot, relativePath] of Object.entries(manifest.figures)) {
+    if (!relativePath) continue
+    const bytes = figureBytes[slot]
+    if (!bytes) {
+      errors.push(`槽位 ${slot} 缺少预装图片数据`)
+      continue
+    }
+    if (bytes.byteLength > UI_PLUGIN_LIMITS.figureBytes) {
+      errors.push(`槽位 ${slot} 图片超过单张上限`)
+    }
+    totalBytes += bytes.byteLength
+  }
+  if (totalBytes > UI_PLUGIN_LIMITS.totalFigureBytes) {
+    errors.push('预装插件图片总体积超过上限')
+  }
+  if (errors.length > 0) return { ok: false, errors }
+
+  const rootDir = uiPluginsRootDir(userDataDir)
+  const targetDir = confinedPluginPath(rootDir, manifest.id)
+  await rm(targetDir, { recursive: true, force: true })
+  await mkdir(targetDir, { recursive: true })
+  await writeFile(
+    join(targetDir, UI_PLUGIN_MANIFEST_FILENAME),
+    `${JSON.stringify(manifest, null, 2)}\n`,
+    'utf8'
+  )
+  for (const [slot, relativePath] of Object.entries(manifest.figures)) {
+    if (!relativePath) continue
+    const bytes = figureBytes[slot]
+    if (!bytes) continue
+    const targetPath = confinedPluginPath(rootDir, manifest.id, relativePath)
+    await mkdir(dirname(targetPath), { recursive: true })
+    await writeFile(targetPath, bytes)
+  }
+
+  return {
+    ok: true,
+    plugin: {
+      manifest,
+      previewDataUrl: await readPluginPreview(targetDir, manifest)
+    }
+  }
+}
+
+export async function removeUiPlugin(userDataDir: string, pluginId: string): Promise {
+  const rootDir = uiPluginsRootDir(userDataDir)
+  let pluginDir: string
+  try {
+    pluginDir = confinedPluginPath(rootDir, pluginId)
+  } catch {
+    return false
+  }
+  if (pluginDir === resolve(rootDir)) return false
+  try {
+    await rm(pluginDir, { recursive: true, force: true })
+    return true
+  } catch {
+    return false
+  }
+}
diff --git a/src/main/services/workspace-files.ts b/src/main/services/workspace-files.ts
index 35a6a251..d627ed7f 100644
--- a/src/main/services/workspace-files.ts
+++ b/src/main/services/workspace-files.ts
@@ -11,6 +11,7 @@ import {
   writeFile
 } from 'node:fs/promises'
 import { randomUUID } from 'node:crypto'
+import { tmpdir } from 'node:os'
 import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
 import type {
   WorkspaceClipboardImageSavePayload,
@@ -31,7 +32,8 @@ import type {
   WorkspaceFileTarget,
   WorkspaceFileWritePayload,
   WorkspaceFileWriteResult,
-  WorkspaceImageReadResult
+  WorkspaceImageReadResult,
+  WorkspacePdfReadResult
 } from '../../shared/workspace-file'
 import {
   canonicalPath,
@@ -49,7 +51,9 @@ import {
 
 const MAX_FILE_PREVIEW_BYTES = 1_500_000
 const MAX_IMAGE_PREVIEW_BYTES = 12 * 1024 * 1024
+const MAX_PDF_PREVIEW_BYTES = 64 * 1024 * 1024
 const WORKSPACE_IMAGE_DIR = 'img'
+const CLIPBOARD_TEMP_DIR = join(tmpdir(), 'kun')
 
 const WORKSPACE_IMAGE_MIME_BY_EXT = new Map([
   ['.png', 'image/png'],
@@ -160,6 +164,41 @@ export async function readWorkspaceImage(
   }
 }
 
+export async function readWorkspacePdf(
+  payload: WorkspaceFileTarget
+): Promise {
+  try {
+    const targetPath = await resolveOpenTargetPath(payload.path, payload.workspaceRoot)
+    const fileInfo = await stat(targetPath)
+    if (fileInfo.isDirectory()) {
+      return { ok: false, message: 'Cannot preview a directory.' }
+    }
+    if (fileInfo.size > MAX_PDF_PREVIEW_BYTES) {
+      return { ok: false, message: 'This PDF is too large to preview in Write mode.' }
+    }
+
+    const ext = extensionFromName(targetPath).toLowerCase()
+    if (ext !== '.pdf') {
+      return { ok: false, message: 'This file is not a PDF document.' }
+    }
+
+    const bytes = await readFile(targetPath)
+    return {
+      ok: true,
+      path: targetPath,
+      dataBase64: bytes.toString('base64'),
+      mimeType: 'application/pdf',
+      size: fileInfo.size,
+      mtimeMs: fileInfo.mtimeMs
+    }
+  } catch (error) {
+    return {
+      ok: false,
+      message: error instanceof Error ? error.message : String(error)
+    }
+  }
+}
+
 export async function writeWorkspaceFile(
   payload: WorkspaceFileWritePayload
 ): Promise {
@@ -230,6 +269,10 @@ function buildWorkspaceImageName(now = new Date()): string {
   return `pasted-image-${iso}-${randomUUID().slice(0, 8)}.png`
 }
 
+function buildClipboardTempImagePath(now = new Date()): string {
+  return join(CLIPBOARD_TEMP_DIR, `${now.getTime()}.png`)
+}
+
 export async function readClipboardImage(): Promise {
   try {
     const image = clipboard.readImage()
@@ -242,10 +285,15 @@ export async function readClipboardImage(): Promise {
       return { ok: false, message: 'Clipboard image could not be encoded as PNG.' }
     }
 
+    const localFilePath = buildClipboardTempImagePath()
+    await mkdir(CLIPBOARD_TEMP_DIR, { recursive: true })
+    await writeFile(localFilePath, buffer)
+
     const size = image.getSize()
     return {
       ok: true,
       name: buildWorkspaceImageName(),
+      localFilePath,
       mimeType: 'image/png',
       dataBase64: buffer.toString('base64'),
       byteSize: buffer.length,
diff --git a/src/main/services/workspace-service.test.ts b/src/main/services/workspace-service.test.ts
index 4626343c..aa0b6d21 100644
--- a/src/main/services/workspace-service.test.ts
+++ b/src/main/services/workspace-service.test.ts
@@ -1,7 +1,7 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { mkdir, mkdtemp, readFile, realpath, readdir, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, realpath, readdir, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { basename, dirname, join } from 'node:path'
 
 vi.mock('electron', () => ({
   app: {
@@ -26,6 +26,7 @@ import {
   readClipboardImage,
   readWorkspaceImage,
   readWorkspaceFile,
+  readWorkspacePdf,
   renameWorkspaceEntry,
   resolveWorkspaceFile,
   saveWorkspaceClipboardImage,
@@ -45,6 +46,7 @@ describe('workspace-service boundary checks', () => {
     await mkdir(workspaceRoot, { recursive: true })
     await writeFile(join(workspaceRoot, 'inside.txt'), 'inside', 'utf8')
     await writeFile(outsideFile, 'outside', 'utf8')
+    await rm('/tmp/kun', { recursive: true, force: true })
   })
 
   it('allows files inside the selected workspace', async () => {
@@ -178,7 +180,7 @@ describe('workspace-service boundary checks', () => {
     await expect(readFile(result.path)).resolves.toEqual(Buffer.from('fake-png-bytes'))
   })
 
-  it('reads clipboard images as PNG base64 without writing workspace files', async () => {
+  it('reads clipboard images as PNG base64 and saves a temp file', async () => {
     vi.mocked(clipboard.readImage).mockReturnValue({
       isEmpty: () => false,
       toPNG: () => Buffer.from('clipboard-png-bytes'),
@@ -190,11 +192,14 @@ describe('workspace-service boundary checks', () => {
     expect(result.ok).toBe(true)
     if (!result.ok) return
     expect(result.name).toMatch(/^pasted-image-.+\.png$/)
+    expect(dirname(result.localFilePath)).toBe(join(tmpdir(), 'kun'))
+    expect(basename(result.localFilePath)).toMatch(/^\d+\.png$/)
     expect(result.mimeType).toBe('image/png')
     expect(result.dataBase64).toBe(Buffer.from('clipboard-png-bytes').toString('base64'))
     expect(result.byteSize).toBe(Buffer.byteLength('clipboard-png-bytes'))
     expect(result.width).toBe(12)
     expect(result.height).toBe(8)
+    await expect(readFile(result.localFilePath)).resolves.toEqual(Buffer.from('clipboard-png-bytes'))
   })
 
   it('saves SDD pasted clipboard images into .kunsdd/img with draft-relative markdown', async () => {
@@ -239,6 +244,39 @@ describe('workspace-service boundary checks', () => {
     expect(result.dataUrl).toBe('data:image/png;base64,iVBORw==')
   })
 
+  it('reads supported workspace PDFs as base64 metadata without exposing raw paths', async () => {
+    const pdfPath = join(workspaceRoot, 'papers', 'study.pdf')
+    const pdfBytes = Buffer.from('%PDF-1.4\n%%EOF')
+    await mkdir(join(workspaceRoot, 'papers'), { recursive: true })
+    await writeFile(pdfPath, pdfBytes)
+
+    const result = await readWorkspacePdf({
+      path: 'papers/study.pdf',
+      workspaceRoot
+    })
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+
+    expect(result.path).toBe(await realpath(pdfPath))
+    expect(result.mimeType).toBe('application/pdf')
+    expect(result.dataBase64).toBe(pdfBytes.toString('base64'))
+    expect(result.size).toBe(pdfBytes.length)
+    expect(result.mtimeMs).toBeGreaterThan(0)
+  })
+
+  it('rejects non-PDF files from the PDF reader', async () => {
+    const result = await readWorkspacePdf({
+      path: 'inside.txt',
+      workspaceRoot
+    })
+
+    expect(result.ok).toBe(false)
+    if (!result.ok) {
+      expect(result.message).toContain('not a PDF')
+    }
+  })
+
   it('renames files within the selected workspace', async () => {
     const result = await renameWorkspaceEntry({
       path: 'inside.txt',
diff --git a/src/main/services/write-export-service.ts b/src/main/services/write-export-service.ts
index 2ef748b2..f7c3ddd0 100644
--- a/src/main/services/write-export-service.ts
+++ b/src/main/services/write-export-service.ts
@@ -451,7 +451,7 @@ async function bufferFromDocxResult(result: ArrayBuffer | Blob): Promise
 }
 
 async function renderHtmlToPdf(html: string): Promise {
-  const tempDir = await mkdtemp(join(tmpdir(), 'deepseek-gui-export-'))
+  const tempDir = await mkdtemp(join(tmpdir(), 'kun-export-'))
   const tempHtmlPath = join(tempDir, 'document.html')
   await writeFile(tempHtmlPath, html, 'utf8')
 
@@ -547,7 +547,7 @@ export async function exportWriteDocument(
     } else if (payload.format === 'docx') {
       const docx = await htmlToDocx(html, null, {
         title,
-        creator: 'DeepSeek GUI',
+        creator: 'Kun',
         keywords: ['markdown', 'export'],
         description: `Exported from ${basename(sourcePath)}`,
         font: 'Arial',
diff --git a/src/main/services/write-infographic-service.test.ts b/src/main/services/write-infographic-service.test.ts
new file mode 100644
index 00000000..fc1e912a
--- /dev/null
+++ b/src/main/services/write-infographic-service.test.ts
@@ -0,0 +1,294 @@
+import { mkdtempSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import type { AppSettingsV1 } from '../../shared/app-settings'
+import type { ImageGenClient, ImageGenEditRequest, ImageGenRequest } from '../../../kun/src/adapters/tool/image-gen-tool-provider.js'
+import { buildWriteInfographicPrompt, requestWriteInfographic } from './write-infographic-service'
+
+let workspace: string
+const PNG_BYTES = Buffer.from(
+  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+  'base64'
+)
+
+function settingsWithImageGen(overrides: Record = {}): AppSettingsV1 {
+  return {
+    agents: {
+      kun: {
+        imageGeneration: {
+          enabled: true,
+          baseUrl: 'https://images.example.test/v1',
+          apiKey: 'sk-image',
+          model: 'test-image-model',
+          defaultSize: '',
+          timeoutMs: 180000,
+          ...overrides
+        }
+      }
+    }
+  } as unknown as AppSettingsV1
+}
+
+function fakeClient(): ImageGenClient & { edits: ImageGenEditRequest[]; requests: ImageGenRequest[] } {
+  const requests: ImageGenRequest[] = []
+  const edits: ImageGenEditRequest[] = []
+  return {
+    id: 'fake',
+    edits,
+    requests,
+    async generate(request) {
+      requests.push(request)
+      return { data: Buffer.from('fake-png-bytes'), mimeType: 'image/png' }
+    },
+    async edit(request) {
+      edits.push(request)
+      return { data: Buffer.from('fake-edited-png-bytes'), mimeType: 'image/png' }
+    }
+  }
+}
+
+describe('write infographic service', () => {
+  beforeEach(() => {
+    // realpath: macOS tmpdir lives behind a /var -> /private/var symlink and
+    // the service canonicalizes workspace paths the same way.
+    workspace = realpathSync(mkdtempSync(join(tmpdir(), 'write-infographic-')))
+  })
+
+  afterEach(() => {
+    rmSync(workspace, { recursive: true, force: true })
+  })
+
+  it('rejects when the image provider is not configured', async () => {
+    const result = await requestWriteInfographic(settingsWithImageGen({ apiKey: '' }), {
+      text: 'some text',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace
+    })
+    expect(result).toMatchObject({ ok: false, message: expect.stringContaining('not configured') })
+  })
+
+  it('rejects documents outside the write workspace', async () => {
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: 'some text',
+      filePath: '/tmp/elsewhere/doc.md',
+      workspaceRoot: workspace
+    }, { client: fakeClient() })
+    expect(result).toMatchObject({ ok: false, message: expect.stringContaining('inside the write workspace') })
+  })
+
+  it('saves the infographic into the workspace img folder and returns a markdown-ready path', async () => {
+    const client = fakeClient()
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: '季度营收增长 25%,主要来自海外市场。',
+      filePath: join(workspace, 'notes', 'report.md'),
+      workspaceRoot: workspace
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+    expect(result.relativePath).toMatch(/^\.\.\/img\/infographic-\d{14}-[0-9a-f]{4}\.png$/)
+    expect(result.absolutePath).toBe(join(workspace, 'img', result.fileName))
+    expect(existsSync(result.absolutePath)).toBe(true)
+    expect(readFileSync(result.absolutePath, 'utf8')).toBe('fake-png-bytes')
+
+    expect(client.requests).toHaveLength(1)
+    expect(client.requests[0].model).toBe('test-image-model')
+    expect(client.requests[0].size).toBe('768x1024')
+    expect(client.requests[0].prompt).toContain('季度营收增长 25%')
+    expect(client.requests[0].prompt).toContain('infographic')
+  })
+
+  it('links the image without ../ when the document sits at the workspace root', async () => {
+    const client = fakeClient()
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: 'root-level document',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+    expect(result.relativePath).toMatch(/^img\/infographic-\d{14}-[0-9a-f]{4}\.png$/)
+    expect(result.absolutePath).toBe(join(workspace, 'img', result.fileName))
+  })
+
+  it('prefers an explicit defaultSize over the portrait default', async () => {
+    const client = fakeClient()
+    const result = await requestWriteInfographic(settingsWithImageGen({ defaultSize: '1024x1536' }), {
+      text: 'fixed-size provider content',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    expect(client.requests[0].size).toBe('1024x1536')
+  })
+
+  it('surfaces provider failures as error results', async () => {
+    const failingClient: ImageGenClient = {
+      id: 'failing',
+      async generate() {
+        throw new Error('HTTP 400: unsupported size')
+      },
+      async edit() {
+        throw new Error('not used')
+      }
+    }
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: 'some text',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace
+    }, { client: failingClient })
+    expect(result).toMatchObject({ ok: false, message: expect.stringContaining('unsupported size') })
+  })
+
+  it('clips overlong selections in the prompt', () => {
+    const prompt = buildWriteInfographicPrompt('x'.repeat(10_000))
+    expect(prompt.length).toBeLessThan(7_000)
+  })
+
+  it('keeps MiniMax prompts inside the provider prompt limit', async () => {
+    const client = fakeClient()
+    const result = await requestWriteInfographic(settingsWithImageGen({
+      protocol: 'minimax-image',
+      model: 'image-01'
+    }), {
+      text: `核心结论:${'增长、留存、转化、复购、风险。'.repeat(300)}`,
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    expect(client.requests[0].prompt.length).toBeLessThanOrEqual(1500)
+    expect(client.requests[0].prompt).toContain('polished infographic poster')
+    expect(client.requests[0].prompt).toContain('核心结论')
+  })
+
+  it('uses a custom prompt prefix when provided', () => {
+    const prompt = buildWriteInfographicPrompt('内容', '请生成手绘风格的信息图。')
+    expect(prompt).toBe('请生成手绘风格的信息图。\n\n内容')
+  })
+
+  it('falls back to the default prefix for blank custom prompts', () => {
+    const prompt = buildWriteInfographicPrompt('content', '   ')
+    expect(prompt).toContain('infographic')
+  })
+
+  it('sends the configured write.selectionAssist.infographicPrompt to the provider', async () => {
+    const client = fakeClient()
+    const settings = {
+      ...settingsWithImageGen(),
+      write: {
+        selectionAssist: {
+          infographicPrompt: '用赛博朋克风格画一张信息图。',
+          quickActions: []
+        }
+      }
+    } as unknown as AppSettingsV1
+    const result = await requestWriteInfographic(settings, {
+      text: '季度营收增长 25%',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    expect(client.requests[0].prompt).toBe('用赛博朋克风格画一张信息图。\n\n季度营收增长 25%')
+  })
+
+  it('writes into a nested imageDir and keeps the relative link clean', async () => {
+    const client = fakeClient()
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: '需求:支持扫码登录。',
+      filePath: join(workspace, '.kunsdd', 'draft', 'dc040c2d', 'requirement.md'),
+      workspaceRoot: workspace,
+      imageDir: '.kunsdd/img',
+      kind: 'design'
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+    expect(result.relativePath).toMatch(/^\.\.\/\.\.\/img\/design-\d{14}-[0-9a-f]{4}\.png$/)
+    expect(result.absolutePath).toBe(join(workspace, '.kunsdd', 'img', result.fileName))
+    expect(existsSync(result.absolutePath)).toBe(true)
+  })
+
+  it('uses the landscape default size and design prompt for kind=design', async () => {
+    const client = fakeClient()
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: '需求内容',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace,
+      kind: 'design'
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    expect(client.requests[0].size).toBe('1024x768')
+    expect(client.requests[0].prompt).toContain('UI design mockup')
+    expect(client.requests[0].prompt).not.toContain('infographic')
+  })
+
+  it('uses selected reference images for design drafts', async () => {
+    const client = fakeClient()
+    const referencePath = join(workspace, '.kunsdd', 'requirements', 'draft-1', 'img', 'source.png')
+    mkdirSync(dirname(referencePath), { recursive: true })
+    writeFileSync(referencePath, PNG_BYTES)
+
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: '根据参考图重绘一个更精致的旅行社区首页。',
+      filePath: join(workspace, '.kunsdd', 'requirements', 'draft-1', 'requirement.md'),
+      workspaceRoot: workspace,
+      imageDir: '.kunsdd/requirements/draft-1/img',
+      kind: 'design',
+      referenceImagePath: referencePath
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    expect(client.requests).toHaveLength(0)
+    expect(client.edits).toHaveLength(1)
+    expect(client.edits[0].images[0]).toMatchObject({
+      name: 'source.png',
+      mimeType: 'image/png'
+    })
+    expect(client.edits[0].prompt).toContain('旅行社区首页')
+    if (!result.ok) return
+    expect(result.relativePath).toMatch(/^img\/design-\d{14}-[0-9a-f]{4}\.png$/)
+    expect(readFileSync(result.absolutePath, 'utf8')).toBe('fake-edited-png-bytes')
+  })
+
+  it('prefers write.selectionAssist.designDraftPrompt for kind=design', async () => {
+    const client = fakeClient()
+    const settings = {
+      ...settingsWithImageGen(),
+      write: {
+        selectionAssist: {
+          infographicPrompt: '信息图提示词不该被用到。',
+          designDraftPrompt: '画一张移动端高保真设计稿。',
+          quickActions: []
+        }
+      }
+    } as unknown as AppSettingsV1
+    const result = await requestWriteInfographic(settings, {
+      text: '扫码登录需求',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace,
+      kind: 'design'
+    }, { client })
+
+    expect(result.ok).toBe(true)
+    expect(client.requests[0].prompt).toBe('画一张移动端高保真设计稿。\n\n扫码登录需求')
+  })
+
+  it('rejects an imageDir that escapes the workspace', async () => {
+    const client = fakeClient()
+    const result = await requestWriteInfographic(settingsWithImageGen(), {
+      text: 'some text',
+      filePath: join(workspace, 'doc.md'),
+      workspaceRoot: workspace,
+      imageDir: '../outside'
+    }, { client })
+
+    expect(result.ok).toBe(false)
+    expect(existsSync(join(workspace, '..', 'outside'))).toBe(false)
+  })
+})
diff --git a/src/main/services/write-infographic-service.ts b/src/main/services/write-infographic-service.ts
new file mode 100644
index 00000000..5d0d8d72
--- /dev/null
+++ b/src/main/services/write-infographic-service.ts
@@ -0,0 +1,214 @@
+import { randomBytes } from 'node:crypto'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
+import { canonicalPath, normalizePathSeparators, resolveTargetPathWithinWorkspace } from './workspace-paths'
+import {
+  normalizeWriteSettings,
+  resolveKunImageGenerationSettings,
+  type AppSettingsV1,
+  type KunImageGenerationSettingsV1,
+  type WriteSettingsPatchV1
+} from '../../shared/app-settings'
+import {
+  WRITE_DESIGN_DRAFT_DEFAULT_PROMPT,
+  WRITE_INFOGRAPHIC_DEFAULT_PROMPT,
+  WRITE_INFOGRAPHIC_MAX_TEXT_CHARS,
+  type WriteInfographicKind,
+  type WriteInfographicRequest,
+  type WriteInfographicResult
+} from '../../shared/write-infographic'
+import {
+  mapImageSize,
+  createImageGenClient,
+  ImageGenHttpError,
+  type ImageGenClient
+} from '../../../kun/src/adapters/tool/image-gen-tool-provider.js'
+import { detectImage } from '../../../kun/src/attachments/attachment-store.js'
+
+// Matches WORKSPACE_IMAGE_DIR in workspace-files.ts so infographics land in
+// the same workspace-level folder as pasted images.
+const INFOGRAPHIC_IMAGE_DIR = 'img'
+const IMAGE_SIZE_TIER = '1K'
+const MINIMAX_PROMPT_MAX_CHARS = 1_500
+const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024
+const REFERENCE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])
+// Portrait reads best for infographics (768x1024); design mockups read best
+// in landscape (1024x768). An explicit defaultSize setting overrides both.
+const KIND_ASPECT_RATIO: Record = {
+  infographic: '3:4',
+  design: '4:3'
+}
+const KIND_FILE_PREFIX: Record = {
+  infographic: 'infographic',
+  design: 'design'
+}
+const KIND_DEFAULT_PROMPT: Record = {
+  infographic: WRITE_INFOGRAPHIC_DEFAULT_PROMPT,
+  design: WRITE_DESIGN_DRAFT_DEFAULT_PROMPT
+}
+
+export function isWriteInfographicConfigured(
+  imageGeneration: Pick
+): boolean {
+  return (
+    imageGeneration.enabled &&
+    Boolean(imageGeneration.baseUrl.trim()) &&
+    Boolean(imageGeneration.apiKey.trim()) &&
+    Boolean(imageGeneration.model.trim())
+  )
+}
+
+export function buildWriteInfographicPrompt(
+  text: string,
+  customPrompt = '',
+  kind: WriteInfographicKind = 'infographic',
+  options: { maxPromptChars?: number } = {}
+): string {
+  const clipped = text.trim().slice(0, WRITE_INFOGRAPHIC_MAX_TEXT_CHARS)
+  const prefix = customPrompt.trim() || KIND_DEFAULT_PROMPT[kind]
+  const maxPromptChars = options.maxPromptChars
+  if (typeof maxPromptChars === 'number' && Number.isFinite(maxPromptChars) && maxPromptChars > 0) {
+    return fitPromptToMaxChars(prefix, clipped, maxPromptChars)
+  }
+  return `${prefix}\n\n${clipped}`
+}
+
+function fitPromptToMaxChars(prefix: string, text: string, maxChars: number): string {
+  const separator = '\n\n'
+  const max = Math.max(1, Math.floor(maxChars))
+  const fittedPrefix = prefix.slice(0, Math.max(0, max - separator.length)).trimEnd()
+  const textBudget = Math.max(0, max - fittedPrefix.length - separator.length)
+  const fittedText = text.slice(0, textBudget).trimEnd()
+  return fittedText ? `${fittedPrefix}${separator}${fittedText}` : fittedPrefix
+}
+
+function imagePromptMaxChars(imageGeneration: KunImageGenerationSettingsV1): number | undefined {
+  return imageGeneration.protocol === 'minimax-image' ? MINIMAX_PROMPT_MAX_CHARS : undefined
+}
+
+async function readReferenceImage(
+  workspaceRoot: string,
+  rawPath: string | undefined
+): Promise<{ image?: { name: string; mimeType: string; data: Buffer }; error?: string }> {
+  const input = rawPath?.trim()
+  if (!input) return {}
+
+  const absolutePath = isAbsolute(input) ? resolve(input) : resolve(workspaceRoot, input)
+  const rel = relative(workspaceRoot, absolutePath)
+  if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
+    return { error: 'reference image must be inside the write workspace' }
+  }
+
+  let data: Buffer
+  try {
+    data = await readFile(absolutePath)
+  } catch {
+    return { error: 'reference image not found' }
+  }
+  if (data.byteLength > MAX_REFERENCE_IMAGE_BYTES) {
+    return { error: `reference image exceeds ${MAX_REFERENCE_IMAGE_BYTES} byte limit` }
+  }
+  const detected = detectImage(data)
+  if (!detected || !REFERENCE_MIME_TYPES.has(detected.mimeType)) {
+    return { error: 'reference image must be png, jpeg, or webp' }
+  }
+  return {
+    image: {
+      name: basename(absolutePath),
+      mimeType: detected.mimeType,
+      data
+    }
+  }
+}
+
+export async function requestWriteInfographic(
+  settings: AppSettingsV1,
+  request: WriteInfographicRequest,
+  options: { client?: ImageGenClient } = {}
+): Promise {
+  const imageGeneration = resolveKunImageGenerationSettings(settings)
+  if (!isWriteInfographicConfigured(imageGeneration)) {
+    return { ok: false, message: 'image generation provider is not configured' }
+  }
+
+  const text = request.text.trim()
+  if (!text) return { ok: false, message: 'selection text is empty' }
+
+  const workspaceRoot = resolve(request.workspaceRoot)
+  const filePath = resolve(request.filePath)
+  const relativeToRoot = relative(workspaceRoot, filePath)
+  if (!relativeToRoot || relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot)) {
+    return { ok: false, message: 'document must be inside the write workspace' }
+  }
+
+  const kind: WriteInfographicKind = request.kind ?? 'infographic'
+  const client = options.client ?? createImageGenClient(imageGeneration)
+  // An explicit defaultSize wins: users set it when their provider only
+  // accepts fixed sizes (e.g. gpt-image's 1024x1536). Otherwise use an
+  // aspect ratio that suits the image kind.
+  const size = imageGeneration.defaultSize.trim() ||
+    mapImageSize(KIND_ASPECT_RATIO[kind], IMAGE_SIZE_TIER, undefined)
+
+  const selectionAssist = normalizeWriteSettings(
+    (settings as { write?: WriteSettingsPatchV1 }).write
+  ).selectionAssist
+  const customPrompt = kind === 'design'
+    ? selectionAssist.designDraftPrompt
+    : selectionAssist.infographicPrompt
+  const reference = await readReferenceImage(workspaceRoot, request.referenceImagePath)
+  if (reference.error) return { ok: false, message: reference.error }
+
+  let image: { data: Buffer; mimeType: string }
+  try {
+    const generationRequest = {
+      prompt: buildWriteInfographicPrompt(text, customPrompt, kind, {
+        maxPromptChars: imagePromptMaxChars(imageGeneration)
+      }),
+      model: imageGeneration.model.trim(),
+      ...(size && size !== 'auto' ? { size } : {}),
+      timeoutMs: imageGeneration.timeoutMs,
+      signal: AbortSignal.timeout(imageGeneration.timeoutMs)
+    }
+    image = reference.image
+      ? await client.edit({ ...generationRequest, images: [reference.image] })
+      : await client.generate(generationRequest)
+  } catch (error) {
+    if (reference.image && error instanceof ImageGenHttpError && [404, 405, 501].includes(error.status)) {
+      return {
+        ok: false,
+        message: 'the configured image provider does not support reference images'
+      }
+    }
+    return { ok: false, message: error instanceof Error ? error.message : String(error) }
+  }
+
+  const ext = image.mimeType === 'image/jpeg' ? 'jpg' : image.mimeType === 'image/webp' ? 'webp' : 'png'
+  const stamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14)
+  const fileName = `${KIND_FILE_PREFIX[kind]}-${stamp}-${randomBytes(2).toString('hex')}.${ext}`
+  let absolutePath: string
+  let markdownPath: string
+  try {
+    const imageDirSetting = request.imageDir?.trim() || INFOGRAPHIC_IMAGE_DIR
+    const imageDir = await resolveTargetPathWithinWorkspace(imageDirSetting, workspaceRoot)
+    await mkdir(imageDir, { recursive: true })
+    absolutePath = join(imageDir, fileName)
+    await writeFile(absolutePath, image.data)
+    // imageDir is canonicalized (symlinks resolved), so derive the document
+    // directory from the same canonical root to keep the relative link clean.
+    // dirname(imageDir) only equals the root for single-segment dirs, so
+    // canonicalize the root itself (covers nested dirs like the per-
+    // requirement '.kunsdd/requirements//img').
+    const canonicalRoot = await canonicalPath(workspaceRoot)
+    const documentDir = join(canonicalRoot, dirname(relativeToRoot))
+    markdownPath = normalizePathSeparators(relative(documentDir, absolutePath))
+  } catch (error) {
+    return { ok: false, message: error instanceof Error ? error.message : String(error) }
+  }
+
+  return {
+    ok: true,
+    relativePath: markdownPath,
+    absolutePath,
+    fileName
+  }
+}
diff --git a/src/main/services/write-inline-completion-service.test.ts b/src/main/services/write-inline-completion-service.test.ts
index f93d7a2a..23404a7e 100644
--- a/src/main/services/write-inline-completion-service.test.ts
+++ b/src/main/services/write-inline-completion-service.test.ts
@@ -145,7 +145,7 @@ describe('requestWriteInlineCompletion', () => {
       suffix: ' a test.',
       max_tokens: 64
     })
-    expect(body.prompt).toContain('DeepSeek GUI inline completion')
+    expect(body.prompt).toContain('Kun inline completion')
     expect(body.prompt).toContain('Return only the text to insert at the cursor')
     expect(body.prompt).not.toContain('<< {
       suffix: ' a test.',
       responseChars: 0
     })
-    expect(debugEntries[0].prompt).toContain('DeepSeek GUI inline completion')
+    expect(debugEntries[0].prompt).toContain('Kun inline completion')
     expect(debugEntries[0].prompt.endsWith('# Draft\n\nThis is')).toBe(true)
   })
 
@@ -581,7 +581,7 @@ describe('requestWriteInlineCompletion', () => {
     const request = createRequest()
 
     const prompt = buildWriteInlineCompletionPrompt(request, null)
-    expect(prompt).toContain('DeepSeek GUI inline completion')
+    expect(prompt).toContain('Kun inline completion')
     expect(prompt).toContain('<<
 }
 
+type ResponsesApiResponse = {
+  output_text?: string
+  output?: Array>
+}
+
+type AnthropicMessageResponse = {
+  content?: Array>
+}
+
 type ChatCompletionMessage = {
   role: 'system' | 'user'
   content: string
 }
 
+type WriteInlineProviderResponseFormat = ModelEndpointFormat | 'fim_completions'
+
 function shouldDisableThinkingForInlineCompletion(model: string): boolean {
   const normalized = model.trim().toLowerCase()
   return normalized.startsWith('deepseek-v4') || normalized === 'deepseek-reasoner'
@@ -264,10 +279,19 @@ function buildRetrievalPromptBlock(
     `Query keywords: ${retrieval.keywords.join(', ')}`
   ]
 
+  const formatSnippetLocation = (snippet: WriteRetrievalSnippet): string => {
+    if (snippet.location.kind === 'pdf') {
+      return snippet.location.pageStart === snippet.location.pageEnd
+        ? `${snippet.path}#page=${snippet.location.pageStart}`
+        : `${snippet.path}#page=${snippet.location.pageStart}-${snippet.location.pageEnd}`
+    }
+    return snippet.location.lineStart === snippet.location.lineEnd
+      ? `${snippet.path}:${snippet.location.lineStart}`
+      : `${snippet.path}:${snippet.location.lineStart}-${snippet.location.lineEnd}`
+  }
+
   retrieval.snippets.forEach((snippet, index) => {
-    const location = snippet.lineStart === snippet.lineEnd
-      ? `${snippet.path}:${snippet.lineStart}`
-      : `${snippet.path}:${snippet.lineStart}-${snippet.lineEnd}`
+    const location = formatSnippetLocation(snippet)
     lines.push('')
     lines.push(`[${index + 1}] ${location}`)
     if (snippet.title) lines.push(`Title: ${sanitizePromptLine(snippet.title)}`)
@@ -284,7 +308,7 @@ export function buildWriteInlineCompletionPrompt(
 ): string {
   const mode = resolveMode(request)
   const lines = [
-    '