From d2364c9c926a1e5da4414833f8e192e9612e443b Mon Sep 17 00:00:00 2001 From: Pidbid Date: Tue, 4 Aug 2026 20:23:45 -0700 Subject: [PATCH 01/10] feat: port upstream platform fixes from official and community PRs Port 23 vendor-neutral upstream fixes and document all accepted and deferred PRs in UPSTREAM_PORTS.md. --- .changeset/append-pretool-hook-output.md | 5 + .changeset/edit-refuse-large-delete.md | 6 + .changeset/expand-kimi-code-home-tilde.md | 5 + .changeset/fix-ajv-coerce-types.md | 7 + .changeset/fix-late-bash-output.md | 5 + .changeset/fix-tools-disabled-v1.md | 11 ++ .changeset/fork-stay-current-session.md | 5 + .changeset/kosong-skip-empty-think.md | 6 + .../mcp-oauth-stale-redirect-registration.md | 5 + .../mcp-structured-result-passthrough.md | 5 + .changeset/plugin-managed-update-ebusy.md | 5 + .changeset/preserve-provider-fallback.md | 5 + .changeset/questions-colon-id-resolve.md | 6 + .changeset/sse-mcp-servers.md | 8 + .changeset/trim-shell-transcript-turns.md | 5 + .changeset/v1-replay-profile-bind.md | 5 + .changeset/web-assets-cache-headers.md | 5 + .changeset/web-codeblock-long-line-pre.md | 5 + .changeset/web-history-text-coalesce.md | 5 + .changeset/wsl-clipboard-image-sta.md | 5 + UPSTREAM_PORTS.md | 63 ++++++ apps/kimi-code/src/tui/commands/registry.ts | 2 +- apps/kimi-code/src/tui/commands/session.ts | 28 +-- apps/kimi-code/src/tui/kimi-tui.ts | 32 ++- apps/kimi-code/src/tui/utils/shell-output.ts | 19 ++ .../src/tui/utils/transcript-window.ts | 15 +- .../src/utils/clipboard/clipboard-image.ts | 34 +++- apps/kimi-code/src/utils/paths.ts | 12 +- .../test/tui/kimi-tui-message-flow.test.ts | 36 +++- .../test/tui/transcript-fold-reclaim.test.ts | 111 +++++++++++ .../test/tui/utils/refresh-providers.test.ts | 66 +++++++ .../test/tui/utils/shell-output.test.ts | 29 ++- .../test/tui/utils/transcript-window.test.ts | 45 +++++ .../utils/clipboard/clipboard-image.test.ts | 67 +++++++ apps/kimi-code/test/utils/paths.test.ts | 15 ++ .../kimi-web/src/components/chat/Markdown.vue | 4 + .../src/composables/messagesToTurns.ts | 40 ++-- apps/kimi-web/src/lib/markdownPerformance.ts | 21 +- apps/kimi-web/test/lib-logic.test.ts | 22 +++ apps/kimi-web/test/turn-logic.test.ts | 23 ++- apps/vis/server/src/lib/context-projector.ts | 2 + .../vis/web/src/components/wire/renderers.tsx | 31 +++ docs/en/guides/getting-started.md | 2 +- docs/en/guides/sessions.md | 2 +- docs/en/guides/use-cases.md | 2 +- docs/en/reference/slash-commands.md | 2 +- docs/zh/guides/getting-started.md | 2 +- docs/zh/guides/sessions.md | 2 +- docs/zh/guides/use-cases.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- .../agent-core-v2/src/agent/mcp/output.ts | 50 +++++ .../agent-core-v2/src/agent/mcp/tools/auth.ts | 5 +- .../src/agent/tools/edit/edit.md | 2 + .../src/agent/tools/edit/edit.ts | 6 + .../src/agent/tools/edit/editTool.ts | 1 + .../agent-core-v2/src/app/edit/editService.ts | 37 +++- .../agent-core-v2/src/app/edit/fileEdit.ts | 1 + .../src/app/edit/fileEditService.ts | 1 + .../agent-core-v2/src/tool/args-validator.ts | 67 ++++++- .../test/agent/mcp/output.test.ts | 90 +++++++++ .../test/agent/mcp/tools/auth.test.ts | 18 +- .../test/app/edit/tools/edit.test.ts | 51 +++++ .../test/tool/args-validator.test.ts | 74 +++++++ .../src/agent/background/process-task.ts | 21 +- packages/agent-core/src/agent/config/index.ts | 24 ++- .../agent-core/src/agent/permission/index.ts | 12 +- .../src/agent/permission/policies/index.ts | 8 +- .../permission/policies/pre-tool-call-hook.ts | 22 ++- .../agent-core/src/agent/records/index.ts | 28 +++ .../agent-core/src/agent/records/types.ts | 27 +++ packages/agent-core/src/agent/turn/index.ts | 15 ++ packages/agent-core/src/mcp/auth-tool.ts | 6 +- packages/agent-core/src/mcp/client-remote.ts | 32 +++ packages/agent-core/src/mcp/client-shared.ts | 12 +- packages/agent-core/src/mcp/client-sse.ts | 169 ++++++++++++++++ packages/agent-core/src/mcp/oauth/provider.ts | 23 +++ packages/agent-core/src/mcp/oauth/service.ts | 4 + packages/agent-core/src/mcp/output.ts | 72 +++++++ packages/agent-core/src/mcp/types.ts | 2 + packages/agent-core/src/plugin/manager.ts | 186 ++++++++++++------ .../agent-core/src/session/hooks/engine.ts | 6 +- .../src/session/hooks/user-prompt.ts | 15 +- packages/agent-core/src/session/index.ts | 34 +++- .../agent-core/src/session/subagent-host.ts | 16 +- .../agent-core/src/tools/args-validator.ts | 67 ++++++- .../agent-core/src/tools/builtin/file/edit.md | 2 + .../agent-core/src/tools/builtin/file/edit.ts | 52 ++++- .../test/agent/background/manager.test.ts | 18 ++ .../agent-core/test/agent/permission.test.ts | 33 ++-- .../test/agent/records/index.test.ts | 77 ++++++++ packages/agent-core/test/agent/tool.test.ts | 31 +++ .../agent-core/test/mcp/auth-tool.test.ts | 18 +- .../agent-core/test/mcp/client-sse.test.ts | 142 +++++++++++++ .../agent-core/test/mcp/config-loader.test.ts | 22 +++ .../test/mcp/connection-manager.test.ts | 60 ++++++ .../agent-core/test/mcp/oauth-store.test.ts | 44 +++++ packages/agent-core/test/mcp/output.test.ts | 90 +++++++++ .../agent-core/test/plugin/manager.test.ts | 25 ++- packages/agent-core/test/tools/edit.test.ts | 60 ++++++ .../kaos/test/e2e/process-lifecycle.test.ts | 19 ++ .../kap-server/src/protocol/events-zod.ts | 1 + packages/kap-server/src/routes/questions.ts | 40 +++- packages/kap-server/src/routes/webAssets.ts | 12 +- packages/kap-server/test/questions.test.ts | 56 +++++- packages/kosong/src/providers/kimi.ts | 10 +- packages/kosong/test/kimi.test.ts | 18 +- packages/oauth/src/refreshProviderModels.ts | 3 + packages/protocol/src/events.ts | 7 + 108 files changed, 2666 insertions(+), 225 deletions(-) create mode 100644 .changeset/append-pretool-hook-output.md create mode 100644 .changeset/edit-refuse-large-delete.md create mode 100644 .changeset/expand-kimi-code-home-tilde.md create mode 100644 .changeset/fix-ajv-coerce-types.md create mode 100644 .changeset/fix-late-bash-output.md create mode 100644 .changeset/fix-tools-disabled-v1.md create mode 100644 .changeset/fork-stay-current-session.md create mode 100644 .changeset/kosong-skip-empty-think.md create mode 100644 .changeset/mcp-oauth-stale-redirect-registration.md create mode 100644 .changeset/mcp-structured-result-passthrough.md create mode 100644 .changeset/plugin-managed-update-ebusy.md create mode 100644 .changeset/preserve-provider-fallback.md create mode 100644 .changeset/questions-colon-id-resolve.md create mode 100644 .changeset/sse-mcp-servers.md create mode 100644 .changeset/trim-shell-transcript-turns.md create mode 100644 .changeset/v1-replay-profile-bind.md create mode 100644 .changeset/web-assets-cache-headers.md create mode 100644 .changeset/web-codeblock-long-line-pre.md create mode 100644 .changeset/web-history-text-coalesce.md create mode 100644 .changeset/wsl-clipboard-image-sta.md create mode 100644 apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts diff --git a/.changeset/append-pretool-hook-output.md b/.changeset/append-pretool-hook-output.md new file mode 100644 index 0000000000..ce79ab3228 --- /dev/null +++ b/.changeset/append-pretool-hook-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix successful PreToolUse hook output not being added to the model context. diff --git a/.changeset/edit-refuse-large-delete.md b/.changeset/edit-refuse-large-delete.md new file mode 100644 index 0000000000..7b1e31a718 --- /dev/null +++ b/.changeset/edit-refuse-large-delete.md @@ -0,0 +1,6 @@ +--- +'@moonshot-ai/agent-core': patch +'@moonshot-ai/kimi-code': patch +--- + +Refuse multi-line empty Edit deletions unless `allow_large_delete` is set, and tell the model to reread a large enough region after `old_string not found`. diff --git a/.changeset/expand-kimi-code-home-tilde.md b/.changeset/expand-kimi-code-home-tilde.md new file mode 100644 index 0000000000..055205e051 --- /dev/null +++ b/.changeset/expand-kimi-code-home-tilde.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Expand `~` and `~/...` values in `KIMI_CODE_HOME` to the current user's home directory. diff --git a/.changeset/fix-ajv-coerce-types.md b/.changeset/fix-ajv-coerce-types.md new file mode 100644 index 0000000000..5d5e12d891 --- /dev/null +++ b/.changeset/fix-ajv-coerce-types.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +--- + +fix: coerce stringified tool args (numbers, booleans, JSON arrays/objects) before validation instead of rejecting them diff --git a/.changeset/fix-late-bash-output.md b/.changeset/fix-late-bash-output.md new file mode 100644 index 0000000000..8e2ced41ec --- /dev/null +++ b/.changeset/fix-late-bash-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Bash commands losing stdout when output streams close after the process exits. diff --git a/.changeset/fix-tools-disabled-v1.md b/.changeset/fix-tools-disabled-v1.md new file mode 100644 index 0000000000..47b5addcdb --- /dev/null +++ b/.changeset/fix-tools-disabled-v1.md @@ -0,0 +1,11 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +fix(agent-core): honor [tools].disabled config in v1 engine + +The `[tools].disabled` array in config.toml was silently ignored by the +v1 engine (v2 has a dedicated toolPolicy service for this). Read the +section from config.raw in bootstrapAgentProfile and merge it into the +profile's disallowedTools so disabled tools are filtered from both the +top-level tool list and the subagent Agent tool description. diff --git a/.changeset/fork-stay-current-session.md b/.changeset/fork-stay-current-session.md new file mode 100644 index 0000000000..8481368f94 --- /dev/null +++ b/.changeset/fork-stay-current-session.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +`/fork` no longer switches to the forked session: the current session stays active and its background tasks keep running. Find the fork in `/sessions`. diff --git a/.changeset/kosong-skip-empty-think.md b/.changeset/kosong-skip-empty-think.md new file mode 100644 index 0000000000..ac2f9ee916 --- /dev/null +++ b/.changeset/kosong-skip-empty-think.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Skip empty OpenAI-compatible `reasoning_content` stream values so they are not journaled as no-op think parts. diff --git a/.changeset/mcp-oauth-stale-redirect-registration.md b/.changeset/mcp-oauth-stale-redirect-registration.md new file mode 100644 index 0000000000..4438993d90 --- /dev/null +++ b/.changeset/mcp-oauth-stale-redirect-registration.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +Fixed MCP OAuth re-authorization always failing with "Invalid redirect URI": the OAuth callback listener binds a random port per flow, but the dynamic client registration recorded the first flow's port, so every later interactive authorization was rejected at the authorization endpoint. A stale registration is now dropped automatically and the flow re-registers with the current callback URI. diff --git a/.changeset/mcp-structured-result-passthrough.md b/.changeset/mcp-structured-result-passthrough.md new file mode 100644 index 0000000000..d63740fe8b --- /dev/null +++ b/.changeset/mcp-structured-result-passthrough.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +MCP tool results now surface the spec-defined `structuredContent` field and `_meta` server metadata to the model as a serialized `` block, instead of silently dropping them. Servers that return their machine-readable contract in these fields work the same as on other MCP hosts. diff --git a/.changeset/plugin-managed-update-ebusy.md b/.changeset/plugin-managed-update-ebusy.md new file mode 100644 index 0000000000..c983892b3e --- /dev/null +++ b/.changeset/plugin-managed-update-ebusy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix managed plugin updates failing with EBUSY on Windows by rename-swapping the live directory instead of deleting it in place. diff --git a/.changeset/preserve-provider-fallback.md b/.changeset/preserve-provider-fallback.md new file mode 100644 index 0000000000..31e65a7b14 --- /dev/null +++ b/.changeset/preserve-provider-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve the configured fallback provider when refreshing provider models. diff --git a/.changeset/questions-colon-id-resolve.md b/.changeset/questions-colon-id-resolve.md new file mode 100644 index 0000000000..6404aacb9f --- /dev/null +++ b/.changeset/questions-colon-id-resolve.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kap-server": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix submitting answers to interactive question prompts being rejected when the model provider returns tool call IDs containing colons (some OpenAI-compatible gateways). diff --git a/.changeset/sse-mcp-servers.md b/.changeset/sse-mcp-servers.md new file mode 100644 index 0000000000..b5dce2f66c --- /dev/null +++ b/.changeset/sse-mcp-servers.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/acp-adapter": minor +"@moonshot-ai/protocol": minor +"@moonshot-ai/kimi-code": minor +--- + +Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports. diff --git a/.changeset/trim-shell-transcript-turns.md b/.changeset/trim-shell-transcript-turns.md new file mode 100644 index 0000000000..242fd8698b --- /dev/null +++ b/.changeset/trim-shell-transcript-turns.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Bound the transcript in `!`-heavy sessions: each shell command now groups as its own trimmable turn instead of piling into an untrimmable tail turn, and a finished command's stored stdout/stderr is capped to the last 64 KB per stream. diff --git a/.changeset/v1-replay-profile-bind.md b/.changeset/v1-replay-profile-bind.md new file mode 100644 index 0000000000..2818e4f0b1 --- /dev/null +++ b/.changeset/v1-replay-profile-bind.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix v1 replay ignoring v2 `profile.bind` records, which made sessions resumed from CLI-created wires lose their tool allowlist and send requests without `tools`. diff --git a/.changeset/web-assets-cache-headers.md b/.changeset/web-assets-cache-headers.md new file mode 100644 index 0000000000..80075e74e1 --- /dev/null +++ b/.changeset/web-assets-cache-headers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Serve hashed static assets with long-lived immutable cache headers so repeat visits load faster. diff --git a/.changeset/web-codeblock-long-line-pre.md b/.changeset/web-codeblock-long-line-pre.md new file mode 100644 index 0000000000..994867f371 --- /dev/null +++ b/.changeset/web-codeblock-long-line-pre.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Keep chat code blocks readable when a plain-text fence has an ultra-long line by preferring the scrollable `
` renderer and constraining stream-diffs overflow.
diff --git a/.changeset/web-history-text-coalesce.md b/.changeset/web-history-text-coalesce.md
new file mode 100644
index 0000000000..0257d8fd5b
--- /dev/null
+++ b/.changeset/web-history-text-coalesce.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+Coalesce consecutive streamed text/thinking chunks in Kimi Web history reloads without inserting newlines.
diff --git a/.changeset/wsl-clipboard-image-sta.md b/.changeset/wsl-clipboard-image-sta.md
new file mode 100644
index 0000000000..11a82b8826
--- /dev/null
+++ b/.changeset/wsl-clipboard-image-sta.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+Fix WSL image paste by running the Windows clipboard helper in STA mode and preferring the PNG clipboard format.
diff --git a/UPSTREAM_PORTS.md b/UPSTREAM_PORTS.md
index 1ebfe78367..88ebc3dc77 100644
--- a/UPSTREAM_PORTS.md
+++ b/UPSTREAM_PORTS.md
@@ -12,6 +12,69 @@
 - KKM 对补丁进行了冲突融合,不保证 KKM 提交 SHA 与上游一致;上游 PR 与固定 head SHA 是来源依据。
 - 每次同步都必须更新本文件,并在 KKM PR 与 Release Notes 中引用对应批次。
 
+## Batch 2026-08-04 — 官方与社区通用修复
+
+KKM 分支:`agent/upstream-port-20260804`  
+目标基线:KKM `main` @ `f6fb808749e421bf1a6d767993a70889e65db863`  
+KKM PR:待创建  
+状态:移植完成,等待 KKM CI 验证与合并。
+
+本批次重新检查了上游已合并 PR 与仍开放的社区 PR。筛选标准仍是基础能力、可靠性、跨平台和协议兼容;不移植 Kimi 账号、登录、额度、托管配置、反馈、遥测身份等厂商业务。
+
+### 上游已合并(官方采纳)
+
+| Upstream PR | 固定 head SHA | 功能 | KKM 处理 |
+|---|---|---|---|
+| [#744](https://github.com/MoonshotAI/kimi-code/pull/744) | `3462a77c7785` | legacy SSE MCP:配置、ACP、插件、OAuth 与文档 | 与 KKM 已有实现对照融合,补齐缺口 |
+| [#2565](https://github.com/MoonshotAI/kimi-code/pull/2565) | `5d8ff30c6c77` | `/fork` 后保持在原会话,避免中断运行中的任务 | 按 KKM 品牌与现有会话 API 适配,并保留测试 |
+| [#2567](https://github.com/MoonshotAI/kimi-code/pull/2567) | `0dd51b89310a` | 恢复会话时回放 `profile.bind`,保留工具配置 | 完整移植 v1 兼容路径与测试 |
+| [#2585](https://github.com/MoonshotAI/kimi-code/pull/2585) | `b35eb4d54d22` | 支持包含冒号的 question/tool-call ID | 融合 KKM 的 session lifecycle 路径 |
+| [#2596](https://github.com/MoonshotAI/kimi-code/pull/2596) | `5387387a4208` | MCP `structuredContent` / `_meta` 传递给模型 | 移植当前 v1/v2 输出路径;不创建缺失的新版 `mcpCore` 树 |
+| [#2600](https://github.com/MoonshotAI/kimi-code/pull/2600) | `422c0d8e970a` | 过滤 MCP 协议保留的 `_meta` 键 | 与 #2596 成组移植并覆盖序列化测试 |
+| [#2609](https://github.com/MoonshotAI/kimi-code/pull/2609) | `a46bc7610e49` | OAuth token 更新时保留 `expiresAt` | 完整移植兼容路径与测试 |
+| [#2620](https://github.com/MoonshotAI/kimi-code/pull/2620) | `8cef67f8af8c` | OAuth 回调地址变化时废弃陈旧 client registration | 移植现有 v1 OAuth 路径;新版 `mcpCore` 部分暂缓 |
+
+### KKM 提前采用的开放社区 PR
+
+| Upstream PR | 固定 head SHA | 功能 | 风险控制 |
+|---|---|---|---|
+| [#2430](https://github.com/MoonshotAI/kimi-code/pull/2430) | `523b048e37cd` | Windows 下托管插件更新规避 `EBUSY` | rename-swap 小补丁,带回滚/测试 |
+| [#2452](https://github.com/MoonshotAI/kimi-code/pull/2452) | `6c2e94a12d0a` | Web 静态资源缓存头 | 上游 CI 已通过;仅缓存策略 |
+| [#2500](https://github.com/MoonshotAI/kimi-code/pull/2500) | `cf75a345e1d6` | 工具参数中字符串形式的 number/boolean/array 定向纠正 | 仅对类型失败字段重试,避免全局强制转换 |
+| [#2501](https://github.com/MoonshotAI/kimi-code/pull/2501) | `63677cd0db72` | provider 刷新时保留 fallback/default | 原子刷新路径与测试一并移植 |
+| [#2502](https://github.com/MoonshotAI/kimi-code/pull/2502) | `089d59a09b64` | 将成功的 PreToolUse hook stdout 追加到模型上下文 | 覆盖 turn、subagent、后台任务与 projector |
+| [#2508](https://github.com/MoonshotAI/kimi-code/pull/2508) | `1ce0e7ec0f8c` | 跳过空 reasoning 流片段 | KKM 已有更严格兼容逻辑;补齐关联路径 |
+| [#2509](https://github.com/MoonshotAI/kimi-code/pull/2509) | `09e6a339ac65` | Web 重载历史时合并连续流式文本/思考片段 | 按 KKM 已有块渲染逻辑手工融合,保留边界 |
+| [#2510](https://github.com/MoonshotAI/kimi-code/pull/2510) | `0265111eca76` | WSL 图片粘贴使用 PowerShell STA 与 PNG 格式 | 保留 KKM 的安全临时路径传递,补齐测试 |
+| [#2511](https://github.com/MoonshotAI/kimi-code/pull/2511) | `eb224a2c7143` | Edit 拒绝意外的大范围空替换删除 | v1/v2 编辑器实现与测试同步 |
+| [#2513](https://github.com/MoonshotAI/kimi-code/pull/2513) | `3314f6a731a1` | Web 超长代码行稳定渲染 | CSS/渲染性能小修复 |
+| [#2537](https://github.com/MoonshotAI/kimi-code/pull/2537) | `82958df646ef` | v1 正确遵守 `[tools].disabled` | 配置层小修复与单测 |
+| [#2541](https://github.com/MoonshotAI/kimi-code/pull/2541) | `72514bc0aef8` | Bash 退出后仍保留迟到 stdout | 生命周期与 e2e 测试同步 |
+| [#2544](https://github.com/MoonshotAI/kimi-code/pull/2544) | `eade0e38a592` | 展开 `KIMI_CODE_HOME=~/...` | 跨平台路径小修复 |
+| [#2603](https://github.com/MoonshotAI/kimi-code/pull/2603) | `1dee7cfbc9ad` | transcript fold 后回收旧 UI entry | 上游 CI 已通过;加入独立回归测试 |
+| [#2621](https://github.com/MoonshotAI/kimi-code/pull/2621) | `43446ed556f8` | 裁剪 shell-only transcript turn,并限制保存输出大小 | 上游 CI 已通过;与 KKM TUI 逻辑融合 |
+
+### 建议继续关注但本批次不合并
+
+| Upstream PR | 原因 / 后续条件 |
+|---|---|
+| [#2586](https://github.com/MoonshotAI/kimi-code/pull/2586) | MCP 非阻塞启动依赖 KKM 尚未引入的 v2 workspace/session lifecycle;需整套架构到位后移植 |
+| [#2608](https://github.com/MoonshotAI/kimi-code/pull/2608) | v2 MCP OAuth opt-in 全部位于缺失的新版 `mcpCore`;不能只移植半套 |
+| [#2573](https://github.com/MoonshotAI/kimi-code/pull/2573) | 自定义 Agent identity 涉及约 79 个文件且以 v2 为主,需单独设计迁移批次 |
+| [#2612](https://github.com/MoonshotAI/kimi-code/pull/2612) | Skill watcher 的 FD 修复依赖 #2366 的新 skill service 架构 |
+| [#2202](https://github.com/MoonshotAI/kimi-code/pull/2202) | 终端鼠标选择跨约 33 个文件,仍属实验性 UI 行为 |
+| [#2604](https://github.com/MoonshotAI/kimi-code/pull/2604) | minidb 大型重构,基础收益不足以覆盖迁移风险 |
+| [#2593](https://github.com/MoonshotAI/kimi-code/pull/2593) | engine-native image refs 仍为 draft,且依赖 v2 turn/wire |
+| [#2610](https://github.com/MoonshotAI/kimi-code/pull/2610) | session effort flag 跨 24 个文件,需先确认 KKM 对模型 effort 的统一策略 |
+| [#2578](https://github.com/MoonshotAI/kimi-code/pull/2578), [#2579](https://github.com/MoonshotAI/kimi-code/pull/2579) | Web UI 小修可用但优先级低,等待与下一次 Web 专项批次合并 |
+
+### 本批次验证
+
+- 验证提交:待 CI
+- CI:待运行
+- Nix Build:待运行
+- 合并提交:待合并
+
 ## Batch 2026-07-31 — 通用基础更新
 
 KKM 分支:`agent/upstream-port-20260731`  
diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts
index 87da0f2630..d7875ffa62 100644
--- a/apps/kimi-code/src/tui/commands/registry.ts
+++ b/apps/kimi-code/src/tui/commands/registry.ts
@@ -315,7 +315,7 @@ export const BUILTIN_SLASH_COMMANDS = [
   {
     name: 'fork',
     aliases: [],
-    description: 'Fork the current session',
+    description: 'Fork the current session into a copy without switching to it',
     priority: 80,
   },
   {
diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts
index 7badcb033b..92694b0979 100644
--- a/apps/kimi-code/src/tui/commands/session.ts
+++ b/apps/kimi-code/src/tui/commands/session.ts
@@ -5,7 +5,6 @@ import { pathToFileURL } from 'node:url';
 import type { Session } from '@moonshot-ai/kimi-code-sdk';
 
 import { detectInstallSource } from '#/cli/update/source';
-import { CLI_COMMAND_NAME } from '#/constant/app';
 import { detectShellEnvironment } from '#/utils/process/shell-env';
 import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
 import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
@@ -56,26 +55,27 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P
   }
 
   const sourceTitle = forkSourceTitle(host, session);
-  let forked: Session;
   try {
-    forked = await host.harness.forkSession({
+    const forked = await host.harness.forkSession({
       id: session.id,
       title: `Fork: ${sourceTitle}`,
     });
-  } catch (error) {
-    const msg = formatErrorMessage(error);
-    host.showError(`Failed to fork session: ${msg}`);
-    return;
-  }
-
-  try {
-    await host.switchToSession(
-      forked,
-      `Session forked (${forked.id}). To return to the original session: ${CLI_COMMAND_NAME} -r ${session.id}`,
+    const forkId = forked.id;
+    try {
+      await forked.close();
+    } catch (error) {
+      const msg = formatErrorMessage(error);
+      host.showError(`Session forked (${forkId}), but failed to release its runtime: ${msg}`);
+      return;
+    }
+    // Stay in the source session: switching to the fork would close the source,
+    // killing its in-flight turn and background tasks.
+    host.showStatus(
+      `Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.`,
     );
   } catch (error) {
     const msg = formatErrorMessage(error);
-    host.showError(`Failed to switch to forked session: ${msg}`);
+    host.showError(`Failed to fork session: ${msg}`);
   }
 }
 
diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts
index 55a0854926..bb7b139266 100644
--- a/apps/kimi-code/src/tui/kimi-tui.ts
+++ b/apps/kimi-code/src/tui/kimi-tui.ts
@@ -1167,10 +1167,18 @@ export class KimiTUI {
       // the UI and the model notification, so there is nothing to render here.
       return;
     }
-    stream.component.finish(stdout, stderr, isError);
+    stream.component.finish(
+      capStoredShellOutput(stdout),
+      capStoredShellOutput(stderr),
+      isError,
+    );
     // Keep the transcript entry's metadata in sync for anything that reads it
     // (export / copy). The component renders itself.
-    stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError);
+    stream.entry.content = formatBashOutputForDisplay(
+      capStoredShellOutput(stdout),
+      capStoredShellOutput(stderr),
+      isError,
+    );
     this.shellOutputStreams.delete(commandId);
     // When the last shell command finishes, leave the shell streaming phase,
     // release one queued message (if any), and refresh the activity pane.
@@ -2324,15 +2332,30 @@ export class KimiTUI {
       newChildren.push(children[i]!);
     }
 
-    for (const idx of toMergeIndices) {
-      const child = children[idx]!;
+    const mergedChildren = toMergeIndices.map((idx) => children[idx]!);
+    for (const child of mergedChildren) {
       if (hasDispose(child)) child.dispose();
     }
+    // The merged components are gone; their transcript entries have to go
+    // too, or the entry list keeps growing underneath the folded tree.
+    this.dropTranscriptEntriesOf(mergedChildren);
 
     children.splice(0, children.length, ...newChildren);
     return true;
   }
 
+  private dropTranscriptEntriesOf(components: readonly Component[]): void {
+    const dropped = new Set();
+    for (const component of components) {
+      const entry = getTranscriptComponentEntry(component);
+      if (entry !== undefined) dropped.add(entry);
+    }
+    if (dropped.size === 0) return;
+    this.state.transcriptEntries = this.state.transcriptEntries.filter(
+      (entry) => !dropped.has(entry),
+    );
+  }
+
   mergeAllTurnSteps(): void {
     if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0 && TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED <= 0)
       return;
@@ -2409,6 +2432,7 @@ export class KimiTUI {
     for (const child of toDispose) {
       if (hasDispose(child)) child.dispose();
     }
+    this.dropTranscriptEntriesOf(toDispose);
     children.splice(0, children.length, ...newChildren);
   }
 
diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts
index 3a482feb73..3a8487d07a 100644
--- a/apps/kimi-code/src/tui/utils/shell-output.ts
+++ b/apps/kimi-code/src/tui/utils/shell-output.ts
@@ -69,3 +69,22 @@ export function formatBashOutputForDisplay(stdout: string, stderr: string, isErr
     return plain.length > 0 ? plain : '(no output)';
   }
 }
+
+/** Cap on each stored stream once a command finishes; the tail is what matters. */
+export const MAX_STORED_STREAM_CHARS = 64 * 1024;
+
+/**
+ * Bound a finished command's stored output. The running buffer is already
+ * capped mid-stream (see ShellRunComponent), but the final copies used to be
+ * stored whole: one inside the component, one formatted into the transcript
+ * entry, so a `! cat bigfile` habit grew the session without bound. Keep the
+ * tail and say on the first line how much was dropped.
+ */
+export function capStoredShellOutput(text: string, maxChars = MAX_STORED_STREAM_CHARS): string {
+  if (text.length <= maxChars) return text;
+  let tail = text.slice(-maxChars);
+  // Do not start the kept text on the low half of a surrogate pair.
+  const first = tail.codePointAt(0);
+  if (first !== undefined && first >= 0xdc00 && first <= 0xdfff) tail = tail.slice(1);
+  return `… (${text.length - tail.length} earlier chars truncated)\n${tail}`;
+}
diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts
index 7f53fe6568..aab66d2c3e 100644
--- a/apps/kimi-code/src/tui/utils/transcript-window.ts
+++ b/apps/kimi-code/src/tui/utils/transcript-window.ts
@@ -69,8 +69,11 @@ export interface TranscriptTurn {
  * defined turn. This matters because a user message is appended (with
  * `turnId: undefined`) before its turn actually starts, so without this
  * buffering every user message would become its own single-entry turn at the
- * front and get trimmed first. Any undefined entries left at the tail (no
- * following turn) become their own turn.
+ * front and get trimmed first. A buffered run is flushed into its own turn
+ * when the next user entry arrives: `!` shell echoes are user entries too,
+ * but no defined turn ever follows them, so each one starts a fresh group.
+ * Any undefined entries left at the tail (no following turn) become their
+ * own turn.
  */
 export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] {
   const turns: TranscriptTurn[] = [];
@@ -80,6 +83,14 @@ export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[
   for (const entry of entries) {
     const turnId = entry.turnId;
     if (turnId === undefined) {
+      // A `!` shell echo is a user entry with no turnId, and no defined turn
+      // ever follows it. Flush the buffered entries at each one so a
+      // `!`-heavy stretch becomes many small trimmable turns instead of a
+      // single tail turn that turnsToTrim can never touch.
+      if (entry.kind === 'user' && pendingUndefined.length > 0) {
+        turns.push({ turnId: undefined, entries: pendingUndefined });
+        pendingUndefined = [];
+      }
       pendingUndefined.push(entry);
       continue;
     }
diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts
index b5428164f4..051251e1d8 100644
--- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts
+++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts
@@ -312,6 +312,9 @@ function readClipboardImageViaXclip(run: RunCommand): ClipboardImage | null {
  * Linux clipboard. PowerShell reaches the Windows clipboard directly;
  * we round-trip via a temp PNG because binary stdout is unreliable
  * across the WSL interop boundary.
+ *
+ * WinForms Clipboard APIs require a single-threaded apartment. Prefer the
+ * PNG clipboard format when present and fall back to GetImage().
  */
 function readClipboardImageViaPowerShell(run: RunCommand): ClipboardImage | null {
   const tmpFile = join(tmpdir(), `kimi-wsl-clip-${randomUUID()}.png`);
@@ -330,13 +333,34 @@ function readClipboardImageViaPowerShell(run: RunCommand): ClipboardImage | null
       'Add-Type -AssemblyName System.Windows.Forms',
       'Add-Type -AssemblyName System.Drawing',
       `$path = '${winPath.replaceAll("'", "''")}'`,
-      '$img = [System.Windows.Forms.Clipboard]::GetImage()',
-      "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
+      '$ok = $false',
+      '$obj = [System.Windows.Forms.Clipboard]::GetDataObject()',
+      "if ($obj -and $obj.GetDataPresent('PNG')) {",
+      "  $stream = $obj.GetData('PNG')",
+      '  if ($stream -is [System.IO.MemoryStream]) {',
+      "    [System.IO.File]::WriteAllBytes($path, $stream.ToArray()); $ok = $true",
+      '  }',
+      '}',
+      'if (-not $ok) {',
+      '  $img = [System.Windows.Forms.Clipboard]::GetImage()',
+      '  if ($img) {',
+      '    $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)',
+      '    $ok = $true',
+      '  }',
+      '}',
+      "if ($ok) { Write-Output 'ok' } else { Write-Output 'empty' }",
     ].join('; ');
 
-    const result = run('powershell.exe', ['-NoProfile', '-Command', psScript], {
-      timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
-    });
+    const result = run(
+      'powershell.exe',
+      ['-STA', '-NoProfile', '-NonInteractive', '-Command', psScript],
+      {
+        timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
+        // Kept for compatibility with callers/tests; the script also embeds
+        // the path because WSL only forwards opted-in variables to Win32.
+        env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath },
+      },
+    );
     if (!result.ok) return null;
     if (result.stdout.toString('utf-8').trim() !== 'ok') return null;
 
diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts
index 2127726ccd..7a214e31a0 100644
--- a/apps/kimi-code/src/utils/paths.ts
+++ b/apps/kimi-code/src/utils/paths.ts
@@ -26,6 +26,16 @@ import {
   KIMI_CODE_UPDATE_STATE_FILE_NAME,
 } from '#/constant/app';
 
+function expandHomeDir(path: string): string {
+  if (path === '~') {
+    return homedir();
+  }
+  if (path.startsWith('~/') || path.startsWith('~\\')) {
+    return join(homedir(), path.slice(2));
+  }
+  return path;
+}
+
 /**
  * Return the root data directory for Kimi Code.
  *
@@ -34,7 +44,7 @@ import {
 export function getDataDir(): string {
   const envDir = process.env[KIMI_CODE_HOME_ENV];
   if (envDir) {
-    return envDir;
+    return expandHomeDir(envDir);
   }
   return join(homedir(), KIMI_CODE_DATA_DIR_NAME);
 }
diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
index b6c86004a6..de2e8844b7 100644
--- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
+++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
@@ -5289,7 +5289,7 @@ command = "vim"
     }
   });
 
-  it('forks the active session and switches to the returned session', async () => {
+  it('forks the active session and stays in the source session', async () => {
     const originalTitle = process.title;
     const source = makeSession({
       id: 'ses-source',
@@ -5312,16 +5312,17 @@ command = "vim"
           id: 'ses-source',
           title: 'Fork: Source title',
         });
-        expect(driver.getCurrentSessionId()).toBe('ses-fork');
+        expect(driver.state.transcriptContainer.render(120).join('\n')).toContain(
+          'Session forked (ses-fork). Still in the original session; switch to the fork via /sessions.',
+        );
       });
-      expect(setTitle).toHaveBeenCalledWith('Fork: Source title');
+      expect(driver.getCurrentSessionId()).toBe('ses-source');
+      expect(source.close).not.toHaveBeenCalled();
+      expect(forked.close).toHaveBeenCalledOnce();
+      expect(forked.onEvent).not.toHaveBeenCalled();
+      expect(setTitle).not.toHaveBeenCalled();
       expect(process.title).toBe('kimi-test-runner');
-      expect(source.close).toHaveBeenCalledOnce();
-      expect(forked.onEvent).toHaveBeenCalledOnce();
       expect(harness.resumeSession).not.toHaveBeenCalled();
-      expect(driver.state.transcriptContainer.render(120).join('\n')).toContain(
-        'Session forked (ses-fork). To return to the original session: kkm -r ses-source',
-      );
     } finally {
       process.title = originalTitle;
     }
@@ -5347,6 +5348,25 @@ command = "vim"
     });
   });
 
+  it('reports when the forked runtime cannot be released', async () => {
+    const source = makeSession({ id: 'ses-source' });
+    const forked = makeSession({ id: 'ses-fork' });
+    forked.close.mockRejectedValueOnce(new Error('close unavailable'));
+    const forkSession = vi.fn(async () => forked);
+    const { driver } = await makeDriver(source, { forkSession });
+
+    driver.handleUserInput('/fork');
+
+    await vi.waitFor(() => {
+      expect(forked.close).toHaveBeenCalledOnce();
+      expect(driver.getCurrentSessionId()).toBe('ses-source');
+      expect(driver.state.transcriptContainer.render(120).join('\n')).toContain(
+        'Session forked (ses-fork), but failed to release its runtime: close unavailable',
+      );
+    });
+    expect(source.close).not.toHaveBeenCalled();
+  });
+
   it('does not create a thinking component for empty thinking deltas', async () => {
     const { driver } = await makeDriver();
     driver.state.appState.streamingPhase = 'thinking';
diff --git a/apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts b/apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts
new file mode 100644
index 0000000000..ea56e455ba
--- /dev/null
+++ b/apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { KimiTUI, type KimiTUIStartupInput } from '#/tui/kimi-tui';
+import { StepSummaryComponent } from '#/tui/components/messages/step-summary';
+
+function makeHarness() {
+  return {
+    getConfig: vi.fn(async () => ({
+      models: {
+        k2: { model: 'moonshot-v1', maxContextSize: 100 },
+      },
+    })),
+    createSession: vi.fn(async () => ({ id: 'ses-1', model: 'k2' })),
+    resumeSession: vi.fn(async () => ({ id: 'ses-1', model: 'k2' })),
+    listSessions: vi.fn(async () => []),
+    close: vi.fn(async () => {}),
+    track: vi.fn(),
+    setTelemetryContext: vi.fn(),
+    getExperimentalFeatures: vi.fn(async () => []),
+    supportsAtomicSectionReplace: vi.fn(() => false),
+    auth: {
+      status: vi.fn(async () => ({ providers: [] })),
+      login: vi.fn(async () => {}),
+      logout: vi.fn(),
+      getManagedUsage: vi.fn(),
+    },
+  };
+}
+
+function makeStartupInput(): KimiTUIStartupInput {
+  return {
+    cliOptions: {
+      session: undefined,
+      continue: false,
+      yolo: false,
+      auto: false,
+      plan: false,
+      model: undefined,
+      outputFormat: undefined,
+      prompt: undefined,
+      skillsDirs: [],
+      agent: undefined,
+      agentFiles: [],
+    },
+    tuiConfig: {
+      theme: 'dark',
+      disablePasteBurst: false,
+      editorCommand: null,
+      notifications: { enabled: true, condition: 'unfocused' },
+      upgrade: { autoInstall: true },
+      statusLine: { items: null, command: null },
+    },
+    version: '0.0.0-test',
+    workDir: '/tmp/proj-a',
+  };
+}
+
+function makeDriver() {
+  const driver = new KimiTUI(makeHarness() as never, makeStartupInput());
+  vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {});
+  vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {});
+  return driver;
+}
+
+describe('transcript fold entry reclaim', () => {
+  it('drops the folded assistant entries when a completed turn folds', () => {
+    const driver = makeDriver();
+    driver.appendTranscriptEntry({ id: 'u1', kind: 'user', renderMode: 'plain', content: 'hello' });
+    for (const id of ['a0', 'a1', 'a2', 'a3']) {
+      driver.appendTranscriptEntry({
+        id,
+        kind: 'assistant',
+        turnId: 't1',
+        renderMode: 'markdown',
+        content: `message ${id}`,
+        modelText: true,
+      });
+    }
+    expect(driver.state.transcriptEntries).toHaveLength(5);
+
+    const folded = driver.mergeCompletedTurnAssistants();
+
+    expect(folded).toBe(true);
+    // the two oldest assistants merged into the summary; the tail stays
+    expect(driver.state.transcriptEntries.map((entry) => entry.id)).toEqual(['u1', 'a2', 'a3']);
+    const summaryCount = driver.state.transcriptContainer.children.filter(
+      (child) => child instanceof StepSummaryComponent,
+    ).length;
+    expect(summaryCount).toBe(1);
+  });
+
+  it('keeps every entry when nothing exceeds the fold caps', () => {
+    const driver = makeDriver();
+    driver.appendTranscriptEntry({ id: 'u1', kind: 'user', renderMode: 'plain', content: 'hello' });
+    for (const id of ['a0', 'a1']) {
+      driver.appendTranscriptEntry({
+        id,
+        kind: 'assistant',
+        turnId: 't1',
+        renderMode: 'markdown',
+        content: `message ${id}`,
+        modelText: true,
+      });
+    }
+
+    const folded = driver.mergeCompletedTurnAssistants();
+
+    expect(folded).toBe(false);
+    expect(driver.state.transcriptEntries.map((entry) => entry.id)).toEqual(['u1', 'a0', 'a1']);
+  });
+});
diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts
index 18d43fec7a..d06e04c6bb 100644
--- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts
+++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts
@@ -1,3 +1,9 @@
+/**
+ * Scenario: refresh configured provider models through the TUI refresh utility.
+ * Responsibilities: persist refreshed records without losing defaults or user aliases.
+ * Wiring: an in-memory persistence host with fetch and OAuth token resolution stubbed.
+ * Run: pnpm exec vitest run apps/kimi-code/test/tui/utils/refresh-providers.test.ts
+ */
 import {
   KIMI_CODE_PROVIDER_NAME,
   resolveKimiCodeOAuthKey,
@@ -37,6 +43,9 @@ function makeRefreshHost(initial: KimiConfig): {
     }
     persisted = { ...persisted, providers, models };
     if (defaultRemoved) persisted = { ...persisted, defaultModel: undefined };
+    if (persisted.defaultProvider === providerId) {
+      persisted = { ...persisted, defaultProvider: undefined };
+    }
     return structuredClone(persisted);
   });
   const setConfig = vi.fn(async (patch: Partial) => {
@@ -126,6 +135,63 @@ describe('refreshAllProviderModels', () => {
     expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef);
   });
 
+  it('preserves defaultProvider when refreshing the managed OAuth provider', async () => {
+    const baseUrl = 'https://api.example.test/coding/v1';
+    const host = makeRefreshHost({
+      providers: {
+        [KIMI_CODE_PROVIDER_NAME]: {
+          type: 'kimi',
+          baseUrl,
+          apiKey: '',
+          oauth: {
+            storage: 'file',
+            key: resolveKimiCodeOAuthKey({ baseUrl }),
+          },
+        },
+      },
+      models: {
+        'kimi-code/kimi-for-coding': {
+          provider: KIMI_CODE_PROVIDER_NAME,
+          model: 'kimi-for-coding',
+          maxContextSize: 262144,
+          capabilities: ['tool_use'],
+          displayName: 'Old Kimi',
+        },
+      },
+      defaultProvider: KIMI_CODE_PROVIDER_NAME,
+      telemetry: true,
+    } as unknown as KimiConfig);
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () =>
+        new Response(
+          JSON.stringify({
+            data: [
+              {
+                id: 'kimi-for-coding',
+                context_length: 262144,
+                supports_reasoning: false,
+                display_name: 'Fresh Kimi',
+              },
+            ],
+          }),
+          { status: 200, headers: { 'Content-Type': 'application/json' } },
+        ),
+      ),
+    );
+
+    const result = await refreshAllProviderModels({
+      getConfig: async () => host.current(),
+      removeProvider: host.removeProvider,
+      setConfig: host.setConfig,
+      resolveOAuthToken: vi.fn(async () => 'access-token'),
+    });
+
+    expect(result.failed).toEqual([]);
+    expect(result.changed).toHaveLength(1);
+    expect(host.current().defaultProvider).toBe(KIMI_CODE_PROVIDER_NAME);
+  });
+
   it('can refresh only the managed OAuth provider without fetching third-party registries', async () => {
     const baseUrl = 'https://api.example.test/coding/v1';
     const registryUrl = 'https://registry.example.test/v1/models/api.json';
diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts
index e7a724b43a..1b2400c1e6 100644
--- a/apps/kimi-code/test/tui/utils/shell-output.test.ts
+++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest';
 
-import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output';
+import { capStoredShellOutput, formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output';
 
 const ESC = '\u001B';
 const BEL = '\u0007';
@@ -106,3 +106,30 @@ describe('formatBashOutputForDisplay', () => {
     ).not.toThrow();
   });
 });
+
+describe('capStoredShellOutput', () => {
+  it('keeps short output untouched', () => {
+    expect(capStoredShellOutput('hello\nworld')).toBe('hello\nworld');
+  });
+
+  it('keeps exactly maxChars untouched', () => {
+    const exact = 'x'.repeat(100);
+    expect(capStoredShellOutput(exact, 100)).toBe(exact);
+  });
+
+  it('keeps the tail and reports the dropped count', () => {
+    const long = `${'a'.repeat(80)}${'b'.repeat(80)}`;
+    const capped = capStoredShellOutput(long, 80);
+    expect(capped).toMatch(/^… \(80 earlier chars truncated\)\n/);
+    expect(capped.endsWith('b'.repeat(80))).toBe(true);
+  });
+
+  it('never starts the tail on a lone low surrogate', () => {
+    // 100 BMP chars, an astral char (surrogate pair), then 100 more; a 101-char
+    // cut lands on the low half of the pair, which must be dropped with it.
+    const text = `${'x'.repeat(100)}${'\u{1F600}'}${'y'.repeat(100)}`;
+    const capped = capStoredShellOutput(text, 101);
+    expect(capped).toMatch(/^… \(102 earlier chars truncated\)\n/);
+    expect(capped.split('\n')[1]).toBe('y'.repeat(100));
+  });
+});
diff --git a/apps/kimi-code/test/tui/utils/transcript-window.test.ts b/apps/kimi-code/test/tui/utils/transcript-window.test.ts
index 4fbc23fec6..cc1061d223 100644
--- a/apps/kimi-code/test/tui/utils/transcript-window.test.ts
+++ b/apps/kimi-code/test/tui/utils/transcript-window.test.ts
@@ -47,6 +47,35 @@ describe('groupTurns', () => {
     expect(turns[1]!.turnId).toBeUndefined();
     expect(turns[1]!.entries).toHaveLength(1);
   });
+
+  it('splits a `!` shell stretch into one turn per command', () => {
+    const echo = () => makeEntry(undefined, 'user');
+    const out = () => makeEntry(undefined, 'status');
+    const turns = groupTurns([echo(), out(), echo(), out(), echo(), out()]);
+    expect(turns).toHaveLength(3);
+    for (const turn of turns) {
+      expect(turn.turnId).toBeUndefined();
+      expect(turn.entries.map((e) => e.kind)).toEqual(['user', 'status']);
+    }
+  });
+
+  it('still attaches a real prompt to the following defined turn', () => {
+    const prompt = makeEntry(undefined, 'user');
+    const turns = groupTurns([prompt, tool('7'), msg('7')]);
+    expect(turns).toHaveLength(1);
+    expect(turns[0]!.turnId).toBe('7');
+    expect(turns[0]!.entries[0]).toBe(prompt);
+  });
+
+  it('flushes a buffered `!` run when the next prompt arrives', () => {
+    const echo = makeEntry(undefined, 'user');
+    const out = makeEntry(undefined, 'status');
+    const prompt = makeEntry(undefined, 'user');
+    const turns = groupTurns([echo, out, prompt, msg('3')]);
+    expect(turns.map((t) => t.turnId)).toEqual([undefined, '3']);
+    expect(turns[0]!.entries).toEqual([echo, out]);
+    expect(turns[1]!.entries).toEqual([prompt, turns[1]!.entries[1]!]);
+  });
 });
 
 describe('turnsToTrim', () => {
@@ -77,6 +106,22 @@ describe('turnsToTrim', () => {
     const removed = turnsToTrim(turns, 2, 0);
     expect(removed.size).toBe(0);
   });
+
+  it('trims old `!` command turns in a shell-heavy session', () => {
+    const entries: TranscriptEntry[] = [];
+    for (let i = 0; i < 10; i++) {
+      entries.push(makeEntry(undefined, 'user'), makeEntry(undefined, 'status'));
+    }
+    const turns = groupTurns(entries); // 10 small turns, one per command
+    expect(turns).toHaveLength(10);
+    const removed = turnsToTrim(turns, 3, 0);
+    // 10 > 3, oldest 7 commands trimmed; the newest 3 stay.
+    expect(removed.size).toBe(14);
+    expect(removed.has(entries[0]!)).toBe(true);
+    expect(removed.has(entries[13]!)).toBe(true);
+    expect(removed.has(entries[14]!)).toBe(false);
+    expect(removed.has(entries[19]!)).toBe(false);
+  });
 });
 
 describe('readEnvInt', () => {
diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts
index 9b12294311..701ecb71a4 100644
--- a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts
+++ b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts
@@ -135,6 +135,73 @@ describe('readClipboardMedia', () => {
     expect(getImageBinary).not.toHaveBeenCalled();
   });
 
+  it('reads a WSL clipboard image through powershell.exe -STA (WinForms requires STA)', async () => {
+    const imageBytes = png(8, 8);
+    let linuxTmpPath = '';
+    const runCommand = vi.fn((command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => {
+      if (command === 'wslpath') {
+        linuxTmpPath = args[1] ?? '';
+        return { ok: true, stdout: Buffer.from('C:\\Users\\test\\AppData\\Local\\Temp\\kimi.png') };
+      }
+      if (command === 'powershell.exe') {
+        expect(args).toContain('-STA');
+        expect(args).toContain('-NoProfile');
+        expect(args).toContain('-NonInteractive');
+        expect(args).toContain('-Command');
+        expect(options?.env?.['KIMI_WSL_CLIPBOARD_IMAGE_PATH']).toBe(
+          'C:\\Users\\test\\AppData\\Local\\Temp\\kimi.png',
+        );
+        // PowerShell writes via the Windows path; under WSL that is the same inode
+        // as the Linux temp file created before wslpath.
+        writeFileSync(linuxTmpPath, imageBytes);
+        return { ok: true, stdout: Buffer.from('ok\n') };
+      }
+      // wl-paste / xclip miss on WSL so the PowerShell fallback runs.
+      return { ok: false, stdout: Buffer.alloc(0) };
+    });
+
+    const media = await readClipboardMedia({
+      platform: 'linux',
+      env: { WSL_DISTRO_NAME: 'Ubuntu' },
+      clipboard: fakeClipboard({}),
+      runCommand,
+    });
+
+    expect(media).toEqual({
+      kind: 'image',
+      bytes: imageBytes,
+      mimeType: 'image/png',
+    });
+    expect(runCommand).toHaveBeenCalledWith(
+      'powershell.exe',
+      expect.arrayContaining(['-STA', '-NoProfile', '-NonInteractive', '-Command']),
+      expect.objectContaining({
+        env: expect.objectContaining({ KIMI_WSL_CLIPBOARD_IMAGE_PATH: expect.any(String) }),
+      }),
+    );
+  });
+
+  it('returns null when the WSL PowerShell clipboard fallback reports empty', async () => {
+    const runCommand = vi.fn((command: string) => {
+      if (command === 'wslpath') {
+        return { ok: true, stdout: Buffer.from('C:\\Temp\\kimi.png') };
+      }
+      if (command === 'powershell.exe') {
+        return { ok: true, stdout: Buffer.from('empty\n') };
+      }
+      return { ok: false, stdout: Buffer.alloc(0) };
+    });
+
+    const media = await readClipboardMedia({
+      platform: 'linux',
+      env: { WSL_DISTRO_NAME: 'Ubuntu' },
+      clipboard: fakeClipboard({}),
+      runCommand,
+    });
+
+    expect(media).toBeNull();
+  });
+
   it('rejects pasted videos larger than 100 MB', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'kimi-code-clip-'));
     try {
diff --git a/apps/kimi-code/test/utils/paths.test.ts b/apps/kimi-code/test/utils/paths.test.ts
index 3ebeb37116..d5db46754c 100644
--- a/apps/kimi-code/test/utils/paths.test.ts
+++ b/apps/kimi-code/test/utils/paths.test.ts
@@ -33,6 +33,21 @@ describe('getDataDir', () => {
     expect(getDataDir()).toBe('/tmp/kimi-test-data');
   });
 
+  it('expands a standalone tilde to the home directory', () => {
+    process.env['KIMI_CODE_HOME'] = '~';
+    expect(getDataDir()).toBe(homedir());
+  });
+
+  it('expands a tilde-prefixed path to the home directory', () => {
+    process.env['KIMI_CODE_HOME'] = '~/.local/share/kimi-code';
+    expect(getDataDir()).toBe(join(homedir(), '.local', 'share', 'kimi-code'));
+  });
+
+  it('does not expand named-user tilde paths', () => {
+    process.env['KIMI_CODE_HOME'] = '~other/kimi-code';
+    expect(getDataDir()).toBe('~other/kimi-code');
+  });
+
   it('returns KIMI_CODE_HOME even if it is a relative path', () => {
     process.env['KIMI_CODE_HOME'] = 'relative/path';
     expect(getDataDir()).toBe('relative/path');
diff --git a/apps/kimi-web/src/components/chat/Markdown.vue b/apps/kimi-web/src/components/chat/Markdown.vue
index 136d255278..bf759d7a77 100644
--- a/apps/kimi-web/src/components/chat/Markdown.vue
+++ b/apps/kimi-web/src/components/chat/Markdown.vue
@@ -679,6 +679,10 @@ function copyDiff(code: string, idx: number) {
 .md :deep(.code-editor-container) {
   line-height: 1.65;
   --diffs-gap-block: var(--space-3);
+  /* Long lines must scroll inside the block; otherwise stream-diffs can overflow
+     the chat column and force a broken plain fallback path (#2495). */
+  max-width: 100%;
+  overflow-x: auto;
 }
 .md :deep(.code-editor-container diffs-container) {
   --diffs-line-height: 1.65em;
diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts
index 0ddc025141..48332342c6 100644
--- a/apps/kimi-web/src/composables/messagesToTurns.ts
+++ b/apps/kimi-web/src/composables/messagesToTurns.ts
@@ -635,29 +635,41 @@ export function messagesToTurns(
   }
 
   function absorbContent(g: Group, content: AppMessage['content']): void {
+    // Coalesce only within this message's consecutive same-kind parts — matching
+    // the live stream path (`text += delta`). A new message or a tool/thinking
+    // boundary still opens a fresh segment.
+    let coalesceText = false;
+    let coalesceThinking = false;
     for (const c of content) {
       if (c.type === 'text') {
         if (c.text) {
-          g.textParts.push(c.text);
-          // Append to a trailing text block, else open a new one — so a tool
-          // call between two text segments splits them into separate blocks.
-          // Stream chunks already contain the model's whitespace (including
-          // paragraph breaks). Inserting a newline here turns token-sized
-          // chunks into one line per word, especially for Chinese output.
           const last = g.blocks.at(-1);
-          if (last && last.kind === 'text') last.text += c.text;
-          else g.blocks.push({ kind: 'text', text: c.text });
+          if (coalesceText && last?.kind === 'text') {
+            last.text += c.text;
+            g.textParts[g.textParts.length - 1] += c.text;
+          } else {
+            g.textParts.push(c.text);
+            g.blocks.push({ kind: 'text', text: c.text });
+          }
+          coalesceText = true;
+          coalesceThinking = false;
         }
       } else if (c.type === 'thinking') {
         if (c.thinking) {
-          g.thinkingParts.push(c.thinking);
-          // Ordered block too: thinking renders WHERE it happened in the turn,
-          // merging consecutive segments (same rule as text blocks above).
           const last = g.blocks.at(-1);
-          if (last && last.kind === 'thinking') last.thinking += c.thinking;
-          else g.blocks.push({ kind: 'thinking', thinking: c.thinking });
+          if (coalesceThinking && last?.kind === 'thinking') {
+            last.thinking += c.thinking;
+            g.thinkingParts[g.thinkingParts.length - 1] += c.thinking;
+          } else {
+            g.thinkingParts.push(c.thinking);
+            g.blocks.push({ kind: 'thinking', thinking: c.thinking });
+          }
+          coalesceThinking = true;
+          coalesceText = false;
         }
       } else if (c.type === 'toolUse') {
+        coalesceText = false;
+        coalesceThinking = false;
         // Single `Agent` subagent spawns and all other tools render as a normal
         // tool card: the card shows the fixed args (prompt / description) plus
         // the final result when expanded, while a subagent's live progress
@@ -680,6 +692,8 @@ export function messagesToTurns(
           g.approvalId = pendingApproval.approvalId;
         }
       } else if (c.type === 'toolResult') {
+        coalesceText = false;
+        coalesceThinking = false;
         // Update the matching tool call status within this group (both the flat
         // tools[] and the ordered block that renders it).
         const idx = g.tools.findIndex((t) => t.id === c.toolCallId);
diff --git a/apps/kimi-web/src/lib/markdownPerformance.ts b/apps/kimi-web/src/lib/markdownPerformance.ts
index 4234453c59..8364e1da69 100644
--- a/apps/kimi-web/src/lib/markdownPerformance.ts
+++ b/apps/kimi-web/src/lib/markdownPerformance.ts
@@ -10,13 +10,30 @@ const HEAVY_TEXT_CHARS = 120_000;
 const HEAVY_CODE_CHARS = 60_000;
 const HEAVY_CODE_FENCES = 32;
 const HEAVY_SINGLE_FENCE_CHARS = 30_000;
+/** stream-diffs / Monaco layout can fail on a single ultra-long line (common
+ *  in plain-text fences). Prefer the plain 
 path so chat keeps a stable
+ *  scrollable code block instead of falling through a broken highlighter. */
+const HEAVY_SINGLE_LINE_CHARS = 512;
 
 const CODE_FENCE_RE = /(^|\n)(`{3,}|~{3,})[^\n]*\n([\s\S]*?)(?:\n)?\2(?=\n|$)/g;
 
+function longestLineLength(code: string): number {
+  let longest = 0;
+  let start = 0;
+  for (let i = 0; i <= code.length; i += 1) {
+    if (i === code.length || code.charCodeAt(i) === 10 /* \n */) {
+      longest = Math.max(longest, i - start);
+      start = i + 1;
+    }
+  }
+  return longest;
+}
+
 export function markdownRenderPlan(text: string): MarkdownRenderPlan {
   let codeFenceCount = 0;
   let codeChars = 0;
   let longestFence = 0;
+  let longestLine = 0;
   CODE_FENCE_RE.lastIndex = 0;
   let match: RegExpExecArray | null;
   while ((match = CODE_FENCE_RE.exec(text)) !== null) {
@@ -24,13 +41,15 @@ export function markdownRenderPlan(text: string): MarkdownRenderPlan {
     codeFenceCount += 1;
     codeChars += code.length;
     longestFence = Math.max(longestFence, code.length);
+    longestLine = Math.max(longestLine, longestLineLength(code));
   }
 
   const heavy =
     text.length >= HEAVY_TEXT_CHARS ||
     codeChars >= HEAVY_CODE_CHARS ||
     codeFenceCount >= HEAVY_CODE_FENCES ||
-    longestFence >= HEAVY_SINGLE_FENCE_CHARS;
+    longestFence >= HEAVY_SINGLE_FENCE_CHARS ||
+    longestLine >= HEAVY_SINGLE_LINE_CHARS;
 
   return {
     codeRenderer: heavy ? 'pre' : 'shiki',
diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts
index 36f3f8eda7..eec64a9381 100644
--- a/apps/kimi-web/test/lib-logic.test.ts
+++ b/apps/kimi-web/test/lib-logic.test.ts
@@ -31,6 +31,7 @@ import AgentTool from '../src/components/chat/tool-calls/AgentTool.vue';
 import EditTool from '../src/components/chat/tool-calls/EditTool.vue';
 import GenericTool from '../src/components/chat/tool-calls/GenericTool.vue';
 import type { ToolCall } from '../src/types';
+import { markdownRenderPlan } from '../src/lib/markdownPerformance';
 import {
   clearTrace,
   installClientErrorCapture,
@@ -863,3 +864,24 @@ describe('keepLiveSubagents', () => {
     expect(merged?.outputBytes).toBe(200);
   });
 });
+
+describe('markdownRenderPlan', () => {
+  it('keeps shiki for ordinary short fences', () => {
+    const plan = markdownRenderPlan('```text\nhello\n```\n');
+    expect(plan.codeRenderer).toBe('shiki');
+    expect(plan.codeFenceCount).toBe(1);
+  });
+
+  it('uses pre when a single code line is extremely long', () => {
+    const longLine = 'x'.repeat(512);
+    const plan = markdownRenderPlan(`\`\`\`text\n${longLine}\n\`\`\`\n`);
+    expect(plan.codeRenderer).toBe('pre');
+    expect(plan.codeFenceCount).toBe(1);
+  });
+
+  it('uses pre for a heavy single fence by character count', () => {
+    const body = `${'line\n'.repeat(2000)}${'y'.repeat(20_000)}`;
+    const plan = markdownRenderPlan(`\`\`\`js\n${body}\n\`\`\`\n`);
+    expect(plan.codeRenderer).toBe('pre');
+  });
+});
diff --git a/apps/kimi-web/test/turn-logic.test.ts b/apps/kimi-web/test/turn-logic.test.ts
index 732f3c3f58..1f02d74f16 100644
--- a/apps/kimi-web/test/turn-logic.test.ts
+++ b/apps/kimi-web/test/turn-logic.test.ts
@@ -612,12 +612,33 @@ describe('messagesToTurns resync dedup', () => {
 
     expect(turns).toHaveLength(1);
     expect(turns[0]?.thinking).toBe('let me check');
-    expect(turns[0]?.text).toBe('I will \nrun ls');
+    expect(turns[0]?.text).toBe('I will run ls');
     expect(turns[0]?.tools).toHaveLength(1);
     // The seed's live progress survives the dedup — the persisted card had none.
     expect(turns[0]?.tools?.[0]?.output).toEqual(['total 8']);
   });
 
+  it('coalesces consecutive streamed text chunks without inserting newlines', () => {
+    // History projection keeps one content part per stream delta; joining with
+    // '\n' made reloads show one line per chunk under white-space: pre-wrap.
+    const turns = messagesToTurns(
+      [
+        message('a1', 'assistant', [
+          { type: 'text', text: '你好!' },
+          { type: 'text', text: '我的' },
+          { type: 'text', text: '上下文' },
+        ]),
+      ],
+      [],
+      undefined,
+      false,
+    );
+
+    expect(turns).toHaveLength(1);
+    expect(turns[0]?.text).toBe('你好!我的上下文');
+    expect(turns[0]?.blocks).toEqual([{ kind: 'text', text: '你好!我的上下文' }]);
+  });
+
   it('keeps the seeded message when the transcript has no copy of the current step yet', () => {
     const turns = messagesToTurns(
       [
diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts
index 76cdec4587..b70e988153 100644
--- a/apps/vis/server/src/lib/context-projector.ts
+++ b/apps/vis/server/src/lib/context-projector.ts
@@ -514,6 +514,8 @@ export function projectContext(
       case 'tools.unregister_user_tool':
       case 'tools.set_active_tools':
       case 'tools.update_store':
+      case 'profile.bind':
+      case 'tools.reset_active_tools':
       case 'llm.tools_snapshot':
       case 'llm.request':
       case 'mcp.tools_discovered':
diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx
index 126ac3bded..d59b239cf0 100644
--- a/apps/vis/web/src/components/wire/renderers.tsx
+++ b/apps/vis/web/src/components/wire/renderers.tsx
@@ -83,6 +83,29 @@ export const WIRE_RENDERERS: RendererMap = {
     },
   },
 
+  'profile.bind': {
+    tone: 'config',
+    label: 'profile',
+    headline: (r) => {
+      const parts: string[] = [];
+      if (r.profileName !== undefined) parts.push(`profile=${r.profileName}`);
+      if (r.modelAlias !== undefined) parts.push(`model=${r.modelAlias}`);
+      if (r.thinkingEffort !== undefined) parts.push(`thinking=${r.thinkingEffort}`);
+      if (r.activeToolNames !== undefined) {
+        parts.push(`${r.activeToolNames.length} tools`);
+      } else {
+        parts.push('all tools');
+      }
+      return {
+        main: (
+          
+            {parts.length === 0 ? (no fields) : parts.join(' · ')}
+          
+        ),
+      };
+    },
+  },
+
   'turn.prompt': {
     tone: 'turn',
     label: 'prompt',
@@ -324,6 +347,14 @@ export const WIRE_RENDERERS: RendererMap = {
     },
   },
 
+  'tools.reset_active_tools': {
+    tone: 'tools',
+    label: 'reset',
+    headline: () => ({
+      main: all tools active,
+    }),
+  },
+
   'tools.update_store': {
     tone: 'meta',
     label: 'store',
diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md
index 2ff8f2a663..1d7bce3bc1 100644
--- a/docs/en/guides/getting-started.md
+++ b/docs/en/guides/getting-started.md
@@ -145,7 +145,7 @@ For a first-time user, the following is all you need to know:
 | `/sessions` | Browse session history and choose one to resume |
 | `/model` | Switch the current model |
 | `/compact` | Manually compress the context to free up tokens |
-| `/fork` | Fork the current session, keeping history but continuing independently |
+| `/fork` | Fork the current session into an independent copy with full history (you stay in the current session) |
 
 **Most-used keyboard shortcuts**
 
diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md
index 4c243d3220..b63ee8d24a 100644
--- a/docs/en/guides/sessions.md
+++ b/docs/en/guides/sessions.md
@@ -85,7 +85,7 @@ To explore a new direction without disrupting the current conversation, use `/fo
 /fork
 ```
 
-The two resulting sessions are completely independent and do not affect each other. You can switch back to the original at any time using `/sessions`. A saved `/goal` is not copied to the fork. Start a new goal there if you want autonomous goal work.
+Forking does not switch you away: you stay in the original session and the conversation continues untouched. The fork is an independent copy you can switch to at any time using `/sessions`. A saved `/goal` is not copied to the fork. Start a new goal there if you want autonomous goal work.
 
 ## Exporting a session
 
diff --git a/docs/en/guides/use-cases.md b/docs/en/guides/use-cases.md
index 0b367aa11e..adf5154a7f 100644
--- a/docs/en/guides/use-cases.md
+++ b/docs/en/guides/use-cases.md
@@ -81,7 +81,7 @@ src/parser/markdown.ts currently has almost no tests. Please add a unit test sui
 Extract the repeated "read body → validate → log → respond" pattern in src/handlers into a middleware. Run the tests afterwards to make sure existing behavior is unchanged.
 ```
 
-For multi-file refactors, use Plan mode first to confirm the approach. You can also use `/fork` to create an experimental branch — if you don't like the result, just switch back to the original session.
+For multi-file refactors, use Plan mode first to confirm the approach. You can also `/fork` the session into an experimental branch and switch to it from `/sessions` — forking itself never disrupts the original session, so you can simply switch back if you don't like the result.
 
 ## One-off scripts and automation
 
diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md
index ae6d6dca75..7247c05e22 100644
--- a/docs/en/reference/slash-commands.md
+++ b/docs/en/reference/slash-commands.md
@@ -30,7 +30,7 @@ Some commands are only available in the idle state. Executing these commands whi
 | `/new` | `/clear` | Start a fresh session, discarding the current context | No |
 | `/sessions` | `/resume` | Browse historical sessions and switch to / restore one | No |
 | `/tasks` | `/task` | Browse the background task list | Yes |
-| `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No |
+| `/fork` | — | Fork a new session from the current one, preserving the full conversation history; you stay in the current session | No |
 | `/title []` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes |
 | `/compact []` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No |
 | `/undo []` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone. Undoing also rolls back the todo list and plan mode state produced by those prompts (code changes are not reverted) | No |
diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md
index ca8ef87fc7..fc3f8870a3 100644
--- a/docs/zh/guides/getting-started.md
+++ b/docs/zh/guides/getting-started.md
@@ -145,7 +145,7 @@ Kimi Code CLI 会规划步骤、修改代码、运行测试,并在每一步告
 | `/sessions` | 浏览历史会话,选择恢复 |
 | `/model` | 切换当前使用的模型 |
 | `/compact` | 手动压缩上下文,释放 token |
-| `/fork` | 派生当前会话,保留历史独立继续 |
+| `/fork` | 派生当前会话为保留完整历史的独立副本(仍停留在当前会话) |
 
 **最常用快捷键**
 
diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md
index 2ca06a6d59..fb979b077b 100644
--- a/docs/zh/guides/sessions.md
+++ b/docs/zh/guides/sessions.md
@@ -85,7 +85,7 @@ kimi --session
 /fork
 ```
 
-派生后的两个会话彼此独立,互不影响,可以随时通过 `/sessions` 切回原来的会话。已保存的 `/goal` 不会复制到派生会话。如果你想在派生会话中进行自主 goal 工作,需要在那里开始一个新 goal。
+fork 后你仍停留在原会话,对话不受影响、可以直接继续;派生出的副本与原会话彼此独立,可以随时通过 `/sessions` 切换过去。已保存的 `/goal` 不会复制到派生会话。如果你想在派生会话中进行自主 goal 工作,需要在那里开始一个新 goal。
 
 ## 导出会话
 
diff --git a/docs/zh/guides/use-cases.md b/docs/zh/guides/use-cases.md
index 3222c08eec..bfd1a93bca 100644
--- a/docs/zh/guides/use-cases.md
+++ b/docs/zh/guides/use-cases.md
@@ -81,7 +81,7 @@ src/parser/markdown.ts 目前几乎没有测试。请补一组单元测试,覆
 把 src/handlers 下重复的「读 body → 校验 → 写日志 → 返回」逻辑抽成一个中间件。改完跑一遍测试,保证现有行为不变。
 ```
 
-多文件重构建议先用 Plan 模式确认方案,可用 `/fork` 派生一个试验分支,不满意直接切回原会话。
+多文件重构建议先用 Plan 模式确认方案,也可以用 `/fork` 派生一个试验分支,再从 `/sessions` 切换过去尝试;fork 本身不影响原会话,不满意切回来即可。
 
 ## 一次性脚本与自动化任务
 
diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md
index a0a5a4d6bd..6dd371a372 100644
--- a/docs/zh/reference/slash-commands.md
+++ b/docs/zh/reference/slash-commands.md
@@ -30,7 +30,7 @@
 | `/new` | `/clear` | 开启全新会话,丢弃当前上下文 | 否 |
 | `/sessions` | `/resume` | 浏览历史会话并切换/恢复 | 否 |
 | `/tasks` | `/task` | 浏览后台任务列表 | 是 |
-| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 |
+| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史;fork 后仍停留在当前会话 | 否 |
 | `/title []` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 |
 | `/compact []` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 |
 | `/undo []` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销。撤销会一并回滚这些提示词产生的 todo 列表和计划模式状态(不回滚代码改动) | 否 |
diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts
index 137930e540..21af7ecf26 100644
--- a/packages/agent-core-v2/src/agent/mcp/output.ts
+++ b/packages/agent-core-v2/src/agent/mcp/output.ts
@@ -147,6 +147,26 @@ export async function mcpResultToExecutableOutput(
   }
 
   const wrapped = wrapMediaOnly(converted, qualifiedToolName);
+  const structuredExtras: Record = {};
+  if (result.structuredContent !== undefined) {
+    structuredExtras['structuredContent'] = result.structuredContent;
+  }
+  if (result._meta !== undefined) {
+    const meta = stripReservedMetaKeys(result._meta);
+    if (meta !== undefined) {
+      structuredExtras['_meta'] = meta;
+    }
+  }
+  if (Object.keys(structuredExtras).length > 0) {
+    const serialized = serializeStructuredExtras(structuredExtras);
+    if (serialized !== undefined) {
+      wrapped.push({
+        type: 'text',
+        text: `\n\n${serialized}\n`,
+      });
+    }
+  }
+
   const budgeted = applyTextBudget(wrapped);
   const compressed = await compressImageContentParts(budgeted.parts, {
     telemetry:
@@ -174,6 +194,36 @@ export async function mcpResultToExecutableOutput(
   };
 }
 
+function serializeStructuredExtras(extras: Record): string | undefined {
+  try {
+    return JSON.stringify(extras).replaceAll('', '');
+  } catch {
+    return undefined;
+  }
+}
+
+function stripReservedMetaKeys(
+  meta: Record,
+): Record | undefined {
+  const out: Record = {};
+  for (const [key, value] of Object.entries(meta)) {
+    if (!isReservedMetaKey(key)) {
+      out[key] = value;
+    }
+  }
+  return Object.keys(out).length > 0 ? out : undefined;
+}
+
+function isReservedMetaKey(key: string): boolean {
+  const slash = key.indexOf('/');
+  if (slash <= 0) return false;
+  const labels = key.slice(0, slash).split('.');
+  return labels.some(
+    (label, i) =>
+      (label === 'modelcontextprotocol' || label === 'mcp') && i < labels.length - 1,
+  );
+}
+
 function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string): ContentPart[] {
   const hasMedia = parts.some(
     (p) => p.type === 'image_url' || p.type === 'audio_url' || p.type === 'video_url',
diff --git a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts
index 69df9c4e28..1912dc89c9 100644
--- a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts
+++ b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts
@@ -44,6 +44,7 @@ export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_
 export interface McpOAuthAuthorizationUrlUpdateData {
   readonly serverName: string;
   readonly authorizationUrl: string;
+  readonly expiresAt?: number;
 }
 
 const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000;
@@ -107,9 +108,11 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable
     }
 
     const urlText = flow.authorizationUrl.toString();
+    const waitTimeoutMs = timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
     const customData: McpOAuthAuthorizationUrlUpdateData = {
       serverName,
       authorizationUrl: urlText,
+      expiresAt: Date.now() + waitTimeoutMs,
     };
     onUpdate?.({
       kind: 'custom',
@@ -126,7 +129,7 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable
     });
 
     try {
-      await flow.complete({ signal, timeoutMs: timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS });
+      await flow.complete({ signal, timeoutMs: waitTimeoutMs });
     } catch (error) {
       return errorResult(serverName, error, urlText);
     }
diff --git a/packages/agent-core-v2/src/agent/tools/edit/edit.md b/packages/agent-core-v2/src/agent/tools/edit/edit.md
index f928fa22fb..808e55d826 100644
--- a/packages/agent-core-v2/src/agent/tools/edit/edit.md
+++ b/packages/agent-core-v2/src/agent/tools/edit/edit.md
@@ -8,6 +8,8 @@ Perform exact replacements in existing files.
 - If `old_string` is ambiguous, add surrounding context. Use `replace_all` only when every occurrence should change — for example, renaming a symbol throughout the file.
 - Multiple Edit calls may run in one response only when they do not target the same file.
 - DO NOT issue consecutive Edit calls on the same file. A previous Edit can invalidate a later Edit's `old_string`, causing `old_string not found`. Read the file again before the next Edit.
+- After Edit fails with `old_string not found`, Read the whole file (or a large enough region covering the edit) once before retrying. Do not loop Edit → short 30–50 line Read → Edit.
+- Never replace a multi-line span with an empty `new_string` unless you intend to delete it — set `allow_large_delete=true` for that intentional deletion. Prefer replacing with the real new content.
 - A write lock serializes same-file edits in response order, but serialization does not make stale `old_string` valid.
 - For pure CRLF files, Read shows LF; use LF in `old_string` and `new_string`, and Edit writes CRLF back.
 - For mixed endings or lone carriage returns, Read shows carriage returns as \r; include actual \r escapes in those positions.
diff --git a/packages/agent-core-v2/src/agent/tools/edit/edit.ts b/packages/agent-core-v2/src/agent/tools/edit/edit.ts
index 6e70f397b3..b6c0f735fb 100644
--- a/packages/agent-core-v2/src/agent/tools/edit/edit.ts
+++ b/packages/agent-core-v2/src/agent/tools/edit/edit.ts
@@ -38,6 +38,12 @@ export const EditInputSchema = z.object({
     .boolean()
     .optional()
     .describe('Set true only when every occurrence of old_string should be replaced.'),
+  allow_large_delete: z
+    .boolean()
+    .optional()
+    .describe(
+      'Set true only when intentionally deleting a multi-line span with an empty (or whitespace-only) new_string. Omit for normal edits.',
+    ),
 });
 
 export type EditInput = z.infer;
diff --git a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts
index c23f2a699b..62fe0338eb 100644
--- a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts
+++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts
@@ -105,6 +105,7 @@ export class EditTool implements IEditTool {
       old_string: args.old_string,
       new_string: args.new_string,
       replace_all: args.replace_all ?? false,
+      allow_large_delete: args.allow_large_delete,
     });
     if (!result.ok) {
       return { isError: true, output: result.error };
diff --git a/packages/agent-core-v2/src/app/edit/editService.ts b/packages/agent-core-v2/src/app/edit/editService.ts
index d3c87ae792..b6fbbb440b 100644
--- a/packages/agent-core-v2/src/app/edit/editService.ts
+++ b/packages/agent-core-v2/src/app/edit/editService.ts
@@ -15,15 +15,44 @@ export interface EditApplyInput {
   readonly old_string: string;
   readonly new_string: string;
   readonly replace_all: boolean;
+  readonly allow_large_delete?: boolean;
 }
 
 export type EditApplyResult =
   | { readonly ok: true; readonly rawContent: string; readonly count: number }
   | { readonly ok: false; readonly error: string };
 
+/** Multi-line empty replacements without an explicit opt-in are refused (see #2427). */
+const LARGE_DELETE_MIN_OLD_LINES = 3;
+
+export function countEditLines(text: string): number {
+  if (text.length === 0) return 0;
+  let lines = 1;
+  for (let i = 0; i < text.length; i++) {
+    if (text.charCodeAt(i) === 10) lines++;
+  }
+  return lines;
+}
+
+export function isOversizedEmptyDeletion(oldString: string, newString: string): boolean {
+  return newString.trim().length === 0 && countEditLines(oldString) >= LARGE_DELETE_MIN_OLD_LINES;
+}
+
+function oversizedDeletionMessage(path: string): string {
+  return (
+    `Refusing a multi-line deletion in ${path}: new_string is empty (or whitespace-only) while ` +
+    `old_string spans ${String(LARGE_DELETE_MIN_OLD_LINES)}+ lines. Read the file again, then either ` +
+    `replace with the intended new content, delete fewer lines at a time, or set allow_large_delete=true ` +
+    `if you intentionally want to remove that entire span.`
+  );
+}
+
 function notFoundMessage(path: string): string {
-  return `old_string not found in ${path}, the file contents may be out of date. Please use the Read Tool to reload the content.
-`;
+  return (
+    `old_string not found in ${path}, the file contents may be out of date. ` +
+    `Read the full file (or a large enough region covering the edit) with the Read tool before retrying — ` +
+    `do not keep retrying Edit from a short 30–50 line window.\n`
+  );
 }
 
 function notUniqueMessage(path: string, count: number): string {
@@ -35,6 +64,10 @@ function notUniqueMessage(path: string, count: number): string {
 
 export class EditService {
   apply(model: TextModel, input: EditApplyInput): EditApplyResult {
+    if (input.allow_large_delete !== true && isOversizedEmptyDeletion(input.old_string, input.new_string)) {
+      return { ok: false, error: oversizedDeletionMessage(input.path) };
+    }
+
     if (input.replace_all) {
       const { text, count } = model.replaceAll(input.old_string, input.new_string);
       if (count === 0) return { ok: false, error: notFoundMessage(input.path) };
diff --git a/packages/agent-core-v2/src/app/edit/fileEdit.ts b/packages/agent-core-v2/src/app/edit/fileEdit.ts
index c8f7c3c08d..c75d91aa43 100644
--- a/packages/agent-core-v2/src/app/edit/fileEdit.ts
+++ b/packages/agent-core-v2/src/app/edit/fileEdit.ts
@@ -17,6 +17,7 @@ export interface FileEditInput {
   readonly old_string: string;
   readonly new_string: string;
   readonly replace_all: boolean;
+  readonly allow_large_delete?: boolean;
 }
 
 export type FileEditResult =
diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts
index fd934e1c46..bf4e2519b8 100644
--- a/packages/agent-core-v2/src/app/edit/fileEditService.ts
+++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts
@@ -34,6 +34,7 @@ export class FileEditService implements IFileEditService {
         old_string: input.old_string,
         new_string: input.new_string,
         replace_all: input.replace_all,
+        allow_large_delete: input.allow_large_delete,
       });
       if (!result.ok) {
         return { ok: false, error: result.error };
diff --git a/packages/agent-core-v2/src/tool/args-validator.ts b/packages/agent-core-v2/src/tool/args-validator.ts
index 1c9ee5ea4e..b47dcd3c44 100644
--- a/packages/agent-core-v2/src/tool/args-validator.ts
+++ b/packages/agent-core-v2/src/tool/args-validator.ts
@@ -102,12 +102,75 @@ export function compileToolArgsValidator(schema: Record): ToolA
   return ajvFor(schema).compile(schema) as ToolArgsValidator;
 }
 
+const TYPE_MISMATCH_RE = /^must be (integer|number|boolean|array|object)$/;
+
+function coerceStringValue(value: string): JsonType {
+  const trimmed = value.trim();
+  if (trimmed === '') return value;
+  if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
+    try {
+      return JSON.parse(trimmed) as JsonType;
+    } catch {
+      return value;
+    }
+  }
+  if (trimmed === 'true') return true;
+  if (trimmed === 'false') return false;
+  const num = Number(trimmed);
+  return Number.isFinite(num) ? num : value;
+}
+
+function setAtPath(obj: JsonObject, path: string, value: JsonType): void {
+  const keys = path.split('/').filter(Boolean);
+  if (keys.length === 0) return;
+  let current: unknown = obj;
+  for (let i = 0; i < keys.length - 1; i++) {
+    const key = keys[i]!.replace(/~1/g, '/').replace(/~0/g, '~');
+    if (typeof current !== 'object' || current === null) return;
+    current = (current as Record)[key];
+  }
+  if (typeof current === 'object' && current !== null) {
+    const lastKey = keys[keys.length - 1]!.replace(/~1/g, '/').replace(/~0/g, '~');
+    (current as Record)[lastKey] = value;
+  }
+}
+
+function getAtPath(obj: JsonObject, path: string): unknown {
+  const keys = path.split('/').filter(Boolean);
+  let current: unknown = obj;
+  for (const key of keys) {
+    if (typeof current !== 'object' || current === null) return undefined;
+    current = (current as Record)[key.replace(/~1/g, '/').replace(/~0/g, '~')];
+  }
+  return current;
+}
+
 export function validateToolArgs(validator: ToolArgsValidator, args: JsonType): string | null {
-  const valid = validator(args);
-  if (valid) {
+  if (validator(args)) {
     return null;
   }
 
+  if (typeof args === 'object' && args !== null) {
+    const typeErrors = (validator.errors ?? []).filter(
+      (e) => e.keyword === 'type' && TYPE_MISMATCH_RE.test(e.message ?? ''),
+    );
+    let mutated = false;
+    for (const error of typeErrors) {
+      const value = getAtPath(args as JsonObject, error.instancePath);
+      if (typeof value !== 'string') continue;
+      const coerced = coerceStringValue(value);
+      if (coerced !== value) {
+        setAtPath(args as JsonObject, error.instancePath, coerced);
+        mutated = true;
+      }
+    }
+    if (mutated) {
+      if (validator(args)) {
+        return null;
+      }
+    }
+  }
+
   const errors = validator.errors ?? [];
   if (errors.length === 0) {
     return 'Tool parameter validation failed';
diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts
index e051fd31d9..c575430ae1 100644
--- a/packages/agent-core-v2/test/agent/mcp/output.test.ts
+++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts
@@ -265,6 +265,96 @@ describe('mcpResultToExecutableOutput', () => {
     expect(out).toEqual({ output: 'oops', isError: true });
   });
 
+  test('surfaces structuredContent and _meta as a serialized mcp-structured-result block', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        structuredContent: { foo: 1 },
+        _meta: { bar: 2 },
+      },
+      'mcp__s__t',
+    );
+    const parts = out.output as ContentPart[];
+    const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
+    expect(joined).toContain('');
+    expect(joined).toContain('"structuredContent":{"foo":1}');
+    expect(joined).toContain('"_meta":{"bar":2}');
+    expect(out.isError).toBe(false);
+  });
+
+  test('keeps the mcp_tool_result wrap when a media-only result carries structuredContent', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'image', data: 'AAA', mimeType: 'image/png' }],
+        isError: false,
+        structuredContent: { foo: 1 },
+      },
+      'mcp__s__shot',
+    );
+    const parts = out.output as ContentPart[];
+    // The structured block sits OUTSIDE the media wrap, after the closing
+    // tag, so the image keeps its tool attribution.
+    expect(parts[0]).toEqual({ type: 'text', text: '' });
+    expect(parts.at(-2)).toEqual({ type: 'text', text: '' });
+    const last = parts.at(-1);
+    expect(last?.type === 'text' && last.text.includes('')).toBe(true);
+  });
+
+  test('strips literal closing tags inside the structured payload', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        _meta: { evil: 'ab' },
+      },
+      'mcp__s__t',
+    );
+    const parts = out.output as ContentPart[];
+    const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
+    expect(joined).toContain('"evil":"ab"');
+    // Exactly one closing tag survives: the wrapper's own.
+    expect(joined.split('')).toHaveLength(2);
+  });
+
+  test('drops protocol-reserved _meta keys and keeps vendor namespaces', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        _meta: {
+          'modelcontextprotocol.io/progress': 1,
+          'tools.mcp.com/trace': 'x',
+          'example.com/custom': 2,
+          // Reserved only when another label FOLLOWS mcp/modelcontextprotocol:
+          // a trailing reserved word is a legitimate vendor namespace.
+          'com.example.mcp/trace': 4,
+          vendorKey: 3,
+        },
+      },
+      'mcp__s__t',
+    );
+    const parts = out.output as ContentPart[];
+    const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
+    expect(joined).not.toContain('modelcontextprotocol.io/progress');
+    expect(joined).not.toContain('tools.mcp.com/trace');
+    expect(joined).toContain('"example.com/custom":2');
+    expect(joined).toContain('"com.example.mcp/trace":4');
+    expect(joined).toContain('"vendorKey":3');
+  });
+
+  test('omits the structured block when every _meta key is protocol-reserved', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        _meta: { 'mcp.dev/internal': true },
+      },
+      'mcp__s__t',
+    );
+    expect(out).toEqual({ output: 'ok', isError: false });
+  });
+
   test('returns an empty output array when the content array is empty', async () => {
     const out = await mcpResultToExecutableOutput(result([]), 'mcp__s__t');
     expect(out).toEqual({ output: [], isError: false });
diff --git a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts
index bfa958df77..353ace0e0a 100644
--- a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts
+++ b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts
@@ -62,14 +62,18 @@ describe('createMcpAuthTool', () => {
     expect(final.output).toMatch(/authenticated successfully/);
     expect(reconnectCalls).toBe(1);
     expect(updates.some((u) => u.text?.includes('https://example.com/authorize'))).toBe(true);
-    expect(updates).toContainEqual({
-      kind: 'custom',
-      customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE,
-      customData: {
-        serverName: 'notion',
-        authorizationUrl: 'https://example.com/authorize?state=abc',
-      },
+    const authUpdate = updates.find(
+      (u) => u.kind === 'custom' && u.customKind === MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE,
+    );
+    expect(authUpdate?.customData).toMatchObject({
+      serverName: 'notion',
+      authorizationUrl: 'https://example.com/authorize?state=abc',
     });
+    // The deadline is absolute (now + wait timeout), so hosts never mirror
+    // the engine-side constant.
+    const { expiresAt } = authUpdate?.customData as { expiresAt?: number };
+    expect(expiresAt).toBeGreaterThan(Date.now());
+    expect(expiresAt).toBeLessThanOrEqual(Date.now() + 15 * 60 * 1000);
   });
 
   it('falls through to reconnect when the provider reports already-authorized', async () => {
diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts
index cd22cb2978..b4498c983a 100644
--- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts
+++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts
@@ -160,6 +160,8 @@ describe('EditTool', () => {
     expect(tool.description).toContain('`old_string` must be unique');
     expect(tool.description).toContain('only when they do not target the same file');
     expect(tool.description).toContain('DO NOT issue consecutive Edit calls on the same file');
+    expect(tool.description).toContain('allow_large_delete=true');
+    expect(tool.description).toContain('short 30–50 line Read');
     expect(tool.description).toContain('DO NOT use Write or Bash `sed`');
     expect(tool.description).toContain('same-file edits in response order');
     expect(tool.description).toContain('old_string not found');
@@ -533,6 +535,55 @@ describe('EditTool', () => {
     expect(writeText).toHaveBeenCalledWith('/tmp/e.txt', 'Hello !');
   });
 
+  it('refuses multi-line empty deletions unless allow_large_delete is set', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    const file = ['# Practice Phase', '', 'body line', '', 'more'].join('\n');
+    const { fs } = createSpiedEditFs({
+      readText: vi.fn().mockResolvedValue(file),
+      writeText,
+    });
+    const tool = buildTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
+
+    const refused = await execute(tool, {
+      path: '/tmp/skill.md',
+      old_string: '# Practice Phase\n\nbody line',
+      new_string: '',
+    });
+    expect(refused).toMatchObject({ isError: true });
+    expect(refused.output).toContain('Refusing a multi-line deletion');
+    expect(refused.output).toContain('allow_large_delete=true');
+    expect(writeText).not.toHaveBeenCalled();
+
+    const allowed = await execute(tool, {
+      path: '/tmp/skill.md',
+      old_string: '# Practice Phase\n\nbody line',
+      new_string: '',
+      allow_large_delete: true,
+    });
+    expect(allowed.output).toContain('Replaced 1 occurrence');
+    expect(writeText).toHaveBeenCalledWith('/tmp/skill.md', '\n\nmore');
+  });
+
+  it('tells the model to reread a large region when old_string is missing', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    const { fs } = createSpiedEditFs({
+      readText: vi.fn().mockResolvedValue('alpha beta'),
+      writeText,
+    });
+    const tool = buildTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
+
+    const result = await execute(tool, {
+      path: '/tmp/a.txt',
+      old_string: 'delta',
+      new_string: 'gamma',
+    });
+
+    expect(result).toMatchObject({ isError: true });
+    expect(result.output).toContain('large enough region');
+    expect(result.output).toContain('30–50 line window');
+    expect(writeText).not.toHaveBeenCalled();
+  });
+
   it('allows absolute edits outside the workspace under default policy', async () => {
     const writeText = vi.fn().mockResolvedValue(undefined);
     const { fs } = createSpiedEditFs({
diff --git a/packages/agent-core-v2/test/tool/args-validator.test.ts b/packages/agent-core-v2/test/tool/args-validator.test.ts
index 0642a4ca28..e601e216b9 100644
--- a/packages/agent-core-v2/test/tool/args-validator.test.ts
+++ b/packages/agent-core-v2/test/tool/args-validator.test.ts
@@ -42,6 +42,80 @@ describe('args-validator (Ajv, format support)', () => {
     expect(validate({ enum: ['a', 'b'] }, 'c')).toContain('allowed values');
     expect(validate({ const: 'x' }, 'y')).toContain('constant');
   });
+
+  it('coerces numeric strings to numbers on validation failure', () => {
+    const schema = {
+      type: 'object',
+      properties: {
+        line_offset: { type: 'integer' },
+        path: { type: 'string' },
+      },
+    };
+    expect(validate(schema, { line_offset: '3', path: 'main.go' })).toBeNull();
+    expect(validate(schema, { line_offset: 'abc', path: 'main.go' })).toContain('must be integer');
+    expect(validate(schema, { line_offset: '%', path: 'main.go' })).toContain('must be integer');
+  });
+
+  it('coerces boolean strings on validation failure', () => {
+    const schema = {
+      type: 'object',
+      properties: {
+        replaceAll: { type: 'boolean' },
+      },
+    };
+    expect(validate(schema, { replaceAll: 'true' })).toBeNull();
+    expect(validate(schema, { replaceAll: 'false' })).toBeNull();
+  });
+
+  it('coerces stringified JSON arrays/objects on validation failure', () => {
+    const schema = {
+      type: 'object',
+      properties: {
+        todos: { type: 'array', items: { type: 'string' } },
+        meta: { type: 'object' },
+      },
+    };
+    expect(validate(schema, { todos: '["a","b"]' })).toBeNull();
+    expect(validate(schema, { meta: '{"key":"val"}' })).toBeNull();
+    expect(validate(schema, { todos: '[broken' })).toContain('must be array');
+  });
+
+  it('does NOT coerce fields whose schema accepts strings', () => {
+    const schema = {
+      type: 'object',
+      properties: {
+        path: { type: 'string' },
+        line_offset: { type: 'integer' },
+      },
+    };
+    expect(validate(schema, { path: '123', line_offset: '3' })).toBeNull();
+  });
+
+  it('reports post-coercion errors, not stale type errors', () => {
+    const schema = {
+      type: 'object',
+      properties: {
+        line_offset: { type: 'integer', minimum: 1 },
+      },
+    };
+    const result = validate(schema, { line_offset: '0' });
+    expect(result).not.toBeNull();
+    expect(result).not.toContain('must be integer');
+    expect(result).toContain('>= 1');
+  });
+
+  it('does NOT coerce null (unlike AJV coerceTypes)', () => {
+    const schema = {
+      type: 'object',
+      properties: {
+        content: { type: 'string' },
+        count: { type: 'integer' },
+      },
+      required: ['content'],
+    };
+    expect(validate(schema, { content: null })).not.toBeNull();
+    expect(validate(schema, { content: 'ok', count: null })).not.toBeNull();
+  });
 });
 
 describe('args-validator (honest type errors)', () => {
diff --git a/packages/agent-core/src/agent/background/process-task.ts b/packages/agent-core/src/agent/background/process-task.ts
index f2b3d73284..ee8633eeb1 100644
--- a/packages/agent-core/src/agent/background/process-task.ts
+++ b/packages/agent-core/src/agent/background/process-task.ts
@@ -23,8 +23,6 @@ export type ProcessBackgroundTaskOutputCallback = (
   text: string,
 ) => void;
 
-const STREAM_DRAIN_GRACE_MS = 250;
-
 export class ProcessBackgroundTask implements BackgroundTask {
   readonly kind = 'process' as const;
   readonly idPrefix = 'bash';
@@ -58,7 +56,7 @@ export class ProcessBackgroundTask implements BackgroundTask {
     let settlement: BackgroundTaskSettlement;
     try {
       const exitCode = await this.proc.wait();
-      await waitForStreamDrain(streamDrained);
+      await streamDrained;
       this.exitCode = exitCode;
       settlement = {
         status: sink.signal.aborted ? 'killed' : exitCode === 0 ? 'completed' : 'failed',
@@ -106,24 +104,9 @@ export class ProcessBackgroundTask implements BackgroundTask {
   }
 }
 
-async function waitForStreamDrain(streamDrained: Promise): Promise {
-  let timeout: ReturnType | undefined;
-  try {
-    await Promise.race([
-      streamDrained,
-      new Promise((resolve) => {
-        timeout = setTimeout(resolve, STREAM_DRAIN_GRACE_MS);
-        timeout.unref?.();
-      }),
-    ]);
-  } finally {
-    if (timeout !== undefined) clearTimeout(timeout);
-  }
-}
-
 async function waitForStreamDrainSettled(streamDrained: Promise): Promise {
   try {
-    await waitForStreamDrain(streamDrained);
+    await streamDrained;
   } catch {
     /* original process/stream error wins */
   }
diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts
index 725186b098..5696060429 100644
--- a/packages/agent-core/src/agent/config/index.ts
+++ b/packages/agent-core/src/agent/config/index.ts
@@ -45,6 +45,20 @@ export class ConfigState {
   }
 
   update(changed: AgentConfigUpdateData): void {
+    this.applyUpdate(changed, true);
+  }
+
+  /**
+   * Restore config state without synthesizing a v1 replay record. This is
+   * used when a v2-only wire record is projected onto v1 state: the state
+   * should be available to the resumed agent, but the v2 record must not
+   * appear as a `config_updated` event in the replay surface.
+   */
+  restore(changed: AgentConfigUpdateData): void {
+    this.applyUpdate(changed, false);
+  }
+
+  private applyUpdate(changed: AgentConfigUpdateData, emitReplayRecord: boolean): void {
     if (Object.keys(changed).length === 0) return;
 
     const targetAlias = changed.modelAlias ?? this._modelAlias;
@@ -86,10 +100,12 @@ export class ConfigState {
       type: 'config.update',
       ...effectiveChanged,
     });
-    this.agent.replayBuilder.push({
-      type: 'config_updated',
-      config: effectiveChanged,
-    });
+    if (emitReplayRecord) {
+      this.agent.replayBuilder.push({
+        type: 'config_updated',
+        config: effectiveChanged,
+      });
+    }
     if (changed.cwd) {
       this._cwd = changed.cwd;
       this.agent.setKaos(this.agent.kaos.withCwd(changed.cwd));
diff --git a/packages/agent-core/src/agent/permission/index.ts b/packages/agent-core/src/agent/permission/index.ts
index dbc4b0855d..068397f0f6 100644
--- a/packages/agent-core/src/agent/permission/index.ts
+++ b/packages/agent-core/src/agent/permission/index.ts
@@ -1,5 +1,6 @@
 import type { Agent } from '..';
 import type { PrepareToolExecutionResult } from '../../loop';
+import type { RenderedHookResult } from '../../session/hooks';
 import { createPermissionDecisionPolicies } from './policies';
 import type {
   ApprovalResponse,
@@ -28,6 +29,7 @@ interface PolicyEvaluation {
 export class PermissionManager {
   readonly policies: PermissionPolicy[];
   readonly rules: PermissionRule[] = [];
+  private readonly allowedPreToolHookResults = new Map();
   private modeOverride: PermissionMode | undefined;
   private readonly parent: PermissionManager | undefined;
   private readonly localSessionApprovalRulePatterns = new Set();
@@ -38,7 +40,9 @@ export class PermissionManager {
   ) {
     this.rules = [...(options.initialRules ?? [])];
     this.parent = options.parent;
-    this.policies = createPermissionDecisionPolicies(this.agent);
+    this.policies = createPermissionDecisionPolicies(this.agent, (toolCallId, result) => {
+      this.allowedPreToolHookResults.set(toolCallId, result);
+    });
   }
 
   get mode(): PermissionMode {
@@ -93,6 +97,12 @@ export class PermissionManager {
     ];
   }
 
+  takeAllowedPreToolHookResult(toolCallId: string): RenderedHookResult | undefined {
+    const result = this.allowedPreToolHookResults.get(toolCallId);
+    this.allowedPreToolHookResults.delete(toolCallId);
+    return result;
+  }
+
   async beforeToolCall(
     context: PermissionPolicyContext,
   ): Promise {
diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts
index a0ba9bdfec..bfab8a17a3 100644
--- a/packages/agent-core/src/agent/permission/policies/index.ts
+++ b/packages/agent-core/src/agent/permission/policies/index.ts
@@ -1,4 +1,5 @@
 import type { Agent } from '../..';
+import type { RenderedHookResult } from '../../../session/hooks';
 import type { PermissionPolicy } from '../types';
 import { AgentSwarmExclusiveDenyPermissionPolicy } from './agent-swarm-exclusive-deny';
 import { AutoModeApprovePermissionPolicy } from './auto-mode-approve';
@@ -25,10 +26,13 @@ import {
 import { YoloModeApprovePermissionPolicy } from './yolo-mode-approve';
 
 /** Permission policies run in order; the first non-undefined result wins. */
-export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy[] {
+export function createPermissionDecisionPolicies(
+  agent: Agent,
+  onAllowedPreToolHookResult?: (toolCallId: string, result: RenderedHookResult) => void,
+): PermissionPolicy[] {
   return [
     // PreToolUse hook returned a block → deny.
-    new PreToolCallHookPermissionPolicy(agent),
+    new PreToolCallHookPermissionPolicy(agent, onAllowedPreToolHookResult),
     // AgentSwarm is batch-exclusive and must run alone, regardless of permission mode.
     new AgentSwarmExclusiveDenyPermissionPolicy(),
     // auto mode + AskUserQuestion → deny.
diff --git a/packages/agent-core/src/agent/permission/policies/pre-tool-call-hook.ts b/packages/agent-core/src/agent/permission/policies/pre-tool-call-hook.ts
index 294125d855..02df59e835 100644
--- a/packages/agent-core/src/agent/permission/policies/pre-tool-call-hook.ts
+++ b/packages/agent-core/src/agent/permission/policies/pre-tool-call-hook.ts
@@ -1,14 +1,22 @@
 import type { Agent } from '../..';
 import { isPlainRecord } from '../../turn/canonical-args';
+import {
+  renderAllowedHookResult,
+  resolveHookBlockDecision,
+  type RenderedHookResult,
+} from '../../../session/hooks';
 import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
 
 export class PreToolCallHookPermissionPolicy implements PermissionPolicy {
   readonly name = 'pre-tool-call-hook';
 
-  constructor(private readonly agent: Agent) {}
+  constructor(
+    private readonly agent: Agent,
+    private readonly onAllowedResult?: (toolCallId: string, result: RenderedHookResult) => void,
+  ) {}
 
   async evaluate(context: PermissionPolicyContext): Promise {
-    const hookResult = await this.agent.hooks?.triggerBlock('PreToolUse', {
+    const hookResults = await this.agent.hooks?.trigger?.('PreToolUse', {
       matcherValue: context.toolCall.name,
       signal: context.signal,
       inputData: {
@@ -18,10 +26,16 @@ export class PreToolCallHookPermissionPolicy implements PermissionPolicy {
       },
     });
     context.signal.throwIfAborted();
-    if (hookResult === undefined) return;
+    if (hookResults === undefined) return;
+    const block = resolveHookBlockDecision('PreToolUse', hookResults);
+    if (block === undefined) {
+      const allowed = renderAllowedHookResult('PreToolUse', hookResults);
+      if (allowed !== undefined) this.onAllowedResult?.(context.toolCall.id, allowed);
+      return;
+    }
     return {
       kind: 'deny',
-      message: hookResult.reason,
+      message: block.reason,
     };
   }
 }
diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts
index 29511a738a..309c79edaf 100644
--- a/packages/agent-core/src/agent/records/index.ts
+++ b/packages/agent-core/src/agent/records/index.ts
@@ -48,6 +48,34 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void {
     case 'config.update':
       agent.config.update(input);
       return;
+    case 'profile.bind': {
+      // v2-engine wires persist the profile binding (including the tool
+      // allowlist) via profile.bind instead of the v1 pair of config.update +
+      // tools.set_active_tools. Map it onto the v1 equivalents so a v2
+      // session resumed here keeps its model, prompt, and tools. Records
+      // without an activeToolNames array (v2's "every tool active") are
+      // skipped wholesale: leaving the config untouched preserves the
+      // session-level fallback that applies the default profile when the
+      // replayed system prompt is empty, matching how names-less
+      // tools.set_active_tools records are treated.
+      if (!Array.isArray(input.activeToolNames)) return;
+      const thinkingEffort = input.thinkingEffort ?? input.thinkingLevel;
+      agent.config.restore({
+        ...(input.modelAlias !== undefined ? { modelAlias: input.modelAlias } : {}),
+        ...(input.profileName !== undefined ? { profileName: input.profileName } : {}),
+        ...(thinkingEffort !== undefined ? { thinkingEffort } : {}),
+        ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}),
+        ...(input.subagents !== undefined ? { subagentNames: input.subagents } : {}),
+      });
+      agent.tools.setActiveTools(input.activeToolNames, input.disallowedTools);
+      return;
+    }
+    case 'tools.reset_active_tools':
+      // v2-only transition back to the unrestricted default (every tool
+      // active). v1 keeps no "all tools" state to restore — the
+      // session-level profile fallback covers fresh resumes — so the record
+      // replays as a no-op.
+      return;
     case 'permission.set_mode':
       agent.permission.setMode(input.mode);
       return;
diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts
index e9c1e1b240..ac5ddb9b21 100644
--- a/packages/agent-core/src/agent/records/types.ts
+++ b/packages/agent-core/src/agent/records/types.ts
@@ -53,6 +53,33 @@ export interface AgentRecordEvents {
 
   'config.update': AgentConfigUpdateData;
 
+  /**
+   * v2-engine profile binding (wire protocol 1.5). v1 never writes this
+   * record; the type exists so replay can map a v2 session's profile binding
+   * onto the v1 equivalents (`config.update` + `tools.set_active_tools`).
+   * Field shapes follow the v2 payload: live v2 records carry
+   * `thinkingEffort`, legacy ones may carry `thinkingLevel` instead.
+   */
+  'profile.bind': {
+    modelAlias?: string;
+    profileName?: string;
+    thinkingEffort?: string;
+    thinkingLevel?: string;
+    systemPrompt?: string;
+    /** v2 tool allowlist; absent means "every tool active". */
+    activeToolNames?: readonly string[];
+    /** v2 profile denylist, applied on top of `activeToolNames`. */
+    disallowedTools?: readonly string[];
+    subagents?: readonly string[];
+  };
+
+  /**
+   * v2-engine transition back to the unrestricted default (every tool
+   * active). v1 has no corresponding state to rebuild; replay treats it as a
+   * no-op so the session-level profile fallback keeps its behavior.
+   */
+  'tools.reset_active_tools': {};
+
   'permission.set_mode': {
     mode: PermissionMode;
   };
diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts
index ccdeed9399..de3a3b89e9 100644
--- a/packages/agent-core/src/agent/turn/index.ts
+++ b/packages/agent-core/src/agent/turn/index.ts
@@ -978,6 +978,21 @@ export class TurnFlow {
               return this.agent.permission.beforeToolCall(ctx);
             },
             finalizeToolResult: async (ctx) => {
+              const preToolHookResult = this.agent.permission.takeAllowedPreToolHookResult(
+                ctx.toolCall.id,
+              );
+              if (preToolHookResult !== undefined) {
+                this.agent.context.appendUserMessage(
+                  [{ type: 'text', text: preToolHookResult.text }],
+                  { kind: 'hook_result', event: 'PreToolUse' },
+                );
+                this.agent.emitEvent({
+                  type: 'hook.result',
+                  turnId,
+                  hookEvent: preToolHookResult.event,
+                  content: preToolHookResult.message,
+                });
+              }
               // Calls rejected in preflight (e.g. invalid args) never reach
               // prepareToolExecution, so register them here — otherwise the
               // repeat breaker cannot count them and the model can re-issue
diff --git a/packages/agent-core/src/mcp/auth-tool.ts b/packages/agent-core/src/mcp/auth-tool.ts
index 414bd0b40a..d921809fa7 100644
--- a/packages/agent-core/src/mcp/auth-tool.ts
+++ b/packages/agent-core/src/mcp/auth-tool.ts
@@ -113,9 +113,13 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable
     }
 
     const urlText = flow.authorizationUrl.toString();
+    const waitTimeoutMs = timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
     const customData: McpOAuthAuthorizationUrlUpdateData = {
       serverName,
       authorizationUrl: urlText,
+      // Absolute deadline of the pending flow, so hosts can render countdown
+      // or expiry states without mirroring DEFAULT_AUTH_TIMEOUT_MS.
+      expiresAt: Date.now() + waitTimeoutMs,
     };
     onUpdate?.({
       kind: 'custom',
@@ -132,7 +136,7 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable
     });
 
     try {
-      await flow.complete({ signal, timeoutMs: timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS });
+      await flow.complete({ signal, timeoutMs: waitTimeoutMs });
     } catch (error) {
       return errorResult(serverName, error, urlText);
     }
diff --git a/packages/agent-core/src/mcp/client-remote.ts b/packages/agent-core/src/mcp/client-remote.ts
index 6cf2d0f115..6ea2187ad2 100644
--- a/packages/agent-core/src/mcp/client-remote.ts
+++ b/packages/agent-core/src/mcp/client-remote.ts
@@ -1,5 +1,37 @@
 import type { McpRemoteServerConfig, McpServerConfig } from '#/config/schema';
 import { ErrorCodes, KimiError } from '#/errors';
+
+export function buildMcpRemoteHeaders(
+  config: McpRemoteServerConfig,
+  envLookup: (name: string) => string | undefined,
+): Record | undefined {
+  const headers: Record = { ...config.headers };
+  if (config.bearerTokenEnvVar !== undefined) {
+    const token = envLookup(config.bearerTokenEnvVar);
+    if (token === undefined || token.length === 0) {
+      throw new KimiError(
+        ErrorCodes.CONFIG_INVALID,
+        `MCP ${config.transport.toUpperCase()} bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`,
+      );
+    }
+    // Strip any case-variant 'authorization' static header before injecting the
+    // bearer; Fetch Headers folds duplicate keys into a comma-joined value,
+    // which produces an invalid auth header rather than letting the bearer win.
+    for (const key of Object.keys(headers)) {
+      if (key.toLowerCase() === 'authorization') {
+        delete headers[key];
+      }
+    }
+    headers['Authorization'] = `Bearer ${token}`;
+  }
+  return Object.keys(headers).length > 0 ? headers : undefined;
+}
+
+export function isRemoteMcpConfig(config: McpServerConfig): config is McpRemoteServerConfig {
+  return config.transport === 'http' || config.transport === 'sse';
+}
+import type { McpRemoteServerConfig, McpServerConfig } from '#/config/schema';
+import { ErrorCodes, KimiError } from '#/errors';
 import { createProxyDispatcher } from '#/utils/proxy';
 import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';
 
diff --git a/packages/agent-core/src/mcp/client-shared.ts b/packages/agent-core/src/mcp/client-shared.ts
index 6c5605ada8..c70508c533 100644
--- a/packages/agent-core/src/mcp/client-shared.ts
+++ b/packages/agent-core/src/mcp/client-shared.ts
@@ -83,11 +83,21 @@ export function toMcpToolDefinition(tool: SdkListedTool): MCPToolDefinition {
  */
 export function toMcpToolResult(result: unknown): MCPToolResult {
   if (typeof result === 'object' && result !== null && 'content' in result) {
-    const typed = result as { content: unknown; isError?: unknown };
+    const typed = result as {
+      content: unknown;
+      isError?: unknown;
+      structuredContent?: unknown;
+      _meta?: unknown;
+    };
     if (Array.isArray(typed.content)) {
       return {
         content: typed.content as MCPToolResult['content'],
         isError: typed.isError === true,
+        structuredContent: typed.structuredContent,
+        _meta:
+          typeof typed._meta === 'object' && typed._meta !== null
+            ? (typed._meta as Record)
+            : undefined,
       };
     }
   }
diff --git a/packages/agent-core/src/mcp/client-sse.ts b/packages/agent-core/src/mcp/client-sse.ts
index c02d0f4a3d..56a61249db 100644
--- a/packages/agent-core/src/mcp/client-sse.ts
+++ b/packages/agent-core/src/mcp/client-sse.ts
@@ -3,6 +3,175 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
 import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
 import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js';
 
+import {
+  buildRequestOptions,
+  KIMI_MCP_CLIENT_NAME,
+  KIMI_MCP_CLIENT_VERSION,
+  toMcpToolDefinition,
+  toMcpToolResult,
+  type UnexpectedCloseListener,
+  type UnexpectedCloseReason,
+} from './client-shared';
+import { buildMcpRemoteHeaders } from './client-remote';
+import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types';
+
+export interface SseMcpClientOptions {
+  readonly clientName?: string;
+  readonly clientVersion?: string;
+  readonly toolCallTimeoutMs?: number;
+  /**
+   * Reads `process.env[name]` by default. Tests can inject a deterministic
+   * lookup function so they do not have to mutate global env.
+   */
+  readonly envLookup?: (name: string) => string | undefined;
+  /**
+   * Lets tests inject a fake `fetch` for the underlying transport.
+   */
+  readonly fetch?: typeof fetch;
+  /**
+   * OAuth client provider attached to the transport. Set only when the server
+   * has no static token configuration; the connection manager wires this in
+   * and surfaces `UnauthorizedError` as a `needs-auth` status.
+   */
+  readonly oauthProvider?: OAuthClientProvider;
+}
+
+/**
+ * Wraps the SDK's deprecated HTTP+SSE transport as a kosong
+ * {@link MCPClient}. This exists for compatibility with older MCP servers;
+ * new remote servers should prefer streamable HTTP.
+ */
+export class SseMcpClient implements MCPClient {
+  private readonly client: Client;
+  private readonly transport: SSEClientTransport;
+  private readonly toolCallTimeoutMs?: number;
+  private started = false;
+  private closed = false;
+  // Mirrors HttpMcpClient: handshake failures surface through connect(), while
+  // post-ready terminal transport errors become unexpected closes.
+  private ready = false;
+  private hooksInstalled = false;
+  private unexpectedCloseListener: UnexpectedCloseListener | undefined;
+  private lastTransportError: Error | undefined;
+  private pendingUnexpectedClose: UnexpectedCloseReason | undefined;
+  private unexpectedCloseFired = false;
+
+  constructor(config: McpServerSseConfig, options: SseMcpClientOptions = {}) {
+    const envLookup = options.envLookup ?? ((name) => process.env[name]);
+    const headers = buildMcpRemoteHeaders(config, envLookup);
+
+    this.transport = new SSEClientTransport(new URL(config.url), {
+      requestInit: headers !== undefined ? { headers } : undefined,
+      fetch: options.fetch,
+      authProvider: options.oauthProvider,
+    });
+    this.client = new Client({
+      name: options.clientName ?? KIMI_MCP_CLIENT_NAME,
+      version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION,
+    });
+    this.toolCallTimeoutMs = options.toolCallTimeoutMs;
+  }
+
+  async connect(): Promise {
+    if (this.closed) {
+      throw new Error('MCP SSE client is closed');
+    }
+    if (this.started) return;
+    this.started = true;
+    this.installTransportHooks();
+    try {
+      await this.client.connect(this.transport);
+    } catch (error) {
+      await this.closeStartedClient();
+      throw error;
+    }
+    if (this.closed) {
+      await this.closeStartedClient();
+      throw new Error('MCP SSE client was closed during startup');
+    }
+    this.ready = true;
+  }
+
+  async close(): Promise {
+    if (this.closed) return;
+    this.closed = true;
+    await this.closeStartedClient();
+  }
+
+  /**
+   * Register a listener for unsolicited terminal transport drops. Brief SSE
+   * stream flaps are left to EventSource's retry loop; terminal HTTP status
+   * errors after startup remove the tools from the agent.
+   */
+  onUnexpectedClose(listener: UnexpectedCloseListener): void {
+    this.unexpectedCloseListener = listener;
+    const pending = this.pendingUnexpectedClose;
+    if (pending !== undefined) {
+      this.pendingUnexpectedClose = undefined;
+      listener(pending);
+    }
+  }
+
+  async listTools(): Promise {
+    const result = await this.client.listTools();
+    return result.tools.map(toMcpToolDefinition);
+  }
+
+  async callTool(
+    name: string,
+    args: Record,
+    signal?: AbortSignal,
+  ): Promise {
+    const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal);
+    const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions);
+    return toMcpToolResult(result);
+  }
+
+  private async closeStartedClient(): Promise {
+    if (!this.started) return;
+    this.started = false;
+    await this.client.close();
+  }
+
+  private installTransportHooks(): void {
+    if (this.hooksInstalled) return;
+    this.hooksInstalled = true;
+    this.client.onclose = () => {
+      if (this.closed) return;
+      if (!this.ready) return;
+      this.fireUnexpectedClose({ error: this.lastTransportError });
+    };
+    this.client.onerror = (error) => {
+      this.lastTransportError = error;
+      if (this.closed) return;
+      if (!this.ready) return;
+      if (isTerminalSseTransportError(error)) {
+        this.fireUnexpectedClose({ error });
+      }
+    };
+  }
+
+  private fireUnexpectedClose(reason: UnexpectedCloseReason): void {
+    if (this.unexpectedCloseFired) return;
+    this.unexpectedCloseFired = true;
+    const listener = this.unexpectedCloseListener;
+    if (listener !== undefined) {
+      listener(reason);
+    } else {
+      this.pendingUnexpectedClose = reason;
+    }
+  }
+}
+
+export function isTerminalSseTransportError(error: Error): boolean {
+  if (error.name === 'UnauthorizedError') return true;
+  return error instanceof SseError && error.code !== undefined;
+}
+import type { McpServerSseConfig } from '#/config/schema';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
+import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js';
+
 import {
   buildRequestOptions,
   KIMI_MCP_CLIENT_NAME,
diff --git a/packages/agent-core/src/mcp/oauth/provider.ts b/packages/agent-core/src/mcp/oauth/provider.ts
index be90113e70..7dd3828ba4 100644
--- a/packages/agent-core/src/mcp/oauth/provider.ts
+++ b/packages/agent-core/src/mcp/oauth/provider.ts
@@ -153,6 +153,29 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
     return this.store.read(`${this.storeKey}${DISCOVERY_SUFFIX}`);
   }
 
+  /**
+   * Drop the persisted DCR client registration when its `redirect_uris` no
+   * longer cover `redirectUri`. Returns true when a stale registration was
+   * dropped.
+   *
+   * The callback listener binds a random port per flow, while a DCR
+   * registration pins the redirect URIs of the flow that created it. Reusing
+   * a registration whose URIs no longer match guarantees an
+   * "invalid redirect URI" rejection at the authorization endpoint — rendered
+   * only in the user's browser, while this client waits for a callback that
+   * never comes. Dropping the registration lets the next `auth()` call
+   * re-register with the current callback URI.
+   */
+  invalidateStaleRegistration(redirectUri: string): boolean {
+    const info = this.clientInformation();
+    if (info === undefined || !('redirect_uris' in info)) return false;
+    const uris = info.redirect_uris;
+    if (!Array.isArray(uris) || uris.length === 0) return false;
+    if (uris.includes(redirectUri)) return false;
+    this.invalidateCredentials('client');
+    return true;
+  }
+
   invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
     if (scope === 'verifier') {
       this._codeVerifier = undefined;
diff --git a/packages/agent-core/src/mcp/oauth/service.ts b/packages/agent-core/src/mcp/oauth/service.ts
index 9b2f807de6..ed7ba626c2 100644
--- a/packages/agent-core/src/mcp/oauth/service.ts
+++ b/packages/agent-core/src/mcp/oauth/service.ts
@@ -126,6 +126,10 @@ export class McpOAuthService {
     }
 
     provider.setRedirectUrl(new URL(callbackServer.redirectUri));
+    // See invalidateStaleRegistration: a reused registration whose redirect
+    // URIs no longer cover this flow's random-port callback would be rejected
+    // at the authorization endpoint with an error only the browser ever sees.
+    provider.invalidateStaleRegistration(callbackServer.redirectUri);
 
     let authorizationUrl: URL | undefined;
     try {
diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts
index 08fe82e9a1..00e64f407a 100644
--- a/packages/agent-core/src/mcp/output.ts
+++ b/packages/agent-core/src/mcp/output.ts
@@ -187,6 +187,39 @@ export async function mcpResultToExecutableOutput(
   }
 
   const wrapped = wrapMediaOnly(converted, qualifiedToolName);
+  // Structured payloads (structuredContent per MCP spec, plus server metadata
+  // in _meta) carry machine-readable contracts such as browser-handoff URLs.
+  // Appended AFTER the media wrap so a media-only result keeps its
+  //  attribution, and BEFORE the text budget so oversized
+  // payloads stay bounded. Literal closing tags inside the serialized
+  // payload are stripped so server data cannot fake an early end of the
+  // block. Protocol-reserved _meta keys are dropped first: those carry
+  // host/protocol plumbing, not model-facing data.
+  const structuredExtras: Record = {};
+  if (result.structuredContent !== undefined) {
+    structuredExtras['structuredContent'] = result.structuredContent;
+  }
+  if (result._meta !== undefined) {
+    const meta = stripReservedMetaKeys(result._meta);
+    if (meta !== undefined) {
+      structuredExtras['_meta'] = meta;
+    }
+  }
+  if (Object.keys(structuredExtras).length > 0) {
+    try {
+      const serialized = JSON.stringify(structuredExtras).replaceAll(
+        '',
+        '',
+      );
+      wrapped.push({
+        type: 'text',
+        text: `\n\n${serialized}\n`,
+      });
+    } catch {
+      // Non-serialisable payloads are dropped rather than failing the call.
+    }
+  }
+
   // Text budget FIRST, on the tool's own text only: captions produced by the
   // compression step below ride the `note` side channel and never compete
   // with a chatty tool's text for the budget — an evicted or mid-string-
@@ -228,6 +261,45 @@ export async function mcpResultToExecutableOutput(
   };
 }
 
+/**
+ * Drop protocol-reserved `_meta` keys before the payload reaches the model.
+ *
+ * Per the MCP spec's `_meta` key-name rules, a key may carry a dot-separated
+ * label prefix terminated by `/`; a prefix is reserved for protocol use when
+ * a `modelcontextprotocol` or `mcp` label is followed by at least one more
+ * label (e.g. `modelcontextprotocol.io/…`, `tools.mcp.com/…` — but not a
+ * vendor namespace like `com.example.mcp/…`). Reserved entries carry
+ * host/protocol plumbing — progress and task wiring, UI component payloads —
+ * that servers do not address to the model, so forwarding them would leak
+ * side-channel data into the conversation. Unprefixed and vendor-prefixed
+ * keys pass through untouched: their semantics belong to the server, and the
+ * host cannot know which of them the model is meant to see.
+ *
+ * Returns `undefined` when nothing survives, so callers can omit the `_meta`
+ * section entirely.
+ */
+function stripReservedMetaKeys(
+  meta: Record,
+): Record | undefined {
+  const out: Record = {};
+  for (const [key, value] of Object.entries(meta)) {
+    if (!isReservedMetaKey(key)) {
+      out[key] = value;
+    }
+  }
+  return Object.keys(out).length > 0 ? out : undefined;
+}
+
+function isReservedMetaKey(key: string): boolean {
+  const slash = key.indexOf('/');
+  if (slash <= 0) return false;
+  const labels = key.slice(0, slash).split('.');
+  return labels.some(
+    (label, i) =>
+      (label === 'modelcontextprotocol' || label === 'mcp') && i < labels.length - 1,
+  );
+}
+
 /**
  * If `parts` contains media but no non-empty text, surround it with
  * `` text tags so the model can attribute the
diff --git a/packages/agent-core/src/mcp/types.ts b/packages/agent-core/src/mcp/types.ts
index dee8cf4eb6..aedb555406 100644
--- a/packages/agent-core/src/mcp/types.ts
+++ b/packages/agent-core/src/mcp/types.ts
@@ -51,6 +51,8 @@ export interface MCPContentBlock {
 export interface MCPToolResult {
   content: MCPContentBlock[];
   isError: boolean;
+  structuredContent?: unknown;
+  _meta?: Record;
 }
 
 /**
diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts
index b22efa71ef..89f2fac1b2 100644
--- a/packages/agent-core/src/plugin/manager.ts
+++ b/packages/agent-core/src/plugin/manager.ts
@@ -64,81 +64,106 @@ export class PluginManager {
   async install(source: string): Promise {
     const resolved = resolveInstallSource(source);
 
-    let normalizedRoot: string;
+    let managedCopy: ManagedPluginCopy | undefined;
     let originalSource: string;
     let sourceType: PluginSource;
     let parsed: ParsedManifestResult;
     let id: string;
     let github: PluginGithubMetadata | undefined;
+    let zipTmpDir: string | undefined;
 
-    if (resolved.kind === 'local-path') {
-      const sourceRoot = await normalizeInstallRoot(resolved.path);
-      originalSource = resolved.path;
-      sourceType = 'local-path';
-      parsed = await parseManifest(sourceRoot);
-      if (parsed.manifest === undefined) {
-        const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
-        throw new Error(`Cannot install plugin at ${sourceRoot}: ${msg}`);
-      }
-      id = normalizePluginId(parsed.manifest.name);
-      normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot);
-      parsed = await parseManifest(normalizedRoot);
-    } else {
-      let zipUrl: string;
-      if (resolved.kind === 'github') {
-        const githubResolution = await resolveGithubSource(resolved);
-        zipUrl = githubResolution.tarballUrl;
-        originalSource = source.trim();
-        sourceType = 'github';
-        github = {
-          owner: resolved.owner,
-          repo: resolved.repo,
-          ref: githubResolution.ref,
-        };
-      } else {
-        zipUrl = resolved.path;
+    try {
+      if (resolved.kind === 'local-path') {
+        const sourceRoot = await normalizeInstallRoot(resolved.path);
         originalSource = resolved.path;
-        sourceType = 'zip-url';
-      }
-      const buffer = await downloadZip(zipUrl);
-      const tmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-'));
-      try {
-        const detectedRoot = await extractZip(buffer, tmpDir);
+        sourceType = 'local-path';
+        parsed = await parseManifest(sourceRoot);
+        if (parsed.manifest === undefined) {
+          const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
+          throw new Error(`Cannot install plugin at ${sourceRoot}: ${msg}`);
+        }
+        id = normalizePluginId(parsed.manifest.name);
+        managedCopy = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot);
+        parsed = await parseManifest(managedCopy.root);
+      } else {
+        let zipUrl: string;
+        if (resolved.kind === 'github') {
+          const githubResolution = await resolveGithubSource(resolved);
+          zipUrl = githubResolution.tarballUrl;
+          originalSource = source.trim();
+          sourceType = 'github';
+          github = {
+            owner: resolved.owner,
+            repo: resolved.repo,
+            ref: githubResolution.ref,
+          };
+        } else {
+          zipUrl = resolved.path;
+          originalSource = resolved.path;
+          sourceType = 'zip-url';
+        }
+        const buffer = await downloadZip(zipUrl);
+        zipTmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-'));
+        const detectedRoot = await extractZip(buffer, zipTmpDir);
         parsed = await parseManifest(detectedRoot);
         if (parsed.manifest === undefined) {
           const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
           throw new Error(`Cannot install plugin from ${originalSource}: ${msg}`);
         }
         id = normalizePluginId(parsed.manifest.name);
-        normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, detectedRoot);
-        parsed = await parseManifest(normalizedRoot);
-      } finally {
-        await rm(tmpDir, { recursive: true, force: true });
+        managedCopy = await copyPluginToManagedRoot(this.kimiHomeDir, id, detectedRoot);
+        parsed = await parseManifest(managedCopy.root);
       }
-    }
 
-    if (parsed.manifest === undefined) {
-      const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
-      throw new Error(`Cannot install plugin at ${normalizedRoot}: ${msg}`);
+      if (parsed.manifest === undefined) {
+        const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
+        throw new Error(`Cannot install plugin at ${managedCopy.root}: ${msg}`);
+      }
+      id = normalizePluginId(parsed.manifest.name);
+      const existing = this.records.get(id);
+      const now = new Date().toISOString();
+      const record = await recordFrom({
+        id,
+        root: managedCopy.root,
+        enabled: existing?.enabled ?? true,
+        installedAt: existing?.installedAt ?? now,
+        updatedAt: now,
+        originalSource,
+        source: sourceType,
+        capabilities: existing?.capabilities,
+        github,
+        parsed,
+      });
+      // Persist from a candidate map, then publish it. Never leave this.records
+      // pointing at a tree that the outer catch rolls back on write failure.
+      const next = new Map(this.records);
+      next.set(id, record);
+      await this.persist(next);
+      this.records = next;
+      await discardPreviousManagedRoot(managedCopy.previousRoot);
+      managedCopy = undefined;
+      return record;
+    } catch (error) {
+      if (managedCopy !== undefined) {
+        try {
+          await rm(managedCopy.root, { recursive: true, force: true });
+          if (managedCopy.previousRoot !== undefined) {
+            await rename(managedCopy.previousRoot, managedCopy.root);
+          }
+        } catch (rollbackError) {
+          throw new AggregateError(
+            [error, rollbackError],
+            'Plugin installation failed and the previous managed copy could not be restored',
+            { cause: error },
+          );
+        }
+      }
+      throw error;
+    } finally {
+      if (zipTmpDir !== undefined) {
+        await rm(zipTmpDir, { recursive: true, force: true });
+      }
     }
-    id = normalizePluginId(parsed.manifest.name);
-    const existing = this.records.get(id);
-    const now = new Date().toISOString();
-    const record = await recordFrom({
-      id,
-      root: normalizedRoot,
-      enabled: existing?.enabled ?? true,
-      installedAt: existing?.installedAt ?? now,
-      updatedAt: now,
-      originalSource,
-      source: sourceType,
-      capabilities: existing?.capabilities,
-      github,
-      parsed,
-    });
-    this.records.set(id, record);
-    await this.persist();
-    return record;
   }
 
   async setEnabled(id: string, enabled: boolean): Promise {
@@ -306,8 +331,8 @@ export class PluginManager {
     return record === undefined ? undefined : recordToInfo(record);
   }
 
-  private async persist(): Promise {
-    const installed: InstalledRecord[] = [...this.records.values()].map((record) => ({
+  private async persist(records: ReadonlyMap = this.records): Promise {
+    const installed: InstalledRecord[] = [...records.values()].map((record) => ({
       id: record.id,
       root: record.root,
       source: record.source,
@@ -355,24 +380,55 @@ async function normalizeInstallRoot(rootPath: string): Promise {
   return resolved;
 }
 
+interface ManagedPluginCopy {
+  readonly root: string;
+  readonly previousRoot?: string;
+}
+
+/**
+ * Publish a plugin into the managed root without deleting the live directory
+ * in-place. On Windows, an MCP child whose cwd is the managed root holds the
+ * directory busy (`EBUSY` on `rmdir`); renaming it aside usually succeeds, and
+ * the previous tree can be deleted later (best-effort) once nothing holds it.
+ */
 async function copyPluginToManagedRoot(
   kimiHomeDir: string,
   id: string,
   sourceRoot: string,
-): Promise {
+): Promise {
   const managedRoot = path.join(kimiHomeDir, 'plugins', 'managed', id);
   const managedDir = path.dirname(managedRoot);
   await mkdir(managedDir, { recursive: true });
   const stagingRoot = await mkdtemp(path.join(managedDir, `${id}-`));
+  const previousRoot = `${stagingRoot}-previous`;
+  let movedPreviousRoot = false;
+  let published = false;
   try {
     await cp(sourceRoot, stagingRoot, { recursive: true });
-    await rm(managedRoot, { recursive: true, force: true });
+    try {
+      await rename(managedRoot, previousRoot);
+      movedPreviousRoot = true;
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+    }
     await rename(stagingRoot, managedRoot);
+    published = true;
+    return {
+      root: await realpath(managedRoot),
+      previousRoot: movedPreviousRoot ? previousRoot : undefined,
+    };
   } catch (error) {
-    await rm(stagingRoot, { recursive: true, force: true });
+    await rm(published ? managedRoot : stagingRoot, { recursive: true, force: true });
+    if (movedPreviousRoot) await rename(previousRoot, managedRoot);
     throw error;
   }
-  return realpath(managedRoot);
+}
+
+async function discardPreviousManagedRoot(previousRoot: string | undefined): Promise {
+  if (previousRoot === undefined) return;
+  // MCP children (or Windows AV) may still hold the old tree; never fail the
+  // install because deferred cleanup could not finish immediately.
+  await rm(previousRoot, { recursive: true, force: true }).catch(() => undefined);
 }
 
 async function recordFrom(input: {
diff --git a/packages/agent-core/src/session/hooks/engine.ts b/packages/agent-core/src/session/hooks/engine.ts
index 8bc0c2401a..562cbcc0c9 100644
--- a/packages/agent-core/src/session/hooks/engine.ts
+++ b/packages/agent-core/src/session/hooks/engine.ts
@@ -50,7 +50,7 @@ export class HookEngine {
     event: string,
     args: HookEngineTriggerArgs = {},
   ): Promise {
-    return blockDecision(event, await this.trigger(event, args));
+    return resolveHookBlockDecision(event, await this.trigger(event, args));
   }
 
   fireAndForgetTrigger(
@@ -206,14 +206,14 @@ function aggregateResults(
   readonly action: 'allow' | 'block';
   readonly reason?: string;
 } {
-  const block = blockDecision(event, results);
+  const block = resolveHookBlockDecision(event, results);
   if (block !== undefined) {
     return { action: 'block', reason: block.reason };
   }
   return { action: 'allow' };
 }
 
-function blockDecision(
+export function resolveHookBlockDecision(
   event: string,
   results: readonly HookResult[],
 ): HookBlockDecision | undefined {
diff --git a/packages/agent-core/src/session/hooks/user-prompt.ts b/packages/agent-core/src/session/hooks/user-prompt.ts
index 1b81c9f615..af85a3df81 100644
--- a/packages/agent-core/src/session/hooks/user-prompt.ts
+++ b/packages/agent-core/src/session/hooks/user-prompt.ts
@@ -12,19 +12,26 @@ export interface RenderedHookResult {
 
 export function renderUserPromptHookResult(
   results: readonly HookResult[] | undefined,
+): RenderedHookResult | undefined {
+  return renderAllowedHookResult('UserPromptSubmit', results);
+}
+
+export function renderAllowedHookResult(
+  event: string,
+  results: readonly HookResult[] | undefined,
 ): RenderedHookResult | undefined {
   const messages =
     results
       ?.filter((result) => result.action !== 'block')
-      ?.map(userPromptHookMessage)
+      ?.map(allowedHookMessage)
       .filter(isNonEmptyString) ??
     [];
   if (messages.length === 0) return undefined;
   const displayMessage = messages.join('\n\n');
   return {
-    event: 'UserPromptSubmit',
+    event,
     message: displayMessage,
-    text: messages.map((message) => renderHookResult('UserPromptSubmit', message)).join('\n'),
+    text: messages.map((message) => renderHookResult(event, message)).join('\n'),
   };
 }
 
@@ -51,7 +58,7 @@ export function renderUserPromptHookBlockResult(
   };
 }
 
-function userPromptHookMessage(result: HookResult): string | undefined {
+function allowedHookMessage(result: HookResult): string | undefined {
   if (result.timedOut === true || (result.exitCode !== undefined && result.exitCode !== 0)) {
     return undefined;
   }
diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts
index 2638041c8b..a715fe5b61 100644
--- a/packages/agent-core/src/session/index.ts
+++ b/packages/agent-core/src/session/index.ts
@@ -781,7 +781,23 @@ export class Session {
       { additionalDirs: this.additionalDirs },
     );
     const subagentNames = Object.keys(this.agentCatalog.delegatableSubagents(profile.name));
-    agent.useProfile(profile, context, this.options.kimiHomeDir, subagentNames);
+
+    // Merge global [tools].disabled config into the profile's disallowedTools.
+    // v2 has a dedicated toolPolicy service for this; v1 reads it from the
+    // raw config (unknown sections land in `config.raw`). #2534.
+    const toolsDisabled = this.readToolsDisabled();
+    const effectiveProfile =
+      toolsDisabled.length > 0
+        ? {
+            ...profile,
+            disallowedTools: [
+              ...(profile.disallowedTools ?? []),
+              ...toolsDisabled,
+            ],
+          }
+        : profile;
+
+    agent.useProfile(effectiveProfile, context, this.options.kimiHomeDir, subagentNames);
     const { agentsMdWarning } = context;
     if (agentsMdWarning !== undefined) {
       this.agentsMdWarning = agentsMdWarning;
@@ -794,6 +810,22 @@ export class Session {
     }
   }
 
+  /**
+   * Read the `[tools].disabled` array from the raw config. v1's
+   * KimiConfigSchema does not have a typed `tools` section (v2 uses a
+   * dedicated toolPolicy service), so unknown sections land in
+   * `config.raw`. This extracts the disabled tool names safely. #2534.
+   */
+  readToolsDisabled(): string[] {
+    const raw = this.kimiConfig?.raw;
+    if (!raw) return [];
+    const toolsSection = raw['tools'];
+    if (typeof toolsSection !== 'object' || toolsSection === null) return [];
+    const disabled = (toolsSection as Record)['disabled'];
+    if (!Array.isArray(disabled)) return [];
+    return disabled.filter((v): v is string => typeof v === 'string');
+  }
+
   async getSessionWarnings(): Promise {
     const warnings: SessionWarning[] = [];
     const agentsMdWarning = await this.computeAgentsMdWarning();
diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts
index aa4513c237..a4b2860f58 100644
--- a/packages/agent-core/src/session/subagent-host.ts
+++ b/packages/agent-core/src/session/subagent-host.ts
@@ -476,7 +476,21 @@ export class SessionSubagentHost {
     const subagentNames = Object.keys(
       this.session.agentCatalog.delegatableSubagents(profile.name),
     );
-    child.useProfile(profile, context, this.session.options.kimiHomeDir, subagentNames);
+
+    // Apply global [tools].disabled to subagents too. #2534.
+    const toolsDisabled = this.session.readToolsDisabled();
+    const effectiveProfile =
+      toolsDisabled.length > 0
+        ? {
+            ...profile,
+            disallowedTools: [
+              ...(profile.disallowedTools ?? []),
+              ...toolsDisabled,
+            ],
+          }
+        : profile;
+
+    child.useProfile(effectiveProfile, context, this.session.options.kimiHomeDir, subagentNames);
     child.tools.inheritUserTools(parent.tools);
   }
 
diff --git a/packages/agent-core/src/tools/args-validator.ts b/packages/agent-core/src/tools/args-validator.ts
index ce967ccb0b..10ffcb99a0 100644
--- a/packages/agent-core/src/tools/args-validator.ts
+++ b/packages/agent-core/src/tools/args-validator.ts
@@ -78,12 +78,75 @@ export function compileToolArgsValidator(schema: Record): ToolA
   return ajvFor(schema).compile(schema) as ToolArgsValidator;
 }
 
+const TYPE_MISMATCH_RE = /^must be (integer|number|boolean|array|object)$/;
+
+function coerceStringValue(value: string): JsonType {
+  const trimmed = value.trim();
+  if (trimmed === '') return value;
+  if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
+    try {
+      return JSON.parse(trimmed) as JsonType;
+    } catch {
+      return value;
+    }
+  }
+  if (trimmed === 'true') return true;
+  if (trimmed === 'false') return false;
+  const num = Number(trimmed);
+  return Number.isFinite(num) ? num : value;
+}
+
+function setAtPath(obj: JsonObject, path: string, value: JsonType): void {
+  const keys = path.split('/').filter(Boolean);
+  if (keys.length === 0) return;
+  let current: unknown = obj;
+  for (let i = 0; i < keys.length - 1; i++) {
+    const key = keys[i]!.replace(/~1/g, '/').replace(/~0/g, '~');
+    if (typeof current !== 'object' || current === null) return;
+    current = (current as Record)[key];
+  }
+  if (typeof current === 'object' && current !== null) {
+    const lastKey = keys[keys.length - 1]!.replace(/~1/g, '/').replace(/~0/g, '~');
+    (current as Record)[lastKey] = value;
+  }
+}
+
+function getAtPath(obj: JsonObject, path: string): unknown {
+  const keys = path.split('/').filter(Boolean);
+  let current: unknown = obj;
+  for (const key of keys) {
+    if (typeof current !== 'object' || current === null) return undefined;
+    current = (current as Record)[key.replace(/~1/g, '/').replace(/~0/g, '~')];
+  }
+  return current;
+}
+
 export function validateToolArgs(validator: ToolArgsValidator, args: JsonType): string | null {
-  const valid = validator(args);
-  if (valid) {
+  if (validator(args)) {
     return null;
   }
 
+  if (typeof args === 'object' && args !== null) {
+    const typeErrors = (validator.errors ?? []).filter(
+      (e) => e.keyword === 'type' && TYPE_MISMATCH_RE.test(e.message ?? ''),
+    );
+    let mutated = false;
+    for (const error of typeErrors) {
+      const value = getAtPath(args as JsonObject, error.instancePath);
+      if (typeof value !== 'string') continue;
+      const coerced = coerceStringValue(value);
+      if (coerced !== value) {
+        setAtPath(args as JsonObject, error.instancePath, coerced);
+        mutated = true;
+      }
+    }
+    if (mutated) {
+      if (validator(args)) {
+        return null;
+      }
+    }
+  }
+
   const errors = validator.errors ?? [];
   if (errors.length === 0) {
     return 'Tool parameter validation failed';
diff --git a/packages/agent-core/src/tools/builtin/file/edit.md b/packages/agent-core/src/tools/builtin/file/edit.md
index f928fa22fb..808e55d826 100644
--- a/packages/agent-core/src/tools/builtin/file/edit.md
+++ b/packages/agent-core/src/tools/builtin/file/edit.md
@@ -8,6 +8,8 @@ Perform exact replacements in existing files.
 - If `old_string` is ambiguous, add surrounding context. Use `replace_all` only when every occurrence should change — for example, renaming a symbol throughout the file.
 - Multiple Edit calls may run in one response only when they do not target the same file.
 - DO NOT issue consecutive Edit calls on the same file. A previous Edit can invalidate a later Edit's `old_string`, causing `old_string not found`. Read the file again before the next Edit.
+- After Edit fails with `old_string not found`, Read the whole file (or a large enough region covering the edit) once before retrying. Do not loop Edit → short 30–50 line Read → Edit.
+- Never replace a multi-line span with an empty `new_string` unless you intend to delete it — set `allow_large_delete=true` for that intentional deletion. Prefer replacing with the real new content.
 - A write lock serializes same-file edits in response order, but serialization does not make stale `old_string` valid.
 - For pure CRLF files, Read shows LF; use LF in `old_string` and `new_string`, and Edit writes CRLF back.
 - For mixed endings or lone carriage returns, Read shows carriage returns as \r; include actual \r escapes in those positions.
diff --git a/packages/agent-core/src/tools/builtin/file/edit.ts b/packages/agent-core/src/tools/builtin/file/edit.ts
index ed3aa8c6fa..4673e758a3 100644
--- a/packages/agent-core/src/tools/builtin/file/edit.ts
+++ b/packages/agent-core/src/tools/builtin/file/edit.ts
@@ -45,10 +45,49 @@ export const EditInputSchema = z.object({
     .boolean()
     .optional()
     .describe('Set true only when every occurrence of old_string should be replaced.'),
+  allow_large_delete: z
+    .boolean()
+    .optional()
+    .describe(
+      'Set true only when intentionally deleting a multi-line span with an empty (or whitespace-only) new_string. Omit for normal edits.',
+    ),
 });
 
 export type EditInput = z.Infer;
 
+/** Multi-line empty replacements without an explicit opt-in are refused (see #2427). */
+const LARGE_DELETE_MIN_OLD_LINES = 3;
+
+export function countEditLines(text: string): number {
+  if (text.length === 0) return 0;
+  let lines = 1;
+  for (let i = 0; i < text.length; i++) {
+    if (text.charCodeAt(i) === 10) lines++;
+  }
+  return lines;
+}
+
+export function isOversizedEmptyDeletion(oldString: string, newString: string): boolean {
+  return newString.trim().length === 0 && countEditLines(oldString) >= LARGE_DELETE_MIN_OLD_LINES;
+}
+
+function oversizedDeletionMessage(path: string): string {
+  return (
+    `Refusing a multi-line deletion in ${path}: new_string is empty (or whitespace-only) while ` +
+    `old_string spans ${String(LARGE_DELETE_MIN_OLD_LINES)}+ lines. Read the file again, then either ` +
+    `replace with the intended new content, delete fewer lines at a time, or set allow_large_delete=true ` +
+    `if you intentionally want to remove that entire span.`
+  );
+}
+
+function notFoundMessage(path: string): string {
+  return (
+    `old_string not found in ${path}, the file contents may be out of date. ` +
+    `Read the full file (or a large enough region covering the edit) with the Read tool before retrying — ` +
+    `do not keep retrying Edit from a short 30–50 line window.\n`
+  );
+}
+
 function replaceOnceLiteral(content: string, oldString: string, newString: string): string {
   const index = content.indexOf(oldString);
   if (index === -1) return content;
@@ -100,6 +139,13 @@ export class EditTool implements BuiltinTool {
       };
     }
 
+    if (
+      args.allow_large_delete !== true &&
+      isOversizedEmptyDeletion(args.old_string, args.new_string)
+    ) {
+      return { isError: true, output: oversizedDeletionMessage(args.path) };
+    }
+
     try {
       const raw = await this.kaos.readText(safePath);
       const modelView = toModelTextView(raw);
@@ -117,8 +163,7 @@ export class EditTool implements BuiltinTool {
         }
 
         if (count === 0) {
-          return { isError: true, output: `old_string not found in ${args.path}, the file contents may be out of date. Please use the Read Tool to reload the content.
-` };
+          return { isError: true, output: notFoundMessage(args.path) };
         }
         if (count > 1) {
           return {
@@ -140,8 +185,7 @@ export class EditTool implements BuiltinTool {
       const parts = content.split(args.old_string);
       const replacementCount = parts.length - 1;
       if (replacementCount === 0) {
-        return { isError: true, output: `old_string not found in ${args.path}, the file contents may be out of date. Please use the Read Tool to reload the content.
-` };
+        return { isError: true, output: notFoundMessage(args.path) };
       }
 
       const newContent = parts.join(args.new_string);
diff --git a/packages/agent-core/test/agent/background/manager.test.ts b/packages/agent-core/test/agent/background/manager.test.ts
index b1c16cdb6c..8f4aad4329 100644
--- a/packages/agent-core/test/agent/background/manager.test.ts
+++ b/packages/agent-core/test/agent/background/manager.test.ts
@@ -424,6 +424,24 @@ describe('BackgroundManager', () => {
     expect(await manager.readOutput(taskId)).toContain('captured output');
   });
 
+  it('keeps the task running when the process exits before stdout ends', async () => {
+    const { manager } = createBackgroundManager();
+    const stdout = new PassThrough();
+    const proc: KaosProcess = {
+      ...immediateProcess(0),
+      stdout,
+    };
+    const taskId = registerProcess(manager, proc, 'echo late output', 'late output test');
+
+    await Promise.resolve();
+    expect(manager.getTask(taskId)).toMatchObject({ status: 'running' });
+
+    stdout.end('late output\n');
+
+    await expect(manager.wait(taskId)).resolves.toMatchObject({ status: 'completed' });
+    expect(await manager.readOutput(taskId)).toContain('late output');
+  });
+
   it('fails process tasks when output capture errors after successful exit', async () => {
     const { manager } = createBackgroundManager();
     const taskId = registerProcess(
diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts
index 6ebf967680..48bb183461 100644
--- a/packages/agent-core/test/agent/permission.test.ts
+++ b/packages/agent-core/test/agent/permission.test.ts
@@ -981,13 +981,10 @@ describe('Simple permission policy direct behavior', () => {
 
 describe('PreToolUse permission policy', () => {
   it('blocks before approval and records the hook policy decision', async () => {
-    const triggerBlock = vi.fn(async () => ({
-      block: true,
-      reason: 'blocked by hook',
-    }));
+    const trigger = vi.fn(async () => [{ action: 'block' as const, reason: 'blocked by hook' }]);
     const { manager, requestApproval, telemetryTrack } = makePermissionManager(
       async () => ({ decision: 'approved' }),
-      { hooks: { triggerBlock } as unknown as Agent['hooks'] },
+      { hooks: { trigger } as unknown as Agent['hooks'] },
     );
 
     await expect(manager.beforeToolCall(hookContext({ id: 'call_hook_block' }))).resolves
@@ -997,7 +994,7 @@ describe('PreToolUse permission policy', () => {
       });
 
     expect(requestApproval).not.toHaveBeenCalled();
-    expect(triggerBlock).toHaveBeenCalledWith('PreToolUse', {
+    expect(trigger).toHaveBeenCalledWith('PreToolUse', {
       matcherValue: 'Bash',
       signal: expect.any(AbortSignal),
       inputData: {
@@ -1015,13 +1012,12 @@ describe('PreToolUse permission policy', () => {
   });
 
   it.each(['auto', 'yolo'] as const)('runs before %s mode bypass', async (mode) => {
-    const triggerBlock = vi.fn(async () => ({
-      block: true,
-      reason: `${mode} hook block`,
-    }));
+    const trigger = vi.fn(async () => [
+      { action: 'block' as const, reason: `${mode} hook block` },
+    ]);
     const { manager, requestApproval, telemetryTrack } = makePermissionManager(
       async () => ({ decision: 'approved' }),
-      { hooks: { triggerBlock } as unknown as Agent['hooks'] },
+      { hooks: { trigger } as unknown as Agent['hooks'] },
     );
     manager.setMode(mode);
 
@@ -1043,10 +1039,10 @@ describe('PreToolUse permission policy', () => {
   });
 
   it('continues through later policies when the hook does not block', async () => {
-    const triggerBlock = vi.fn(async () => undefined);
+    const trigger = vi.fn(async () => []);
     const { manager, requestApproval, telemetryTrack } = makePermissionManager(
       async () => ({ decision: 'approved' }),
-      { hooks: { triggerBlock } as unknown as Agent['hooks'] },
+      { hooks: { trigger } as unknown as Agent['hooks'] },
     );
 
     await expect(manager.beforeToolCall(hookContext({ id: 'call_hook_allow' }))).resolves
@@ -1064,13 +1060,12 @@ describe('PreToolUse permission policy', () => {
   });
 
   it('passes an empty hook input object for non-plain arguments', async () => {
-    const triggerBlock = vi.fn(async () => ({
-      block: true,
-      reason: 'array args blocked',
-    }));
+    const trigger = vi.fn(async () => [
+      { action: 'block' as const, reason: 'array args blocked' },
+    ]);
     const { manager } = makePermissionManager(
       async () => ({ decision: 'approved' }),
-      { hooks: { triggerBlock } as unknown as Agent['hooks'] },
+      { hooks: { trigger } as unknown as Agent['hooks'] },
     );
 
     await manager.beforeToolCall(
@@ -1081,7 +1076,7 @@ describe('PreToolUse permission policy', () => {
       }),
     );
 
-    expect(triggerBlock).toHaveBeenCalledWith(
+    expect(trigger).toHaveBeenCalledWith(
       'PreToolUse',
       expect.objectContaining({
         inputData: {
diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts
index 72859d428e..f8b2779522 100644
--- a/packages/agent-core/test/agent/records/index.test.ts
+++ b/packages/agent-core/test/agent/records/index.test.ts
@@ -285,6 +285,83 @@ describe('AgentRecords persistence metadata', () => {
     expect(names).not.toContain('Write');
   });
 
+  it('replays a v2 profile.bind record as config.update + tools.set_active_tools', async () => {
+    const persistence = new InMemoryAgentRecordPersistence([
+      // v2-engine wires are stamped with protocol 1.5.
+      { type: 'metadata', protocol_version: '1.5', created_at: 1 },
+      {
+        type: 'profile.bind',
+        modelAlias: 'mock-model',
+        profileName: 'coding',
+        thinkingEffort: 'off',
+        systemPrompt: 'You are a v2 coding agent.',
+        activeToolNames: ['Read', 'Write', 'Bash'],
+        disallowedTools: ['Write'],
+        subagents: ['explore'],
+      } as AgentRecord,
+    ]);
+    const { agent } = testAgent({ persistence });
+
+    await agent.records.replay();
+
+    expect(agent.config.modelAlias).toBe('mock-model');
+    expect(agent.config.profileName).toBe('coding');
+    expect(agent.config.systemPrompt).toBe('You are a v2 coding agent.');
+    expect(agent.config.subagentNames).toEqual(['explore']);
+    expect(agent.replayBuilder.buildResult().map((record) => record.type)).not.toContain(
+      'config_updated',
+    );
+    const names = agent.tools.loopTools.map((tool) => tool.name);
+    expect(names).toContain('Read');
+    expect(names).toContain('Bash');
+    expect(names).not.toContain('Write');
+  });
+
+  it('skips a profile.bind record without activeToolNames so the profile fallback still fires', async () => {
+    const persistence = new InMemoryAgentRecordPersistence([
+      { type: 'metadata', protocol_version: '1.5', created_at: 1 },
+      // v2's "every tool active" binding: no allowlist to restore. The record
+      // must be ignored wholesale so the session-level default-profile
+      // fallback (gated on an empty replayed system prompt) keeps firing.
+      {
+        type: 'profile.bind',
+        modelAlias: 'mock-model',
+        systemPrompt: 'You are a v2 agent.',
+      } as AgentRecord,
+      { type: 'goal.create', goalId: 'g1', objective: 'do work' } as AgentRecord,
+    ]);
+    const { agent } = testAgent({ persistence });
+
+    await agent.records.replay();
+
+    expect(agent.config.systemPrompt).toBe('');
+    // Replay continued past the skipped record.
+    expect(agent.goal.getGoal().goal?.goalId).toBe('g1');
+  });
+
+  it('replays a v2 tools.reset_active_tools record as a no-op', async () => {
+    const persistence = new InMemoryAgentRecordPersistence([
+      { type: 'metadata', protocol_version: '1.5', created_at: 1 },
+      {
+        type: 'tools.set_active_tools',
+        names: ['Read'],
+      } as AgentRecord,
+      { type: 'tools.reset_active_tools' } as AgentRecord,
+      { type: 'goal.create', goalId: 'g1', objective: 'do work' } as AgentRecord,
+    ]);
+    const { agent } = testAgent({ persistence });
+    agent.config.update({ modelAlias: 'mock-model' });
+
+    await agent.records.replay();
+
+    // v1 has no "all tools" state to restore; the earlier restriction stays
+    // (fails closed) and replay continues past the record.
+    const names = agent.tools.loopTools.map((tool) => tool.name);
+    expect(names).toContain('Read');
+    expect(names).not.toContain('Write');
+    expect(agent.goal.getGoal().goal?.goalId).toBe('g1');
+  });
+
   it('restores goal.* records during replay', async () => {
     const persistence = new InMemoryAgentRecordPersistence([
       { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 },
diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts
index b2433e4da7..9ac5dee17d 100644
--- a/packages/agent-core/test/agent/tool.test.ts
+++ b/packages/agent-core/test/agent/tool.test.ts
@@ -66,6 +66,37 @@ describe('Agent tools', () => {
     expect(JSON.stringify(ctx.agent.context.data().history)).toContain('blocked by PreToolUse');
   });
 
+  it('appends successful PreToolUse stdout to model context after the tool result', async () => {
+    const hookEngine = new HookEngine([
+      {
+        event: 'PreToolUse',
+        matcher: 'Bash',
+        command: 'node -e "process.stdout.write(\'UNTRUSTED-CONTENT-MARKER\')"',
+      },
+    ]);
+    const ctx = testAgent({
+      kaos: createCommandKaos('tool output'),
+      hookEngine,
+    });
+    ctx.configure({ tools: ['Bash'] });
+    await ctx.rpc.setPermission({ mode: 'auto' });
+
+    ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
+    ctx.mockNextResponse({ type: 'text', text: 'The command completed.' });
+    await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run Bash' }] });
+
+    await ctx.untilTurnEnd();
+
+    expect(ctx.llmCalls).toHaveLength(2);
+    const history = ctx.llmCalls[1]!.history;
+    const toolResultIndex = history.findIndex((message) => message.role === 'tool');
+    const hookResultIndex = history.findIndex((message) =>
+      JSON.stringify(message).includes('UNTRUSTED-CONTENT-MARKER'),
+    );
+    expect(hookResultIndex).toBeGreaterThan(toolResultIndex);
+    expect(history[hookResultIndex]?.role).toBe('user');
+  });
+
   it('emits PostToolUse after successful tools', async () => {
     const triggered: Array<[string, string, number]> = [];
     const hookEngine = new HookEngine(
diff --git a/packages/agent-core/test/mcp/auth-tool.test.ts b/packages/agent-core/test/mcp/auth-tool.test.ts
index 0025a4ab30..824377d4b0 100644
--- a/packages/agent-core/test/mcp/auth-tool.test.ts
+++ b/packages/agent-core/test/mcp/auth-tool.test.ts
@@ -66,14 +66,18 @@ describe('createMcpAuthTool', () => {
     expect(final.output).toMatch(/authenticated successfully/);
     expect(reconnectCalls).toBe(1);
     expect(updates.some((u) => u.text?.includes('https://example.com/authorize'))).toBe(true);
-    expect(updates).toContainEqual({
-      kind: 'custom',
-      customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE,
-      customData: {
-        serverName: 'notion',
-        authorizationUrl: 'https://example.com/authorize?state=abc',
-      },
+    const authUpdate = updates.find(
+      (u) => u.kind === 'custom' && u.customKind === MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE,
+    );
+    expect(authUpdate?.customData).toMatchObject({
+      serverName: 'notion',
+      authorizationUrl: 'https://example.com/authorize?state=abc',
     });
+    // The deadline is absolute (now + wait timeout), so hosts never mirror
+    // the engine-side constant.
+    const { expiresAt } = authUpdate?.customData as { expiresAt?: number };
+    expect(expiresAt).toBeGreaterThan(Date.now());
+    expect(expiresAt).toBeLessThanOrEqual(Date.now() + 15 * 60 * 1000);
   });
 
   it('falls through to reconnect when the provider reports already-authorized', async () => {
diff --git a/packages/agent-core/test/mcp/client-sse.test.ts b/packages/agent-core/test/mcp/client-sse.test.ts
index 8a3c900f1f..bd7417edbe 100644
--- a/packages/agent-core/test/mcp/client-sse.test.ts
+++ b/packages/agent-core/test/mcp/client-sse.test.ts
@@ -140,3 +140,145 @@ describe('SseMcpClient', () => {
     expect(isTerminalSseTransportError(new Error('fetch failed'))).toBe(false);
   });
 });
+import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
+import { SseError } from '@modelcontextprotocol/sdk/client/sse.js';
+import { afterEach, describe, expect, it } from 'vitest';
+import { z } from 'zod';
+
+import { SseMcpClient, isTerminalSseTransportError } from '../../src/mcp/client-sse';
+
+const cleanups: Array<() => Promise | void> = [];
+
+afterEach(async () => {
+  for (const cleanup of cleanups.splice(0)) {
+    await cleanup();
+  }
+});
+
+async function startInProcessSseMcpServer(opts?: {
+  authToken?: string;
+}): Promise<{ url: string; close: () => Promise }> {
+  const transports = new Map();
+  const httpServer: Server = createServer((req, res) => {
+    if (opts?.authToken !== undefined) {
+      const auth = req.headers['authorization'];
+      if (auth !== `Bearer ${opts.authToken}`) {
+        res.writeHead(401, { 'content-type': 'text/plain' });
+        res.end('unauthorized');
+        return;
+      }
+    }
+
+    const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+    if (req.method === 'GET' && url.pathname === '/mcp') {
+      const mcpServer = new McpServer({ name: 'mock-sse', version: '0.0.1' });
+      mcpServer.registerTool(
+        'echo',
+        { description: 'Echoes text', inputSchema: { text: z.string() } },
+        ({ text }) => ({ content: [{ type: 'text', text }] }),
+      );
+      const transport = new SSEServerTransport('/messages', res);
+      transports.set(transport.sessionId, transport);
+      transport.onclose = () => {
+        transports.delete(transport.sessionId);
+      };
+      void mcpServer.connect(transport);
+      return;
+    }
+
+    if (req.method === 'POST' && url.pathname === '/messages') {
+      const sessionId = url.searchParams.get('sessionId');
+      const transport = sessionId === null ? undefined : transports.get(sessionId);
+      if (transport === undefined) {
+        res.writeHead(404).end('Session not found');
+        return;
+      }
+      void transport.handlePostMessage(req, res);
+      return;
+    }
+
+    res.writeHead(404).end('not found');
+  });
+
+  await new Promise((resolve) => {
+    httpServer.listen(0, '127.0.0.1', resolve);
+  });
+  const port = (httpServer.address() as AddressInfo).port;
+
+  return {
+    url: `http://127.0.0.1:${port}/mcp`,
+    async close() {
+      await Promise.all([...transports.values()].map((transport) => transport.close()));
+      await new Promise((resolve, reject) => {
+        httpServer.close((err) => {
+          if (err) {
+            reject(err);
+            return;
+          }
+          resolve();
+        });
+      });
+    },
+  };
+}
+
+describe('SseMcpClient', () => {
+  it('connects, lists tools, and round-trips a call over real SSE', async () => {
+    const server = await startInProcessSseMcpServer();
+    cleanups.push(server.close);
+
+    const client = new SseMcpClient({ transport: 'sse', url: server.url });
+    try {
+      await client.connect();
+      const tools = await client.listTools();
+      expect(tools.map((t) => t.name)).toEqual(['echo']);
+
+      const result = await client.callTool('echo', { text: 'hello sse' });
+      expect(result.isError).toBe(false);
+      expect(result.content).toEqual([{ type: 'text', text: 'hello sse' }]);
+    } finally {
+      await client.close();
+    }
+  }, 15000);
+
+  it('forwards bearer token from envLookup on the SSE and POST requests', async () => {
+    const server = await startInProcessSseMcpServer({ authToken: 'good-token' });
+    cleanups.push(server.close);
+
+    const client = new SseMcpClient(
+      {
+        transport: 'sse',
+        url: server.url,
+        bearerTokenEnvVar: 'EXAMPLE_TOKEN',
+      },
+      { envLookup: (name) => (name === 'EXAMPLE_TOKEN' ? 'good-token' : undefined) },
+    );
+    try {
+      await client.connect();
+      const result = await client.callTool('echo', { text: 'with auth' });
+      expect(result.content).toEqual([{ type: 'text', text: 'with auth' }]);
+    } finally {
+      await client.close();
+    }
+  }, 15000);
+
+  it('classifies terminal SSE transport errors without treating reconnect flaps as terminal', () => {
+    const unauthorized = new Error('Unauthorized');
+    unauthorized.name = 'UnauthorizedError';
+    expect(isTerminalSseTransportError(unauthorized)).toBe(true);
+    expect(
+      isTerminalSseTransportError(
+        new SseError(
+          204,
+          'Server sent HTTP 204',
+          {} as ConstructorParameters[2],
+        ),
+      ),
+    ).toBe(true);
+    expect(isTerminalSseTransportError(new Error('fetch failed'))).toBe(false);
+  });
+});
diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts
index 754630b593..a5e583be36 100644
--- a/packages/agent-core/test/mcp/config-loader.test.ts
+++ b/packages/agent-core/test/mcp/config-loader.test.ts
@@ -305,6 +305,28 @@ describe('loadMcpServers', () => {
     });
   });
 
+  it('loads explicit SSE server config', async () => {
+    const home = makeTempDir();
+    const cwd = makeTempDir();
+    await writeJson(join(home, 'mcp.json'), {
+      mcpServers: {
+        legacy: {
+          transport: 'sse',
+          url: 'https://mcp.example.com/sse',
+          headers: { 'X-Tenant': 'kimi' },
+          bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
+        },
+      },
+    });
+    const servers = await loadMcpServers({ cwd, homeDir: home });
+    expect(servers['legacy']).toEqual({
+      transport: 'sse',
+      url: 'https://mcp.example.com/sse',
+      headers: { 'X-Tenant': 'kimi' },
+      bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
+    });
+  });
+
   it('honors KIMI_CODE_HOME env var when homeDir is not supplied', async () => {
     const home = makeTempDir();
     const cwd = makeTempDir();
diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts
index ba7a361ff3..20219f346a 100644
--- a/packages/agent-core/test/mcp/connection-manager.test.ts
+++ b/packages/agent-core/test/mcp/connection-manager.test.ts
@@ -155,6 +155,25 @@ describe('McpConnectionManager', () => {
     }
   });
 
+  it('marks SSE servers failed when configured bearer token env var is missing', async () => {
+    const cm = new McpConnectionManager({ envLookup: () => undefined });
+    try {
+      await cm.connectAll({
+        legacy: {
+          transport: 'sse',
+          url: 'https://example.invalid/sse',
+          bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
+        },
+      });
+      const entry = cm.get('legacy');
+      expect(entry?.transport).toBe('sse');
+      expect(entry?.status).toBe('failed');
+      expect(entry?.error).toContain('"LEGACY_MCP_TOKEN" is not set or is empty');
+    } finally {
+      await cm.shutdown();
+    }
+  });
+
   it('marks disabled servers without attempting a connection', async () => {
     const cm = new McpConnectionManager();
     try {
@@ -652,6 +671,47 @@ describe('McpConnectionManager', () => {
     }
   }, 15000);
 
+  it('flips SSE servers into needs-auth when the server returns 401 and no static token is set', async () => {
+    const server: HttpServer = createHttpServer((_req, res) => {
+      res.writeHead(401, {
+        'content-type': 'text/plain',
+        'www-authenticate': 'Bearer realm="mcp", resource_metadata="http://x/.well-known/oauth-protected-resource"',
+      });
+      res.end('unauthorized');
+    });
+    await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+    const port = (server.address() as HttpAddress).port;
+    const storeDir = await mkdtemp(join(tmpdir(), 'kimi-mcp-oauth-sse-cm-'));
+    const oauthService = new McpOAuthService({ store: new JsonFileStore(storeDir) });
+    const cm = new McpConnectionManager({ oauthService });
+    try {
+      await cm.connectAll({
+        legacy: {
+          transport: 'sse',
+          url: `http://127.0.0.1:${port}/sse`,
+          startupTimeoutMs: 5_000,
+        },
+      });
+      const entry = cm.get('legacy');
+      expect(entry?.transport).toBe('sse');
+      expect(entry?.status).toBe('needs-auth');
+      expect(entry?.error).toContain('run /mcp-config login legacy');
+      expect(entry?.toolCount).toBe(0);
+    } finally {
+      await cm.shutdown();
+      await new Promise((resolve, reject) => {
+        server.close((err) => {
+          if (err) {
+            reject(err);
+            return;
+          }
+          resolve();
+        });
+      });
+      await rm(storeDir, { recursive: true, force: true });
+    }
+  }, 15000);
+
   it('flips cached OAuth credentials that require reauth into needs-auth', async () => {
     const server: HttpServer = createHttpServer((req, res) => {
       if (req.url === '/token') {
diff --git a/packages/agent-core/test/mcp/oauth-store.test.ts b/packages/agent-core/test/mcp/oauth-store.test.ts
index bba389755a..def46df4dc 100644
--- a/packages/agent-core/test/mcp/oauth-store.test.ts
+++ b/packages/agent-core/test/mcp/oauth-store.test.ts
@@ -164,3 +164,47 @@ function token(accessToken: string): OAuthTokens {
     token_type: 'Bearer',
   };
 }
+
+describe('McpOAuthClientProvider.invalidateStaleRegistration', () => {
+  let dir: string;
+
+  beforeEach(async () => {
+    dir = await mkdtemp(join(tmpdir(), 'kimi-mcp-oauth-stale-'));
+  });
+  afterEach(async () => {
+    await rm(dir, { recursive: true, force: true });
+  });
+
+  function makeProvider() {
+    return new McpOAuthClientProvider({
+      serverName: 'srv',
+      serverUrl: 'https://mcp.example.com/mcp',
+      store: new JsonFileStore(dir),
+    });
+  }
+
+  it('drops a registration whose redirect_uris miss the current callback', () => {
+    const provider = makeProvider();
+    provider.saveClientInformation({
+      client_id: 'c1',
+      redirect_uris: ['http://127.0.0.1:11111/callback'],
+    });
+    expect(provider.invalidateStaleRegistration('http://127.0.0.1:22222/callback')).toBe(true);
+    expect(provider.clientInformation()).toBeUndefined();
+  });
+
+  it('keeps a registration that still covers the callback URI', () => {
+    const provider = makeProvider();
+    provider.saveClientInformation({
+      client_id: 'c1',
+      redirect_uris: ['http://127.0.0.1:11111/callback'],
+    });
+    expect(provider.invalidateStaleRegistration('http://127.0.0.1:11111/callback')).toBe(false);
+    expect(provider.clientInformation()).toMatchObject({ client_id: 'c1' });
+  });
+
+  it('is a no-op without a stored registration', () => {
+    const provider = makeProvider();
+    expect(provider.invalidateStaleRegistration('http://127.0.0.1:11111/callback')).toBe(false);
+  });
+});
diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts
index 19023b0b54..9fca2499f2 100644
--- a/packages/agent-core/test/mcp/output.test.ts
+++ b/packages/agent-core/test/mcp/output.test.ts
@@ -264,6 +264,96 @@ describe('mcpResultToExecutableOutput', () => {
     expect(out).toEqual({ output: 'oops', isError: true });
   });
 
+  test('surfaces structuredContent and _meta as a serialized mcp-structured-result block', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        structuredContent: { foo: 1 },
+        _meta: { bar: 2 },
+      },
+      'mcp__s__t',
+    );
+    const parts = out.output as ContentPart[];
+    const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
+    expect(joined).toContain('');
+    expect(joined).toContain('"structuredContent":{"foo":1}');
+    expect(joined).toContain('"_meta":{"bar":2}');
+    expect(out.isError).toBe(false);
+  });
+
+  test('keeps the mcp_tool_result wrap when a media-only result carries structuredContent', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'image', data: 'AAA', mimeType: 'image/png' }],
+        isError: false,
+        structuredContent: { foo: 1 },
+      },
+      'mcp__s__shot',
+    );
+    const parts = out.output as ContentPart[];
+    // The structured block sits OUTSIDE the media wrap, after the closing
+    // tag, so the image keeps its tool attribution.
+    expect(parts[0]).toEqual({ type: 'text', text: '' });
+    expect(parts.at(-2)).toEqual({ type: 'text', text: '' });
+    const last = parts.at(-1);
+    expect(last?.type === 'text' && last.text.includes('')).toBe(true);
+  });
+
+  test('strips literal closing tags inside the structured payload', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        _meta: { evil: 'ab' },
+      },
+      'mcp__s__t',
+    );
+    const parts = out.output as ContentPart[];
+    const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
+    expect(joined).toContain('"evil":"ab"');
+    // Exactly one closing tag survives: the wrapper's own.
+    expect(joined.split('')).toHaveLength(2);
+  });
+
+  test('drops protocol-reserved _meta keys and keeps vendor namespaces', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        _meta: {
+          'modelcontextprotocol.io/progress': 1,
+          'tools.mcp.com/trace': 'x',
+          'example.com/custom': 2,
+          // Reserved only when another label FOLLOWS mcp/modelcontextprotocol:
+          // a trailing reserved word is a legitimate vendor namespace.
+          'com.example.mcp/trace': 4,
+          vendorKey: 3,
+        },
+      },
+      'mcp__s__t',
+    );
+    const parts = out.output as ContentPart[];
+    const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
+    expect(joined).not.toContain('modelcontextprotocol.io/progress');
+    expect(joined).not.toContain('tools.mcp.com/trace');
+    expect(joined).toContain('"example.com/custom":2');
+    expect(joined).toContain('"com.example.mcp/trace":4');
+    expect(joined).toContain('"vendorKey":3');
+  });
+
+  test('omits the structured block when every _meta key is protocol-reserved', async () => {
+    const out = await mcpResultToExecutableOutput(
+      {
+        content: [{ type: 'text', text: 'ok' }],
+        isError: false,
+        _meta: { 'mcp.dev/internal': true },
+      },
+      'mcp__s__t',
+    );
+    expect(out).toEqual({ output: 'ok', isError: false });
+  });
+
   test('returns an empty string when the content array is empty', async () => {
     const out = await mcpResultToExecutableOutput(result([]), 'mcp__s__t');
     // No parts survive; collapseSingleText has nothing to collapse so the
diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts
index ce8888c90f..9a25989359 100644
--- a/packages/agent-core/test/plugin/manager.test.ts
+++ b/packages/agent-core/test/plugin/manager.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, readdir, realpath, symlink, writeFile } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
 
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
 import yazl from 'yazl';
 
 import { PluginManager } from '../../src/plugin/manager';
+import * as pluginStore from '../../src/plugin/store';
 
 async function makeKimiHome(): Promise {
   return mkdtemp(path.join(tmpdir(), 'kimi-home-'));
@@ -325,6 +326,28 @@ describe('PluginManager', () => {
     expect(updated.updatedAt).not.toBe(first.updatedAt);
     expect(updated.originalSource).toBe(updatedRoot);
     expect(manager.info('demo')?.mcpServers[0]?.enabled).toBe(false);
+    // Rename-swap publish must not leave sibling `*-previous` trees behind when
+    // the old managed root is free to delete (Windows EBUSY path defers this).
+    const managedDir = path.join(home, 'plugins', 'managed');
+    const leftover = (await readdir(managedDir)).filter((name) => name.includes('-previous'));
+    expect(leftover).toEqual([]);
+  });
+
+  it('install() does not publish in-memory records when persistence fails', async () => {
+    const home = await makeKimiHome();
+    const root = await makePlugin('demo', { version: '1.0.0' });
+    const manager = new PluginManager({ kimiHomeDir: home });
+    await manager.load();
+    await manager.install(root);
+    expect(manager.get('demo')?.manifest?.version).toBe('1.0.0');
+
+    const updatedRoot = await makePlugin('demo', { version: '2.0.0' });
+    const persist = vi.spyOn(pluginStore, 'writeInstalled').mockRejectedValueOnce(new Error('disk full'));
+    await expect(manager.install(updatedRoot)).rejects.toThrow(/disk full/);
+    persist.mockRestore();
+
+    expect(manager.get('demo')?.manifest?.version).toBe('1.0.0');
+    expect(manager.list()).toHaveLength(1);
   });
 
   it('keeps a plugin in error state instead of losing it on a broken manifest', async () => {
diff --git a/packages/agent-core/test/tools/edit.test.ts b/packages/agent-core/test/tools/edit.test.ts
index 1ef0f895c9..478431a0bd 100644
--- a/packages/agent-core/test/tools/edit.test.ts
+++ b/packages/agent-core/test/tools/edit.test.ts
@@ -41,6 +41,8 @@ describe('EditTool', () => {
     expect(tool.description).toContain('`old_string` must be unique');
     expect(tool.description).toContain('only when they do not target the same file');
     expect(tool.description).toContain('DO NOT issue consecutive Edit calls on the same file');
+    expect(tool.description).toContain('allow_large_delete=true');
+    expect(tool.description).toContain('short 30–50 line Read');
     // replace_all should be framed with its positive rename-across-file use-case.
     expect(tool.description.toLowerCase()).toContain('renam');
     // Editing files should go through Edit, not Write and not a Bash `sed`
@@ -398,6 +400,64 @@ describe('EditTool', () => {
     expect(writeText).toHaveBeenCalledWith('/tmp/e.txt', 'Hello !');
   });
 
+  it('refuses multi-line empty deletions unless allow_large_delete is set', async () => {
+    const writeText = vi.fn().mockResolvedValue(0);
+    const file = ['# Practice Phase', '', 'body line', '', 'more'].join('\n');
+    const tool = new EditTool(
+      createFakeKaos({
+        readText: vi.fn().mockResolvedValue(file),
+        writeText,
+      }),
+      PERMISSIVE_WORKSPACE,
+    );
+
+    const refused = await executeTool(
+      tool,
+      context({
+        path: '/tmp/skill.md',
+        old_string: '# Practice Phase\n\nbody line',
+        new_string: '',
+      }),
+    );
+    expect(refused).toMatchObject({ isError: true });
+    expect(refused.output).toContain('Refusing a multi-line deletion');
+    expect(refused.output).toContain('allow_large_delete=true');
+    expect(writeText).not.toHaveBeenCalled();
+
+    const allowed = await executeTool(
+      tool,
+      context({
+        path: '/tmp/skill.md',
+        old_string: '# Practice Phase\n\nbody line',
+        new_string: '',
+        allow_large_delete: true,
+      }),
+    );
+    expect(allowed.output).toContain('Replaced 1 occurrence');
+    expect(writeText).toHaveBeenCalledWith('/tmp/skill.md', '\n\nmore');
+  });
+
+  it('tells the model to reread a large region when old_string is missing', async () => {
+    const writeText = vi.fn().mockResolvedValue(0);
+    const tool = new EditTool(
+      createFakeKaos({
+        readText: vi.fn().mockResolvedValue('alpha beta'),
+        writeText,
+      }),
+      PERMISSIVE_WORKSPACE,
+    );
+
+    const result = await executeTool(
+      tool,
+      context({ path: '/tmp/a.txt', old_string: 'delta', new_string: 'gamma' }),
+    );
+
+    expect(result).toMatchObject({ isError: true });
+    expect(result.output).toContain('large enough region');
+    expect(result.output).toContain('30–50 line window');
+    expect(writeText).not.toHaveBeenCalled();
+  });
+
   it('allows absolute edits outside the workspace under default policy', async () => {
     const writeText = vi.fn().mockResolvedValue(0);
     const tool = new EditTool(
diff --git a/packages/kaos/test/e2e/process-lifecycle.test.ts b/packages/kaos/test/e2e/process-lifecycle.test.ts
index a943c46288..fc69bc9ee8 100644
--- a/packages/kaos/test/e2e/process-lifecycle.test.ts
+++ b/packages/kaos/test/e2e/process-lifecycle.test.ts
@@ -1,3 +1,10 @@
+/**
+ * Scenario: LocalKaos process execution and lifecycle management.
+ * Responsibilities: stream I/O, exit status, wait semantics, and process cleanup.
+ * Wiring: real local child processes and streams; no process boundary is stubbed.
+ * Run: pnpm exec vitest run packages/kaos/test/e2e/process-lifecycle.test.ts
+ */
+
 import { mkdtemp, realpath, rm } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
@@ -92,6 +99,18 @@ describe('e2e: process lifecycle', () => {
       expect(exitCode).toBe(42);
       expect(proc.exitCode).toBe(42);
     });
+
+    it('allows wait-before-read when stdout exceeds the prefetch limit', async () => {
+      const outputBytes = 1024 * 1024;
+      const proc = await kaos.exec(
+        'node',
+        '-e',
+        `process.stdout.write(Buffer.alloc(${String(outputBytes)}, 0x61))`,
+      );
+
+      await expect(proc.wait()).resolves.toBe(0);
+      await expect(streamToBuffer(proc.stdout)).resolves.toHaveLength(outputBytes);
+    });
   });
 
   describe('long-running process → kill', () => {
diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts
index 9e532208d1..6863196dac 100644
--- a/packages/kap-server/src/protocol/events-zod.ts
+++ b/packages/kap-server/src/protocol/events-zod.ts
@@ -454,6 +454,7 @@ export const toolUpdateSchema = z.object({
 export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({
   serverName: z.string(),
   authorizationUrl: z.string(),
+  expiresAt: z.number().optional(),
 }) satisfies z.ZodType;
 
 export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType;
diff --git a/packages/kap-server/src/routes/questions.ts b/packages/kap-server/src/routes/questions.ts
index 164316b5fc..c9b358bdf7 100644
--- a/packages/kap-server/src/routes/questions.ts
+++ b/packages/kap-server/src/routes/questions.ts
@@ -18,6 +18,15 @@
  * Fastify cannot disambiguate `:question_id` from `:question_id:dismiss` on the
  * same path prefix. The POST body is therefore validated manually (the dismiss
  * path carries an empty body), not via the Zod preHandler.
+ *
+ * **Colon-bearing question ids**: the question id is derived from the LLM
+ * tool_call id, and some providers emit ids containing a colon (e.g.
+ * `AskUserQuestion:0`), which the action-suffix parse rejects as an unknown
+ * action. The resolve handler therefore falls back to matching the FULL tail
+ * against the pending list before emitting 40001 — a hit resolves, a miss
+ * keeps the validation error. (Dismiss is unaffected: `id:0:dismiss` parses
+ * off the final colon.)
+
  *
  * Error mapping (REST.md §3.6):
  *   - 40401 (session.not_found)        — no live session matches {sid}
@@ -164,13 +173,6 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
         defaultAction: 'resolve',
         resourceLabel: 'question',
       });
-      if (parsed.kind === 'invalid') {
-        reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id));
-        return;
-      }
-      const questionId = parsed.id;
-      const action: 'resolve' | 'dismiss' = parsed.kind === 'bare' ? 'resolve' : parsed.action;
-
       const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id);
       if (handle === undefined) {
         reply.send(
@@ -180,6 +182,30 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
       }
 
       const interaction = handle.accessor.get(ISessionInteractionService);
+
+      let questionId: string;
+      let action: 'resolve' | 'dismiss';
+      if (parsed.kind === 'invalid') {
+        // Compat fallback: some providers emit tool_call ids CONTAINING a
+        // colon (e.g. `AskUserQuestion:0`), which the action-suffix parse
+        // rejects as an unknown action. Treat the full tail as the question
+        // id when it matches a pending (or recently-resolved, so duplicate
+        // resolves keep the 40902 semantics) question; otherwise keep 40001.
+        if (
+          interaction.listPending('question').some((i) => i.id === tail) ||
+          interaction.isRecentlyResolved(tail)
+        ) {
+          questionId = tail;
+          action = 'resolve';
+        } else {
+          reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id));
+          return;
+        }
+      } else {
+        questionId = parsed.id;
+        action = parsed.kind === 'bare' ? 'resolve' : parsed.action;
+      }
+
       const pendingInteraction = interaction
         .listPending('question')
         .find((i) => i.id === questionId);
diff --git a/packages/kap-server/src/routes/webAssets.ts b/packages/kap-server/src/routes/webAssets.ts
index 7b7ad01760..cb86be8fd5 100644
--- a/packages/kap-server/src/routes/webAssets.ts
+++ b/packages/kap-server/src/routes/webAssets.ts
@@ -1,6 +1,6 @@
 import { createReadStream } from 'node:fs';
 import { stat } from 'node:fs/promises';
-import { extname, join, normalize, resolve, sep } from 'node:path';
+import { extname, join, normalize, relative, resolve, sep } from 'node:path';
 
 import type { FastifyReply, FastifyRequest } from 'fastify';
 
@@ -57,9 +57,19 @@ async function serveWebAsset(
   return reply
     .type(mimeType(filePath))
     .header('Content-Length', String(fileInfo.size))
+    .header('Cache-Control', cacheControl(assetsDir, filePath))
     .send(createReadStream(filePath));
 }
 
+// Vite emits content-hashed files under `assets/` — cache them forever.
+// Everything else (index.html, SPA fallback, favicon, ...) must revalidate.
+function cacheControl(assetsDir: string, filePath: string): string {
+  const rel = relative(resolve(assetsDir), filePath);
+  return rel.startsWith(`assets${sep}`)
+    ? 'public, max-age=31536000, immutable'
+    : 'no-cache';
+}
+
 async function resolveStaticFile(
   assetsDir: string,
   pathname: string,
diff --git a/packages/kap-server/test/questions.test.ts b/packages/kap-server/test/questions.test.ts
index 620588cf26..65ced20029 100644
--- a/packages/kap-server/test/questions.test.ts
+++ b/packages/kap-server/test/questions.test.ts
@@ -84,7 +84,9 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => {
       server = undefined;
     }
     if (home !== undefined) {
-      await rm(home, { recursive: true, force: true });
+      // maxRetries: the async query-store shard writer can still be flushing
+      // after close (ENOTEMPTY on macOS) — same retry pattern as fs.test.ts.
+      await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
       home = undefined;
     }
   });
@@ -336,6 +338,58 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => {
     expect(body.code).toBe(40405);
   });
 
+  it('resolves a question whose id contains a colon (provider tool_call id)', async () => {
+    const sid = await createSession();
+    // Real-world shape: no explicit id, so the question id falls back to the
+    // tool_call id — which some providers emit as `{function_name}:{index}`.
+    const resultPromise: Promise = questionService(sid).request({
+      toolCallId: 'AskUserQuestion:0',
+      questions: [
+        {
+          question: 'Pick one',
+          options: [{ label: 'Yes' }, { label: 'No' }],
+        },
+      ],
+    });
+
+    const list = await getJson(`/api/v1/sessions/${sid}/questions?status=pending`);
+    expect(list.body.data.items[0]!.question_id).toBe('AskUserQuestion:0');
+
+    const { body } = await postJson(
+      `/api/v1/sessions/${sid}/questions/AskUserQuestion%3A0`,
+      { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } },
+    );
+    expect(body.code).toBe(0);
+    expect(body.data.resolved).toBe(true);
+    await expect(resultPromise).resolves.toEqual({ answers: { 'Pick one': 'Yes' } });
+  });
+
+  it('keeps 40001 for a colon tail that matches no pending question', async () => {
+    const sid = await createSession();
+    const { body } = await postJson(`/api/v1/sessions/${sid}/questions/q-9:0`, {
+      answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } },
+    });
+    expect(body.code).toBe(40001);
+  });
+
+  it('returns 40902 on a duplicate resolve of a colon-id question', async () => {
+    const sid = await createSession();
+    questionService(sid).enqueue({
+      toolCallId: 'AskUserQuestion:1',
+      questions: [{ question: 'Pick one', options: [{ label: 'Yes' }] }],
+    });
+    const url = `/api/v1/sessions/${sid}/questions/AskUserQuestion%3A1`;
+    await postJson(url, {
+      answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } },
+    });
+
+    const dup = await postJson<{ resolved: false }>(url, {
+      answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } },
+    });
+    expect(dup.body.code).toBe(40902);
+    expect(dup.body.data).toEqual({ resolved: false });
+  });
+
   it('returns 40401 for an unknown session', async () => {
     const { body } = await getJson('/api/v1/sessions/nope/questions?status=pending');
     expect(body.code).toBe(40401);
diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts
index 4496a432d9..0272b09abc 100644
--- a/packages/kosong/src/providers/kimi.ts
+++ b/packages/kosong/src/providers/kimi.ts
@@ -324,8 +324,11 @@ class KimiStreamedMessage implements StreamedMessage {
     // Reasoning dialect: accept any known wire key and remember which one the
     // endpoint used, so the next request echoes thinking back under the same
     // field (newer vLLM renamed `reasoning_content` to `reasoning`).
+    // Observe empty strings for dialect detection, but do not journal no-op
+    // think parts — some gateways keep `reasoning_content: ""` on every chunk
+    // after thinking ends (see #2506).
     const reasoning = this._reasoningKeyDialect.observe(message);
-    if (reasoning !== undefined) {
+    if (reasoning) {
       yield { type: 'think', think: reasoning } satisfies StreamedMessagePart;
     }
 
@@ -382,8 +385,11 @@ class KimiStreamedMessage implements StreamedMessage {
         const delta = choice.delta;
 
         // Reasoning dialect: same detection as the non-stream path.
+        // Observe empty strings for dialect detection, but do not journal no-op
+        // think parts — some gateways keep `reasoning_content: ""` on every chunk
+        // after thinking ends (see #2506).
         const reasoning = this._reasoningKeyDialect.observe(delta);
-        if (reasoning !== undefined) {
+        if (reasoning) {
           yield { type: 'think', think: reasoning } satisfies StreamedMessagePart;
         }
 
diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts
index d817326baa..bcb2143f18 100644
--- a/packages/kosong/test/kimi.test.ts
+++ b/packages/kosong/test/kimi.test.ts
@@ -1250,7 +1250,7 @@ describe('KimiChatProvider', () => {
       ]);
     });
 
-    it('yields an empty ThinkPart when reasoning_content is explicitly empty', async () => {
+    it('skips an empty reasoning_content field instead of journaling a no-op think part', async () => {
       const provider = createProvider();
       (provider as any)._client.chat.completions.create = vi.fn().mockImplementation(() =>
         mockCreateResult({
@@ -1279,7 +1279,6 @@ describe('KimiChatProvider', () => {
       for await (const part of stream) parts.push(part);
 
       expect(parts).toEqual([
-        { type: 'think', think: '' },
         {
           type: 'function',
           id: 'call_1',
@@ -1588,13 +1587,21 @@ describe('KimiChatProvider', () => {
       }
     }
 
-    it('yields an empty ThinkPart from an explicitly empty streaming delta', async () => {
+    it('skips empty streaming reasoning deltas so reasoning_content: "" chunks do not bloat the journal', async () => {
       const provider = createProvider(true);
       const chunks = [
+        {
+          id: 'chatcmpl-empty-reasoning',
+          choices: [{ index: 0, delta: { reasoning_content: 'hmm' }, finish_reason: null }],
+        },
         {
           id: 'chatcmpl-empty-reasoning',
           choices: [{ index: 0, delta: { reasoning_content: '' }, finish_reason: null }],
         },
+        {
+          id: 'chatcmpl-empty-reasoning',
+          choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
+        },
       ];
       (
         provider as unknown as { _client: { chat: { completions: { create: unknown } } } }
@@ -1604,7 +1611,10 @@ describe('KimiChatProvider', () => {
       const parts = [];
       for await (const part of stream) parts.push(part);
 
-      expect(parts).toEqual([{ type: 'think', think: '' }]);
+      expect(parts).toEqual([
+        { type: 'think', think: 'hmm' },
+        { type: 'text', text: 'hi' },
+      ]);
     });
 
     it('buffers indexed argument deltas until the real tool name arrives', async () => {
diff --git a/packages/oauth/src/refreshProviderModels.ts b/packages/oauth/src/refreshProviderModels.ts
index 49c521fd63..c51b5eec09 100644
--- a/packages/oauth/src/refreshProviderModels.ts
+++ b/packages/oauth/src/refreshProviderModels.ts
@@ -443,6 +443,7 @@ export async function refreshProviderModels(
             models: next.models,
             defaultModel: next.defaultModel,
             thinking: next.thinking,
+            defaultProvider: next['defaultProvider'],
           });
           changed.push({
             providerId: KIMI_CODE_PROVIDER_NAME,
@@ -522,6 +523,7 @@ export async function refreshProviderModels(
           models: next.models,
           defaultModel: next.defaultModel,
           thinking: next.thinking,
+          defaultProvider: next['defaultProvider'],
         });
         changed.push({
           providerId,
@@ -741,6 +743,7 @@ export async function refreshProviderModels(
           models: next.models,
           defaultModel: next.defaultModel,
           thinking: next.thinking,
+          defaultProvider: next['defaultProvider'],
         });
         for (const change of changedProviders) {
           changed.push({
diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts
index ac12f43796..9b4797df7a 100644
--- a/packages/protocol/src/events.ts
+++ b/packages/protocol/src/events.ts
@@ -421,6 +421,12 @@ export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_
 export interface McpOAuthAuthorizationUrlUpdateData {
   readonly serverName: string;
   readonly authorizationUrl: string;
+  /**
+   * Epoch-ms instant when the engine stops waiting for the OAuth callback.
+   * Hosts derive countdowns and expiry states from this value instead of
+   * mirroring the engine-side timeout constant.
+   */
+  readonly expiresAt?: number;
 }
 
 export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked';
@@ -1328,6 +1334,7 @@ export const toolUpdateSchema = z.object({
 export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({
   serverName: z.string(),
   authorizationUrl: z.string(),
+  expiresAt: z.number().optional(),
 }) satisfies z.ZodType;
 
 export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType;

From dc029b8e01033ec0a7e38565bc4e640c81893391 Mon Sep 17 00:00:00 2001
From: Pidbid 
Date: Tue, 4 Aug 2026 20:26:37 -0700
Subject: [PATCH 02/10] fix: reconcile ports with existing KKM MCP layout

Avoid duplicating SSE files already present in KKM and adapt structured MCP results to the current v2 agent/mcp type layout.
---
 .../src/agent/mcp/client-shared.ts            |  12 +-
 packages/agent-core-v2/src/agent/mcp/types.ts |   2 +
 packages/agent-core/src/mcp/client-remote.ts  |  32 ----
 packages/agent-core/src/mcp/client-sse.ts     | 169 ------------------
 .../agent-core/test/mcp/client-sse.test.ts    | 142 ---------------
 5 files changed, 13 insertions(+), 344 deletions(-)

diff --git a/packages/agent-core-v2/src/agent/mcp/client-shared.ts b/packages/agent-core-v2/src/agent/mcp/client-shared.ts
index ce3e1db662..8e91b4f9e2 100644
--- a/packages/agent-core-v2/src/agent/mcp/client-shared.ts
+++ b/packages/agent-core-v2/src/agent/mcp/client-shared.ts
@@ -99,11 +99,21 @@ export function toMcpToolDefinition(tool: SdkListedTool): MCPToolDefinition {
 
 export function toMcpToolResult(result: unknown): MCPToolResult {
   if (typeof result === 'object' && result !== null && 'content' in result) {
-    const typed = result as { content: unknown; isError?: unknown };
+    const typed = result as {
+      content: unknown;
+      isError?: unknown;
+      structuredContent?: unknown;
+      _meta?: unknown;
+    };
     if (Array.isArray(typed.content)) {
       return {
         content: typed.content as MCPToolResult['content'],
         isError: typed.isError === true,
+        structuredContent: typed.structuredContent,
+        _meta:
+          typeof typed._meta === 'object' && typed._meta !== null
+            ? (typed._meta as Record)
+            : undefined,
       };
     }
   }
diff --git a/packages/agent-core-v2/src/agent/mcp/types.ts b/packages/agent-core-v2/src/agent/mcp/types.ts
index ff783c6b6a..b43b85ab88 100644
--- a/packages/agent-core-v2/src/agent/mcp/types.ts
+++ b/packages/agent-core-v2/src/agent/mcp/types.ts
@@ -34,6 +34,8 @@ export interface MCPContentBlock {
 export interface MCPToolResult {
   content: MCPContentBlock[];
   isError: boolean;
+  structuredContent?: unknown;
+  _meta?: Record;
 }
 
 export interface MCPToolDefinition {
diff --git a/packages/agent-core/src/mcp/client-remote.ts b/packages/agent-core/src/mcp/client-remote.ts
index 6ea2187ad2..6cf2d0f115 100644
--- a/packages/agent-core/src/mcp/client-remote.ts
+++ b/packages/agent-core/src/mcp/client-remote.ts
@@ -1,37 +1,5 @@
 import type { McpRemoteServerConfig, McpServerConfig } from '#/config/schema';
 import { ErrorCodes, KimiError } from '#/errors';
-
-export function buildMcpRemoteHeaders(
-  config: McpRemoteServerConfig,
-  envLookup: (name: string) => string | undefined,
-): Record | undefined {
-  const headers: Record = { ...config.headers };
-  if (config.bearerTokenEnvVar !== undefined) {
-    const token = envLookup(config.bearerTokenEnvVar);
-    if (token === undefined || token.length === 0) {
-      throw new KimiError(
-        ErrorCodes.CONFIG_INVALID,
-        `MCP ${config.transport.toUpperCase()} bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`,
-      );
-    }
-    // Strip any case-variant 'authorization' static header before injecting the
-    // bearer; Fetch Headers folds duplicate keys into a comma-joined value,
-    // which produces an invalid auth header rather than letting the bearer win.
-    for (const key of Object.keys(headers)) {
-      if (key.toLowerCase() === 'authorization') {
-        delete headers[key];
-      }
-    }
-    headers['Authorization'] = `Bearer ${token}`;
-  }
-  return Object.keys(headers).length > 0 ? headers : undefined;
-}
-
-export function isRemoteMcpConfig(config: McpServerConfig): config is McpRemoteServerConfig {
-  return config.transport === 'http' || config.transport === 'sse';
-}
-import type { McpRemoteServerConfig, McpServerConfig } from '#/config/schema';
-import { ErrorCodes, KimiError } from '#/errors';
 import { createProxyDispatcher } from '#/utils/proxy';
 import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';
 
diff --git a/packages/agent-core/src/mcp/client-sse.ts b/packages/agent-core/src/mcp/client-sse.ts
index 56a61249db..c02d0f4a3d 100644
--- a/packages/agent-core/src/mcp/client-sse.ts
+++ b/packages/agent-core/src/mcp/client-sse.ts
@@ -3,175 +3,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
 import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
 import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js';
 
-import {
-  buildRequestOptions,
-  KIMI_MCP_CLIENT_NAME,
-  KIMI_MCP_CLIENT_VERSION,
-  toMcpToolDefinition,
-  toMcpToolResult,
-  type UnexpectedCloseListener,
-  type UnexpectedCloseReason,
-} from './client-shared';
-import { buildMcpRemoteHeaders } from './client-remote';
-import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types';
-
-export interface SseMcpClientOptions {
-  readonly clientName?: string;
-  readonly clientVersion?: string;
-  readonly toolCallTimeoutMs?: number;
-  /**
-   * Reads `process.env[name]` by default. Tests can inject a deterministic
-   * lookup function so they do not have to mutate global env.
-   */
-  readonly envLookup?: (name: string) => string | undefined;
-  /**
-   * Lets tests inject a fake `fetch` for the underlying transport.
-   */
-  readonly fetch?: typeof fetch;
-  /**
-   * OAuth client provider attached to the transport. Set only when the server
-   * has no static token configuration; the connection manager wires this in
-   * and surfaces `UnauthorizedError` as a `needs-auth` status.
-   */
-  readonly oauthProvider?: OAuthClientProvider;
-}
-
-/**
- * Wraps the SDK's deprecated HTTP+SSE transport as a kosong
- * {@link MCPClient}. This exists for compatibility with older MCP servers;
- * new remote servers should prefer streamable HTTP.
- */
-export class SseMcpClient implements MCPClient {
-  private readonly client: Client;
-  private readonly transport: SSEClientTransport;
-  private readonly toolCallTimeoutMs?: number;
-  private started = false;
-  private closed = false;
-  // Mirrors HttpMcpClient: handshake failures surface through connect(), while
-  // post-ready terminal transport errors become unexpected closes.
-  private ready = false;
-  private hooksInstalled = false;
-  private unexpectedCloseListener: UnexpectedCloseListener | undefined;
-  private lastTransportError: Error | undefined;
-  private pendingUnexpectedClose: UnexpectedCloseReason | undefined;
-  private unexpectedCloseFired = false;
-
-  constructor(config: McpServerSseConfig, options: SseMcpClientOptions = {}) {
-    const envLookup = options.envLookup ?? ((name) => process.env[name]);
-    const headers = buildMcpRemoteHeaders(config, envLookup);
-
-    this.transport = new SSEClientTransport(new URL(config.url), {
-      requestInit: headers !== undefined ? { headers } : undefined,
-      fetch: options.fetch,
-      authProvider: options.oauthProvider,
-    });
-    this.client = new Client({
-      name: options.clientName ?? KIMI_MCP_CLIENT_NAME,
-      version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION,
-    });
-    this.toolCallTimeoutMs = options.toolCallTimeoutMs;
-  }
-
-  async connect(): Promise {
-    if (this.closed) {
-      throw new Error('MCP SSE client is closed');
-    }
-    if (this.started) return;
-    this.started = true;
-    this.installTransportHooks();
-    try {
-      await this.client.connect(this.transport);
-    } catch (error) {
-      await this.closeStartedClient();
-      throw error;
-    }
-    if (this.closed) {
-      await this.closeStartedClient();
-      throw new Error('MCP SSE client was closed during startup');
-    }
-    this.ready = true;
-  }
-
-  async close(): Promise {
-    if (this.closed) return;
-    this.closed = true;
-    await this.closeStartedClient();
-  }
-
-  /**
-   * Register a listener for unsolicited terminal transport drops. Brief SSE
-   * stream flaps are left to EventSource's retry loop; terminal HTTP status
-   * errors after startup remove the tools from the agent.
-   */
-  onUnexpectedClose(listener: UnexpectedCloseListener): void {
-    this.unexpectedCloseListener = listener;
-    const pending = this.pendingUnexpectedClose;
-    if (pending !== undefined) {
-      this.pendingUnexpectedClose = undefined;
-      listener(pending);
-    }
-  }
-
-  async listTools(): Promise {
-    const result = await this.client.listTools();
-    return result.tools.map(toMcpToolDefinition);
-  }
-
-  async callTool(
-    name: string,
-    args: Record,
-    signal?: AbortSignal,
-  ): Promise {
-    const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal);
-    const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions);
-    return toMcpToolResult(result);
-  }
-
-  private async closeStartedClient(): Promise {
-    if (!this.started) return;
-    this.started = false;
-    await this.client.close();
-  }
-
-  private installTransportHooks(): void {
-    if (this.hooksInstalled) return;
-    this.hooksInstalled = true;
-    this.client.onclose = () => {
-      if (this.closed) return;
-      if (!this.ready) return;
-      this.fireUnexpectedClose({ error: this.lastTransportError });
-    };
-    this.client.onerror = (error) => {
-      this.lastTransportError = error;
-      if (this.closed) return;
-      if (!this.ready) return;
-      if (isTerminalSseTransportError(error)) {
-        this.fireUnexpectedClose({ error });
-      }
-    };
-  }
-
-  private fireUnexpectedClose(reason: UnexpectedCloseReason): void {
-    if (this.unexpectedCloseFired) return;
-    this.unexpectedCloseFired = true;
-    const listener = this.unexpectedCloseListener;
-    if (listener !== undefined) {
-      listener(reason);
-    } else {
-      this.pendingUnexpectedClose = reason;
-    }
-  }
-}
-
-export function isTerminalSseTransportError(error: Error): boolean {
-  if (error.name === 'UnauthorizedError') return true;
-  return error instanceof SseError && error.code !== undefined;
-}
-import type { McpServerSseConfig } from '#/config/schema';
-import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
-import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js';
-
 import {
   buildRequestOptions,
   KIMI_MCP_CLIENT_NAME,
diff --git a/packages/agent-core/test/mcp/client-sse.test.ts b/packages/agent-core/test/mcp/client-sse.test.ts
index bd7417edbe..8a3c900f1f 100644
--- a/packages/agent-core/test/mcp/client-sse.test.ts
+++ b/packages/agent-core/test/mcp/client-sse.test.ts
@@ -140,145 +140,3 @@ describe('SseMcpClient', () => {
     expect(isTerminalSseTransportError(new Error('fetch failed'))).toBe(false);
   });
 });
-import { createServer, type Server } from 'node:http';
-import type { AddressInfo } from 'node:net';
-
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
-import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
-import { SseError } from '@modelcontextprotocol/sdk/client/sse.js';
-import { afterEach, describe, expect, it } from 'vitest';
-import { z } from 'zod';
-
-import { SseMcpClient, isTerminalSseTransportError } from '../../src/mcp/client-sse';
-
-const cleanups: Array<() => Promise | void> = [];
-
-afterEach(async () => {
-  for (const cleanup of cleanups.splice(0)) {
-    await cleanup();
-  }
-});
-
-async function startInProcessSseMcpServer(opts?: {
-  authToken?: string;
-}): Promise<{ url: string; close: () => Promise }> {
-  const transports = new Map();
-  const httpServer: Server = createServer((req, res) => {
-    if (opts?.authToken !== undefined) {
-      const auth = req.headers['authorization'];
-      if (auth !== `Bearer ${opts.authToken}`) {
-        res.writeHead(401, { 'content-type': 'text/plain' });
-        res.end('unauthorized');
-        return;
-      }
-    }
-
-    const url = new URL(req.url ?? '/', 'http://127.0.0.1');
-    if (req.method === 'GET' && url.pathname === '/mcp') {
-      const mcpServer = new McpServer({ name: 'mock-sse', version: '0.0.1' });
-      mcpServer.registerTool(
-        'echo',
-        { description: 'Echoes text', inputSchema: { text: z.string() } },
-        ({ text }) => ({ content: [{ type: 'text', text }] }),
-      );
-      const transport = new SSEServerTransport('/messages', res);
-      transports.set(transport.sessionId, transport);
-      transport.onclose = () => {
-        transports.delete(transport.sessionId);
-      };
-      void mcpServer.connect(transport);
-      return;
-    }
-
-    if (req.method === 'POST' && url.pathname === '/messages') {
-      const sessionId = url.searchParams.get('sessionId');
-      const transport = sessionId === null ? undefined : transports.get(sessionId);
-      if (transport === undefined) {
-        res.writeHead(404).end('Session not found');
-        return;
-      }
-      void transport.handlePostMessage(req, res);
-      return;
-    }
-
-    res.writeHead(404).end('not found');
-  });
-
-  await new Promise((resolve) => {
-    httpServer.listen(0, '127.0.0.1', resolve);
-  });
-  const port = (httpServer.address() as AddressInfo).port;
-
-  return {
-    url: `http://127.0.0.1:${port}/mcp`,
-    async close() {
-      await Promise.all([...transports.values()].map((transport) => transport.close()));
-      await new Promise((resolve, reject) => {
-        httpServer.close((err) => {
-          if (err) {
-            reject(err);
-            return;
-          }
-          resolve();
-        });
-      });
-    },
-  };
-}
-
-describe('SseMcpClient', () => {
-  it('connects, lists tools, and round-trips a call over real SSE', async () => {
-    const server = await startInProcessSseMcpServer();
-    cleanups.push(server.close);
-
-    const client = new SseMcpClient({ transport: 'sse', url: server.url });
-    try {
-      await client.connect();
-      const tools = await client.listTools();
-      expect(tools.map((t) => t.name)).toEqual(['echo']);
-
-      const result = await client.callTool('echo', { text: 'hello sse' });
-      expect(result.isError).toBe(false);
-      expect(result.content).toEqual([{ type: 'text', text: 'hello sse' }]);
-    } finally {
-      await client.close();
-    }
-  }, 15000);
-
-  it('forwards bearer token from envLookup on the SSE and POST requests', async () => {
-    const server = await startInProcessSseMcpServer({ authToken: 'good-token' });
-    cleanups.push(server.close);
-
-    const client = new SseMcpClient(
-      {
-        transport: 'sse',
-        url: server.url,
-        bearerTokenEnvVar: 'EXAMPLE_TOKEN',
-      },
-      { envLookup: (name) => (name === 'EXAMPLE_TOKEN' ? 'good-token' : undefined) },
-    );
-    try {
-      await client.connect();
-      const result = await client.callTool('echo', { text: 'with auth' });
-      expect(result.content).toEqual([{ type: 'text', text: 'with auth' }]);
-    } finally {
-      await client.close();
-    }
-  }, 15000);
-
-  it('classifies terminal SSE transport errors without treating reconnect flaps as terminal', () => {
-    const unauthorized = new Error('Unauthorized');
-    unauthorized.name = 'UnauthorizedError';
-    expect(isTerminalSseTransportError(unauthorized)).toBe(true);
-    expect(
-      isTerminalSseTransportError(
-        new SseError(
-          204,
-          'Server sent HTTP 204',
-          {} as ConstructorParameters[2],
-        ),
-      ),
-    ).toBe(true);
-    expect(isTerminalSseTransportError(new Error('fetch failed'))).toBe(false);
-  });
-});

From 3c2ce5ad9fafd558556d6d40502a348c0df39c28 Mon Sep 17 00:00:00 2001
From: Pidbid 
Date: Tue, 4 Aug 2026 20:28:37 -0700
Subject: [PATCH 03/10] fix: finish KKM baseline reconciliation

Reuse existing SSE regression coverage and import the transcript output cap helper used by the port.
---
 apps/kimi-code/src/tui/kimi-tui.ts            |  2 +-
 .../agent-core/test/mcp/config-loader.test.ts | 22 -------
 .../test/mcp/connection-manager.test.ts       | 60 -------------------
 3 files changed, 1 insertion(+), 83 deletions(-)

diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts
index bb7b139266..04dc6b59ce 100644
--- a/apps/kimi-code/src/tui/kimi-tui.ts
+++ b/apps/kimi-code/src/tui/kimi-tui.ts
@@ -144,7 +144,7 @@ import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image
 import { REPLAY_TURN_LIMIT } from './utils/message-replay';
 import { hasPatchChanges } from './utils/object-patch';
 import { sessionRowsForPicker } from './utils/session-picker-rows';
-import { formatBashOutputForDisplay } from './utils/shell-output';
+import { capStoredShellOutput, formatBashOutputForDisplay } from './utils/shell-output';
 import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup';
 import { installTerminalFocusTracking } from './utils/terminal-focus';
 import { installEditorMouseTracking } from './utils/editor-mouse';
diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts
index a5e583be36..754630b593 100644
--- a/packages/agent-core/test/mcp/config-loader.test.ts
+++ b/packages/agent-core/test/mcp/config-loader.test.ts
@@ -305,28 +305,6 @@ describe('loadMcpServers', () => {
     });
   });
 
-  it('loads explicit SSE server config', async () => {
-    const home = makeTempDir();
-    const cwd = makeTempDir();
-    await writeJson(join(home, 'mcp.json'), {
-      mcpServers: {
-        legacy: {
-          transport: 'sse',
-          url: 'https://mcp.example.com/sse',
-          headers: { 'X-Tenant': 'kimi' },
-          bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
-        },
-      },
-    });
-    const servers = await loadMcpServers({ cwd, homeDir: home });
-    expect(servers['legacy']).toEqual({
-      transport: 'sse',
-      url: 'https://mcp.example.com/sse',
-      headers: { 'X-Tenant': 'kimi' },
-      bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
-    });
-  });
-
   it('honors KIMI_CODE_HOME env var when homeDir is not supplied', async () => {
     const home = makeTempDir();
     const cwd = makeTempDir();
diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts
index 20219f346a..ba7a361ff3 100644
--- a/packages/agent-core/test/mcp/connection-manager.test.ts
+++ b/packages/agent-core/test/mcp/connection-manager.test.ts
@@ -155,25 +155,6 @@ describe('McpConnectionManager', () => {
     }
   });
 
-  it('marks SSE servers failed when configured bearer token env var is missing', async () => {
-    const cm = new McpConnectionManager({ envLookup: () => undefined });
-    try {
-      await cm.connectAll({
-        legacy: {
-          transport: 'sse',
-          url: 'https://example.invalid/sse',
-          bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
-        },
-      });
-      const entry = cm.get('legacy');
-      expect(entry?.transport).toBe('sse');
-      expect(entry?.status).toBe('failed');
-      expect(entry?.error).toContain('"LEGACY_MCP_TOKEN" is not set or is empty');
-    } finally {
-      await cm.shutdown();
-    }
-  });
-
   it('marks disabled servers without attempting a connection', async () => {
     const cm = new McpConnectionManager();
     try {
@@ -671,47 +652,6 @@ describe('McpConnectionManager', () => {
     }
   }, 15000);
 
-  it('flips SSE servers into needs-auth when the server returns 401 and no static token is set', async () => {
-    const server: HttpServer = createHttpServer((_req, res) => {
-      res.writeHead(401, {
-        'content-type': 'text/plain',
-        'www-authenticate': 'Bearer realm="mcp", resource_metadata="http://x/.well-known/oauth-protected-resource"',
-      });
-      res.end('unauthorized');
-    });
-    await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
-    const port = (server.address() as HttpAddress).port;
-    const storeDir = await mkdtemp(join(tmpdir(), 'kimi-mcp-oauth-sse-cm-'));
-    const oauthService = new McpOAuthService({ store: new JsonFileStore(storeDir) });
-    const cm = new McpConnectionManager({ oauthService });
-    try {
-      await cm.connectAll({
-        legacy: {
-          transport: 'sse',
-          url: `http://127.0.0.1:${port}/sse`,
-          startupTimeoutMs: 5_000,
-        },
-      });
-      const entry = cm.get('legacy');
-      expect(entry?.transport).toBe('sse');
-      expect(entry?.status).toBe('needs-auth');
-      expect(entry?.error).toContain('run /mcp-config login legacy');
-      expect(entry?.toolCount).toBe(0);
-    } finally {
-      await cm.shutdown();
-      await new Promise((resolve, reject) => {
-        server.close((err) => {
-          if (err) {
-            reject(err);
-            return;
-          }
-          resolve();
-        });
-      });
-      await rm(storeDir, { recursive: true, force: true });
-    }
-  }, 15000);
-
   it('flips cached OAuth credentials that require reauth into needs-auth', async () => {
     const server: HttpServer = createHttpServer((req, res) => {
       if (req.url === '/token') {

From d274a0025a69b9bd40fcbb47bc4efee1e7ffe2e9 Mon Sep 17 00:00:00 2001
From: Pidbid 
Date: Tue, 4 Aug 2026 20:35:34 -0700
Subject: [PATCH 04/10] fix: align port tests and defer kaos-dependent patch

Update snapshots and coercion assertions for the accepted behavior, tolerate legacy session test doubles, and defer #2541 until KKM has the required kaos wait/read lifecycle.
---
 .changeset/fix-late-bash-output.md            |  5 -----
 UPSTREAM_PORTS.md                             |  4 ++--
 .../test/agent/loop/loop.test.ts              |  2 +-
 .../test/tool/args-validator.test.ts          | 12 ++++++-----
 packages/agent-core-v2/test/tool/tool.test.ts |  2 +-
 .../src/agent/background/process-task.ts      | 21 +++++++++++++++++--
 .../agent-core/src/session/subagent-host.ts   |  2 +-
 .../test/agent/background/manager.test.ts     | 18 ----------------
 .../kaos/test/e2e/process-lifecycle.test.ts   | 19 -----------------
 9 files changed, 31 insertions(+), 54 deletions(-)
 delete mode 100644 .changeset/fix-late-bash-output.md

diff --git a/.changeset/fix-late-bash-output.md b/.changeset/fix-late-bash-output.md
deleted file mode 100644
index 8e2ced41ec..0000000000
--- a/.changeset/fix-late-bash-output.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@moonshot-ai/kimi-code": patch
----
-
-Fix Bash commands losing stdout when output streams close after the process exits.
diff --git a/UPSTREAM_PORTS.md b/UPSTREAM_PORTS.md
index 88ebc3dc77..a72a04699f 100644
--- a/UPSTREAM_PORTS.md
+++ b/UPSTREAM_PORTS.md
@@ -16,7 +16,7 @@
 
 KKM 分支:`agent/upstream-port-20260804`  
 目标基线:KKM `main` @ `f6fb808749e421bf1a6d767993a70889e65db863`  
-KKM PR:待创建  
+KKM PR:[Pidbid/kkm#5](https://github.com/Pidbid/kkm/pull/5)  
 状态:移植完成,等待 KKM CI 验证与合并。
 
 本批次重新检查了上游已合并 PR 与仍开放的社区 PR。筛选标准仍是基础能力、可靠性、跨平台和协议兼容;不移植 Kimi 账号、登录、额度、托管配置、反馈、遥测身份等厂商业务。
@@ -49,7 +49,6 @@ KKM PR:待创建
 | [#2511](https://github.com/MoonshotAI/kimi-code/pull/2511) | `eb224a2c7143` | Edit 拒绝意外的大范围空替换删除 | v1/v2 编辑器实现与测试同步 |
 | [#2513](https://github.com/MoonshotAI/kimi-code/pull/2513) | `3314f6a731a1` | Web 超长代码行稳定渲染 | CSS/渲染性能小修复 |
 | [#2537](https://github.com/MoonshotAI/kimi-code/pull/2537) | `82958df646ef` | v1 正确遵守 `[tools].disabled` | 配置层小修复与单测 |
-| [#2541](https://github.com/MoonshotAI/kimi-code/pull/2541) | `72514bc0aef8` | Bash 退出后仍保留迟到 stdout | 生命周期与 e2e 测试同步 |
 | [#2544](https://github.com/MoonshotAI/kimi-code/pull/2544) | `eade0e38a592` | 展开 `KIMI_CODE_HOME=~/...` | 跨平台路径小修复 |
 | [#2603](https://github.com/MoonshotAI/kimi-code/pull/2603) | `1dee7cfbc9ad` | transcript fold 后回收旧 UI entry | 上游 CI 已通过;加入独立回归测试 |
 | [#2621](https://github.com/MoonshotAI/kimi-code/pull/2621) | `43446ed556f8` | 裁剪 shell-only transcript turn,并限制保存输出大小 | 上游 CI 已通过;与 KKM TUI 逻辑融合 |
@@ -66,6 +65,7 @@ KKM PR:待创建
 | [#2604](https://github.com/MoonshotAI/kimi-code/pull/2604) | minidb 大型重构,基础收益不足以覆盖迁移风险 |
 | [#2593](https://github.com/MoonshotAI/kimi-code/pull/2593) | engine-native image refs 仍为 draft,且依赖 v2 turn/wire |
 | [#2610](https://github.com/MoonshotAI/kimi-code/pull/2610) | session effort flag 跨 24 个文件,需先确认 KKM 对模型 effort 的统一策略 |
+| [#2541](https://github.com/MoonshotAI/kimi-code/pull/2541) | “保留迟到 Bash stdout”依赖 KKM 尚缺的新版 kaos wait/read 生命周期;独立移植会使 wait-before-read 与流错误测试挂起 |
 | [#2578](https://github.com/MoonshotAI/kimi-code/pull/2578), [#2579](https://github.com/MoonshotAI/kimi-code/pull/2579) | Web UI 小修可用但优先级低,等待与下一次 Web 专项批次合并 |
 
 ### 本批次验证
diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts
index c8dcfd92e0..89917ef966 100644
--- a/packages/agent-core-v2/test/agent/loop/loop.test.ts
+++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts
@@ -105,7 +105,7 @@ describe('Agent loop', () => {
       [emit] turn.step.started           { "turnId": 0, "step": 1, "stepId": "" }
       [emit] agent.activity.updated      { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "