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-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..0b356513ba 100644
--- a/UPSTREAM_PORTS.md
+++ b/UPSTREAM_PORTS.md
@@ -12,6 +12,80 @@
 - KKM 对补丁进行了冲突融合,不保证 KKM 提交 SHA 与上游一致;上游 PR 与固定 head SHA 是来源依据。
 - 每次同步都必须更新本文件,并在 KKM PR 与 Release Notes 中引用对应批次。
 
+## Batch 2026-08-04 — 官方与社区通用修复
+
+KKM 分支:`agent/upstream-port-20260804`  
+目标基线:KKM `main` @ `f6fb808749e421bf1a6d767993a70889e65db863`  
+KKM PR:[Pidbid/kkm#5](https://github.com/Pidbid/kkm/pull/5)  
+状态:已完成移植与完整 CI 验证,等待通过 PR #5 合并到 `main`。
+
+本批次重新检查了上游已合并 PR 与仍开放的社区 PR。筛选标准仍是基础能力、可靠性、跨平台和协议兼容;不移植 Kimi 账号、登录、额度、托管配置、反馈、遥测身份等厂商业务。
+
+### KKM 移植提交
+
+| KKM commit | 内容 |
+|---|---|
+| `d2364c9c926a` | 首轮导入本批次筛选出的官方与社区通用修复 |
+| `dc029b8e0103` | 按 KKM 现有 SSE/MCP v2 布局消除重复实现并适配结构化结果 |
+| `3c2ce5ad9faf` | 复用现有 SSE 测试并补齐 shell 输出工具引用 |
+| `d274a0025a69` | 完成 session、参数纠正、Edit 快照兼容,并因架构前置缺失撤回 #2541 |
+| `c069f5095387`…`77c187b1bfb0` | 根据完整 CI 更新工具快照;最终代码树在 `77c187b1bfb0` 验证通过 |
+
+
+### 上游已合并(官方采纳)
+
+| 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` | 配置层小修复与单测 |
+| [#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 的统一策略 |
+| [#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 专项批次合并 |
+
+### 本批次验证
+
+- 验证提交:`77c187b1bfb059bf139208bd66f628e8dae91609`
+- [CI run 30973570199](https://github.com/Pidbid/kkm/actions/runs/30973570199):`lint`、`typecheck`、`build`、5 个测试分片和 `pi-tui` 全部通过;Windows job 按工作流条件跳过。
+- [Nix Build run 30973570206](https://github.com/Pidbid/kkm/actions/runs/30973570206):flake workspace 同步检查和 `nix build .#kimi-code` 全部通过。
+- 合并记录:通过 [Pidbid/kkm#5](https://github.com/Pidbid/kkm/pull/5) 使用 merge commit 合并;该 PR 与本文件共同保留最终合并提交和逐项来源。
+
 ## 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..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';
@@ -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/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/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/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-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/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts
index c8dcfd92e0..6f505a3dc7 100644
--- a/packages/agent-core-v2/test/agent/loop/loop.test.ts
+++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts
@@ -105,8 +105,8 @@ 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": "