Procházet zdrojové kódy

Merge remote-tracking branch 'origin/master' into xtr/durable-inbox-web-recovery

_Kerman před 2 týdny
rodič
revize
4d306deb0a
60 změnil soubory, kde provedl 685 přidání a 155 odebrání
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml
  2. 4 2
      .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md
  3. 4 2
      .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml
  5. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md
  6. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
  8. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
  9. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
  10. 6 0
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.i18n.yaml
  11. 27 0
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md
  12. 27 0
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md
  13. 6 0
      .agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.i18n.yaml
  14. 25 0
      .agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.md
  15. 25 0
      .agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.zh.md
  16. 2 2
      apps/cli/README.i18n.yaml
  17. 2 2
      apps/cli/README.md
  18. 2 2
      apps/cli/README.zh.md
  19. 1 1
      apps/cli/package.json
  20. 2 2
      apps/cli/reference/README.i18n.yaml
  21. 8 8
      apps/cli/reference/README.md
  22. 8 8
      apps/cli/reference/README.zh.md
  23. 37 52
      apps/cli/src/args.ts
  24. 58 6
      apps/cli/tests/args.spec.ts
  25. 13 12
      apps/cli/tests/built-bin.e2e.ts
  26. 30 0
      apps/cli/tests/expected/launcher-help.txt
  27. 1 1
      apps/cli/tests/profiles/AGENTS.md
  28. 1 1
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  29. 4 4
      apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
  30. 12 1
      apps/web/tests/expected/models-settings/declared-edit.expected.md
  31. 16 4
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  32. 6 1
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  33. 33 0
      apps/web/tests/models-settings.e2e.ts
  34. 18 4
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  35. 2 2
      docs/architecture.i18n.yaml
  36. 1 1
      docs/architecture.md
  37. 1 1
      docs/architecture.zh.md
  38. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  39. 3 1
      packages/client/ui-settings-models/README.md
  40. 3 1
      packages/client/ui-settings-models/README.zh.md
  41. 10 1
      packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx
  42. 61 0
      packages/client/ui-settings-models/src/client/ModelImageInput.tsx
  43. 9 2
      packages/client/ui-settings-models/src/client/ModelListEditor.tsx
  44. 2 2
      packages/client/ui-settings-models/src/client/ModelsSection.module.css
  45. 12 2
      packages/client/ui-settings-models/src/client/locales.ts
  46. 2 1
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  47. 50 0
      packages/client/ui-settings-models/tests/model-image-input.client.spec.tsx
  48. 18 1
      packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
  49. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  50. 1 1
      packages/llm/llm-deepseek/README.md
  51. 1 1
      packages/llm/llm-deepseek/README.zh.md
  52. 5 2
      packages/llm/llm-deepseek/src/common/file-store.ts
  53. 5 5
      packages/llm/llm-deepseek/src/common/files-api.ts
  54. 14 0
      packages/llm/llm-deepseek/src/common/messages-api.ts
  55. 2 2
      packages/llm/llm-deepseek/src/protocols/messages/adapter.ts
  56. 1 1
      packages/llm/llm-deepseek/src/protocols/messages/types.ts
  57. 1 0
      packages/llm/llm-deepseek/tests/file-store.spec.ts
  58. 15 0
      packages/llm/llm-deepseek/tests/files-api.spec.ts
  59. 2 1
      packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts
  60. 18 0
      packages/llm/llm-deepseek/tests/messages/adapter.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md
-2026-08-22-single-dsh-application-launcher.md: 630040c75c4c20c57b5e26288663265174331ca5
-2026-08-22-single-dsh-application-launcher.zh.md: 9bd51e6c4e8b7ab60cbabc384431bc4e22a6b522
+2026-08-22-single-dsh-application-launcher.md: 46a627eed652a0a0edaf7b900510b5d369efcdf0
+2026-08-22-single-dsh-application-launcher.zh.md: b7fa0f2b35c2fbf6c3a28f463b73170c84547a40

+ 4 - 2
.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md

@@ -14,7 +14,7 @@ The Python SDK distributes a native executable through four platform wheels. Its
 
 ### Launch scope
 
-Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, `dsh --profile sdk-minimal`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry.
+Every supported Node application starts through the `dsh` CLI and one named profile. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile <name>` or `dsh <name>`. `plugin` names the management command; a profile with that name requires `--profile plugin`.
 
 Vendor CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are outside the application-launch inventory. A package app bin or root demo that launches a package entry is not an accepted extension point.
 
@@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher
 
 ### Python runtime
 
-The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application.
+The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile.
 
 The executable family is `deepseek-harness-sdk-runtime-<platform>-<arch>`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. [docs/architecture.md](../../../../docs/architecture.md) owns this launch, and the [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows carrier.
 
@@ -56,6 +56,8 @@ The executable family is `deepseek-harness-sdk-runtime-<platform>-<arch>`. The S
 
 ## Existing decisions and supersession
 
+[Profile command shorthand](../feature/2026-09-15-profile-command-shorthand.md) supersedes this note's Web-only shorthand mechanism; this note retains authority over application composition and lifecycle ownership.
+
 This decision supersedes the application-launch and package-name facts in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md), [TypeScript SDK client and subagent backend](../../archived/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md), [remove the SDK project toolchain](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md), and [single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md). Those notes retain independent authority for profile layering, client/wire semantics, deleted project tooling, and native packaging.
 
 The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [adding-a-package cookbook](../../../../docs/cookbook/adding-a-package.md) owns role-based package names. The [standalone sdk-minimal profile](../../archived/architecture/2026-08-24-standalone-sdk-minimal-profile.md) partially supersedes this note's base-first rule and complete-tree alternative while retaining this note's launcher ownership. No active note is fully superseded or eligible for archival.

+ 4 - 2
.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md

@@ -14,7 +14,7 @@ Python SDK 通过四个平台 wheel 包分发原生可执行文件。其打包
 
 ### 启动范围
 
-所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk`、`dsh --profile sdk-minimal` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。
+所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile <name>` 或 `dsh <name>` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。
 
 Vendor CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于应用启动清单。包应用 bin 或直接启动包入口的根 demo 都不是可接受的扩展点。
 
@@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile <name> ...` 管
 
 ### Python 运行时
 
-Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。
+Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。
 
 可执行文件族是 `deepseek-harness-sdk-runtime-<platform>-<arch>`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[docs/architecture.md](../../../../docs/architecture.zh.md)负责该启动方式,[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 载体。
 
@@ -56,6 +56,8 @@ Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../..
 
 ## 既有决策与取代关系
 
+[Profile 命令简写](../feature/2026-09-15-profile-command-shorthand.zh.md)取代本 Note 中仅为 Web 提供简写的机制;本 Note 继续负责应用组合与生命周期的所有权。
+
 本决策取代 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)、[TypeScript SDK 客户端与 SDK subagent 后端](../../archived/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)、[移除 SDK 项目工具链](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md)和[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的应用启动与包名事实。这些 Note 对 profile 分层、客户端/协议语义、已删除的项目工具链与原生打包仍分别具有独立权威。
 
 [ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md)负责基于角色的包名。[独立 sdk-minimal profile](../../archived/architecture/2026-08-24-standalone-sdk-minimal-profile.md)部分取代本 Note 的 base 优先规则与完整配置树替代方案,同时保留本 Note 对 launcher 所有权的决策。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md
+2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3
+2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md

@@ -0,0 +1,25 @@
+# Agent Note: Exact v1 recognition for Messages base URLs
+
+Status: implemented
+
+English | [中文](2026-09-15-messages-v1-base-url.zh.md)
+
+## Problem
+
+The Messages transport appends the Anthropic-standard `/v1` namespace to a configured base URL. A base that already ends in `/v1` previously produced `/v1/v1/messages`, while recognizing every `v`-plus-digit suffix as a provider version granted undocumented compatibility and could bypass the standard namespace.
+
+## Decision
+
+The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged.
+
+Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them.
+
+## Alternatives considered
+
+**Recognize any final segment beginning with `v` and a digit.** This avoids repetition for more custom endpoints, but it turns a narrow duplicate-`v1` repair into an undocumented compatibility policy and can route requests outside the Anthropic-standard namespace.
+
+**Always append `/v1`.** This follows the standard path for unversioned roots but preserves the original duplicate path for callers whose configured base already ends in `/v1`.
+
+## Consequences
+
+Messages, Files, and file-cache identity use one deterministic rule. Exact `/v1` configurations remain compatible without changing the recommended unversioned base. Other version-like suffixes are not treated as API versions and therefore resolve beneath an added `/v1`; this deliberately gives up speculative proxy compatibility.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Messages 基址严格识别 v1
+
+Status: implemented
+
+[English](2026-09-15-messages-v1-base-url.md) | 中文
+
+## 问题
+
+Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空间。此前,已经以 `/v1` 结尾的基址会生成 `/v1/v1/messages`;而把所有 `v` 加数字的后缀都识别为提供方版本,会提供未经说明的兼容性,并可能绕过标准命名空间。
+
+## 决策
+
+共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。
+
+Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。
+
+## 考虑过的替代方案
+
+**识别所有以 `v` 加数字开头的末尾路径段。** 这可以避免更多自定义端点重复版本,但会把范围有限的 `v1` 重复修复变成未经说明的兼容策略,并可能把请求路由到 Anthropic 标准命名空间之外。
+
+**始终追加 `/v1`。** 这对无版本根地址遵循标准路径,但仍会为已以 `/v1` 结尾的配置产生原有重复路径。
+
+## 结果
+
+Messages、Files 与文件缓存标识使用同一条确定性规则。严格匹配 `/v1` 的配置保持兼容,推荐的无版本基址无需改变。其他版本式后缀不被视为 API 版本,因此会在其下追加 `/v1`;这会有意放弃对代理的推测性兼容。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
-2026-09-07-deepseek-messages-adapter.md: 9a92f9e95931bebfa9fa6e7e64fbd7d27ec8306c
-2026-09-07-deepseek-messages-adapter.zh.md: 67f6c97c0a416cbddb7ec9d0de01e47e658036b7
+2026-09-07-deepseek-messages-adapter.md: 6b7aff250aacd4bb7d0af3b4e68ee5b2002c13e3
+2026-09-07-deepseek-messages-adapter.zh.md: 829e2189cb48a2acaa1ec27f7ccade0f163b58ad

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md

@@ -16,11 +16,11 @@ The adapter follows the [DeepSeek compatibility documentation](https://api-docs.
 
 Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; content validation such as tool argument parsing still fails explicitly. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
 
-Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages uses `/v1/files` with its required beta header, while Chat Completions uses `/files`. Cached ids remain scoped by configured endpoint and credential. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
+Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
 
 System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint.
 
-Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default; a custom `baseURL` or environment override is never rewritten to match a protocol.
+Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment.
 
 Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md

@@ -16,11 +16,11 @@ Status: implemented
 
 助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;工具参数等内容校验仍会正常报错。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
 
-两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 使用 `/v1/files` 并携带必需的 beta 标头,Chat Completions 使用 `/files`。缓存 id 仍按配置的端点和凭据限定作用域。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
+两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
 
 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。
 
-Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值;不会为匹配协议而改写自定义 `baseURL` 或环境覆盖。
+Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。
 
 两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md
+2026-09-14-model-image-input-settings.md: ef328400332aa58c02a450ead0195eff124c5fdf
+2026-09-14-model-image-input-settings.zh.md: 70ec427b138026124cad2ffdb1fc5f60597abe8a

+ 27 - 0
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md

@@ -0,0 +1,27 @@
+# Agent Note: Model image-input settings
+
+Status: implemented
+
+English | [中文](2026-09-14-model-image-input-settings.zh.md)
+
+## Problem
+
+Models settings can edit a model id without exposing the input capabilities that determine whether image attachments are accepted. A custom vision model can therefore appear in the picker while retaining a text-only declaration.
+
+## Decision
+
+Each model row exposes image input under Model options. Supported declares text and image; Not supported declares text only; Default removes the model's explicit input field. DeepSeek writes `inputModalities`, whose absent value means text only. Pi-ai writes `input`, whose absent or empty value inherits the installed model catalog or provider default. Opening a row preserves that inheritance without materializing an override.
+
+The shared field replaces one drafted row and preserves unrelated metadata. Selecting text only or default for DeepSeek also removes its image request limits, because the adapter rejects those limits without image input. Saving uses the existing catalog-array settings mutation and adapter validation. Configuration declares an upstream capability; it does not add image processing to a text-only model.
+
+## Alternatives considered
+
+**Keep `input` editable only in the settings document.** The [earlier pi-ai modality decision](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md) kept this field outside the model-list editor. That leaves users who add custom vision models through the UI unable to enable their image input there. Per-row editing supplies that configuration while the default choice preserves catalog inheritance.
+
+**A two-state switch.** Treating an absent pi-ai declaration as disabled would misrepresent inherited vision support and encourage overwriting catalog defaults. The explicit Default choice preserves the adapter's existing resolution rules.
+
+**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields.
+
+## Consequences
+
+Users can configure image input for DeepSeek and custom pi-ai model rows through the same control. Restoring defaults can change effective capabilities when the installed catalog or provider defaults change. DeepSeek image limits must be configured again after disabling images. Provider routing and the [catalog recovery rules](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) remain owned by their existing decisions.

+ 27 - 0
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:模型图片输入设置
+
+Status: implemented
+
+[English](2026-09-14-model-image-input-settings.md) | 中文
+
+## 问题
+
+模型设置可以编辑模型 ID,却没有展示决定图片附件是否被接受的输入能力。因此,自定义视觉模型虽然出现在选择器中,仍可能保留仅文本的声明。
+
+## 决策
+
+每个模型行在「模型选项」下提供图片输入设置。「支持」声明文本和图片;「不支持」声明仅文本;「默认」移除模型的显式输入字段。DeepSeek 写入 `inputModalities`,缺省时表示仅文本。Pi-ai 写入 `input`,缺省或空数组时继承已安装模型目录或提供方默认值。打开模型行保留这种继承,不会生成覆盖值。
+
+共享字段替换一个草稿模型行并保留无关元数据。DeepSeek 选择仅文本或默认时,还会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组设置变更和适配器校验。配置声明上游能力,不会为仅文本模型增加图片处理能力。
+
+## 考虑过的替代方案
+
+**仅允许在设置文档中编辑 `input`。** [早期 pi-ai 输入模态决策](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md)将该字段留在模型列表编辑器之外。这使通过 UI 添加自定义视觉模型的用户无法在同一界面启用图片输入。逐行编辑提供了该配置,而默认选项保留模型目录继承。
+
+**两态开关。** 将缺省的 pi-ai 声明视为禁用,会错误表达继承的视觉能力,并促使用户覆盖模型目录的默认值。显式「默认」选项保留适配器现有的解析规则。
+
+**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,使选择的仅文本状态有效,同时保留无关模型字段。
+
+## 影响
+
+用户可以通过相同控件配置 DeepSeek 和自定义 pi-ai 模型行的图片输入。恢复默认值后,有效能力可能随已安装模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)仍由现有决策负责。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.md
+2026-09-15-profile-command-shorthand.md: 15bdf30b98eb814e299e8e2c3af3756b1f59aad1
+2026-09-15-profile-command-shorthand.zh.md: 77ffd2324fc8fe518368fce44f52ad85274eb7b5

+ 25 - 0
.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.md

@@ -0,0 +1,25 @@
+# Agent Note: Profile command shorthand
+
+Status: implemented
+
+English | [中文](2026-09-15-profile-command-shorthand.zh.md)
+
+## Problem
+
+Profile launch needs a concise spelling that works for custom names without making plugin management depend on the contents of the Harness home.
+
+## Decision
+
+The CLI expands a leading non-option argument other than `plugin` into `--profile <name>` before parsing. Both spellings use the same launcher flags, app-argument forwarding, and profile validation. `plugin` retains command priority only as the first argument; `dsh --profile plugin` selects the same-named profile explicitly. After profile selection, `plugin` is forwarded as an app argument. Repeated profile selection before app arguments is rejected.
+
+This decision supersedes the Web-only shorthand mechanism in [one dsh application launcher](../architecture/2026-08-22-single-dsh-application-launcher.md); that note retains authority over application composition and lifecycle ownership.
+
+## Alternatives considered
+
+- Registering profiles as commands requires filesystem discovery and makes parsing depend on installed profiles.
+- Giving profiles priority over built-in commands makes installing a profile change the meaning of plugin-management invocations.
+- Last-wins profile selection can launch a different app from the leading name; explicit rejection avoids that ambiguity.
+
+## Consequences
+
+Custom profiles and shipped profiles share one shorthand without adding public types. Names must immediately follow `dsh`; an unknown name reaches the existing missing-profile diagnostic. Removing the dedicated `web` command also lets an already selected profile receive `web` as an app argument. Parser equivalence tests, built-bin acceptance, and the keyless headless tool round trip cover the shared launch path.

+ 25 - 0
.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Profile 命令简写
+
+Status: implemented
+
+[English](2026-09-15-profile-command-shorthand.md) | 中文
+
+## Problem
+
+Profile 启动需要一种适用于自定义名称的简洁写法,同时不能让插件管理依赖 Harness home 中的内容。
+
+## Decision
+
+CLI 在解析前,将开头非选项且非 `plugin` 的参数展开为 `--profile <name>`。两种写法使用相同的启动器 flag、应用参数透传和 profile 校验。`plugin` 仅在首个参数位置保持命令优先级;`dsh --profile plugin` 显式选择同名 profile。选定 profile 后,`plugin` 作为应用参数透传。应用参数开始之前,重复选择 profile 会被拒绝。
+
+本决策取代[统一 dsh 应用启动器](../architecture/2026-08-22-single-dsh-application-launcher.zh.md)中仅为 Web 提供简写的机制;该 Note 继续负责应用组合与生命周期的所有权。
+
+## Alternatives considered
+
+- 将 profile 注册为命令需要扫描文件系统,并使解析依赖已安装的 profile。
+- 让 profile 优先于内置命令,会使安装 profile 改变插件管理调用的含义。
+- 让后一次 profile 选择覆盖前一次,可能启动与开头名称不同的应用;显式拒绝可避免这种歧义。
+
+## Consequences
+
+自定义和内置 profile 共用一种简写,无需新增公开类型。名称必须紧跟 `dsh`;未知名称会触发现有的 profile 缺失诊断。移除专用的 `web` 命令后,已选定的 profile 也能将 `web` 作为应用参数接收。解析等价性测试、构建产物验收和无密钥 headless 工具往返场景覆盖共用的启动路径。

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: 92bd282b1148217a41ce12bbb4d227df088b13a0
-README.zh.md: b64ed8f81fb28acb70d96b1b0c116ea03dbd5fbb
+README.md: 68500e54372d16a9ead8e548eed5a7e4836be3fb
+README.zh.md: a1002812c3782f898d89793d89a50a1ab4ea4e1e

+ 2 - 2
apps/cli/README.md

@@ -8,13 +8,13 @@ The `dsh` command is the sole supported Node application launcher: profiles are
 
 | Command | Purpose |
 |---|---|
-| `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
+| `dsh <name>` / `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
 | `dsh --profile <name> --from-default-profile <template>` | Create a new custom profile from a shipped template, then boot it. |
 | `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. |
 | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. |
 | `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. |
 | `dsh --profile sdk-minimal` | Serve SDK clients with the standalone minimal agent tree. |
-| `dsh web` | Alias of `--profile web`. |
+| `dsh web` | Boot the Web profile. |
 | `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
 
 The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it.

+ 2 - 2
apps/cli/README.zh.md

@@ -8,13 +8,13 @@
 
 | 命令 | 用途 |
 |---|---|
-| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
+| `dsh <name>` / `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
 | `dsh --profile <name> --from-default-profile <template>` | 从随附模板创建新的自定义 profile,然后启动它。 |
 | `dsh --profile acp` | 通过 ACP stdio 为自动化客户端提供服务,直至断开连接。 |
 | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 |
 | `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK 客户端提供服务,直至关闭或断开连接。 |
 | `dsh --profile sdk-minimal` | 以独立极简 agent(智能体)配置树为 SDK 客户端提供服务。 |
-| `dsh web` | `--profile web` 的别名。 |
+| `dsh web` | 启动 Web profile。 |
 | `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
 
 运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动、配置 dump 和插件管理请求。

+ 1 - 1
apps/cli/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh",
-  "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
+  "description": "dsh CLI: profile launch, plugin management, and configuration inspection",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"

+ 2 - 2
apps/cli/reference/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/reference/README.md
-README.md: dcf9f6e3ad2304bf13a5497b9252638fdef2638f
-README.zh.md: c1138bbed8a884757d97c445061bb1686f47b44c
+README.md: dd8256fbe81d8a0e3d6993875f577cbc6f8b027e
+README.zh.md: a7d9417227feb59cfca9bb04167e5258a6840d69

+ 8 - 8
apps/cli/reference/README.md

@@ -2,11 +2,11 @@
 
 English | [中文](README.zh.md)
 
-This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
+This reference defines the profile, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
 
 ## Profile boot
 
-`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
+`dsh <name>` abbreviates `dsh --profile <name>` and boots the profile at `$DSH_HOME/profiles/<name>`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
 
 Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode.
 
@@ -17,17 +17,17 @@ The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize
 An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
 
 ```sh
-dsh --profile rescue --from-default-profile web
-dsh --profile rescue
+dsh rescue --from-default-profile web
+dsh rescue
 ```
 
 ### App arguments
 
-The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
+The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
 
 A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. With HMR enabled, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
 
-Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
+Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. `plugin` selects plugin management only when it immediately follows `dsh`; after a profile is selected, `plugin` and `web` are ordinary app arguments. Repeated `--profile` options before app arguments are rejected, including an explicit option after a shorthand name. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
 
 The shipped apps own these command lines:
 
@@ -74,9 +74,9 @@ dsh --profile tui
 
 Git-hosted plugins that ship sources build during install through their `prepare` script, which pnpm ≥10 blocks until the consumer allows it: the first `add` fails with pnpm's `allowBuilds` hint (and a dsh pointer at the profile's `pnpm-workspace.yaml`); copy the printed key there and re-run. Installing a built tarball or a local checkout needs no allowance.
 
-## Web alias
+## Web profile
 
-`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
+`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
 
 ```sh
 dsh web

+ 8 - 8
apps/cli/reference/README.zh.md

@@ -2,13 +2,13 @@
 
 [English](README.md) | 中文
 
-本参考定义 profile 启动、web 别名、插件管理和配置 dump 等命令模式。argv 由 [`src/args.ts`](../src/args.ts) 统一解析一次,[`src/bin.ts`](../src/bin.ts) 只会动态导入选中的运行器。
+本参考定义 profile 启动、插件管理和配置 dump 等命令模式。argv 由 [`src/args.ts`](../src/args.ts) 统一解析一次,[`src/bin.ts`](../src/bin.ts) 只会动态导入选中的运行器。
 
 <a id="profile-boot"></a>
 
 ## Profile 启动
 
-`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
+`dsh <name>` 是 `dsh --profile <name>` 的简写,启动位于 `$DSH_HOME/profiles/<name>` 的 profile。简写中的名称必须紧跟 `dsh`;`plugin` 仍为插件管理命令,因此启动同名 profile 时须使用 `dsh --profile plugin`。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
 
 组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。
 
@@ -19,17 +19,17 @@
 profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
 
 ```sh
-dsh --profile rescue --from-default-profile web
-dsh --profile rescue
+dsh rescue --from-default-profile web
+dsh rescue
 ```
 
 ### 应用参数
 
-启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
+启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
 
 每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。启用 HMR 时,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
 
-启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
+启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。`plugin` 仅在紧跟 `dsh` 时选择插件管理命令;选定 profile 后,`plugin` 和 `web` 都是普通应用参数。应用参数开始之前,重复指定 `--profile` 会被拒绝,包括简写后再指定 `--profile` 的情况。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
 
 随附的应用接受以下命令行参数:
 
@@ -76,9 +76,9 @@ dsh --profile tui
 
 随源码发布的 Git 托管插件会在安装期间通过 `prepare` 脚本构建,而 pnpm ≥10 默认会阻止该脚本,直到使用方明确允许。首次运行 `add` 会失败,并显示 pnpm 的 `allowBuilds` 提示;dsh 还会提示应修改该 profile 的 `pnpm-workspace.yaml`。将输出的键复制到该文件后,重新运行命令即可。安装已经构建好的 tarball 或本地 checkout 时,无需加入 `allowBuilds`。
 
-## Web 别名
+## Web Profile
 
-`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
+`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
 
 ```sh
 dsh web

+ 37 - 52
apps/cli/src/args.ts

@@ -10,12 +10,12 @@
  * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
  * and `dsh --profile web -h` prints the web app's help, not this one's.
  *
- * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
+ * `dsh <name>` abbreviates `dsh --profile <name>`; `plugin` manages a profile's
  * plugin dependencies by forwarding to pnpm.
  * @module @deepseek-ai/dsh/args
  */
 
-import { Command, CommanderError } from 'commander'
+import { Command, CommanderError, InvalidArgumentError } from 'commander'
 
 /** Boot a named profile and hand it the invocation's inner arguments. */
 interface ProfileInvocation {
@@ -51,7 +51,7 @@ interface PluginInvocation {
 /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
 export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
 
-/** Launcher flags shared by the default command and the `web` alias. */
+/** Launcher flags for profile boot and configuration dumps. */
 interface BootOptions {
   patch?: string[]
   dumpConfig?: boolean
@@ -65,6 +65,11 @@ interface BootOptions {
  */
 const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
 
+function selectProfile(value: string, previous?: string): string {
+  if (previous !== undefined) throw new InvalidArgumentError('select a profile only once')
+  return value
+}
+
 function rejectElectronProfile(program: Command, profile: string): void {
   if (profile.toLowerCase() === 'desktop') {
     program.error('error: profile "desktop" is managed exclusively by the Electron application')
@@ -74,20 +79,20 @@ function rejectElectronProfile(program: Command, profile: string): void {
 /** The launcher's own help text; each app prints its own. */
 const HELP_EXAMPLES = `
 Examples:
-  dsh --profile web                          boot the web profile (same as: dsh web)
-  dsh --profile rescue --from-default-profile web
-                                             create rescue from the shipped web template, then boot it
-  dsh --profile headless "run the tests"     answer one task, print the result, and exit
-  dsh --profile tui --patch ./extra.yml      boot a custom profile with one extra overlay
-  dsh --profile tui --resume <session>       arguments after the launcher flags reach the app
-  dsh --profile web --help                   the web app's own flags and help
-  dsh plugin --profile tui add <package>     install a plugin into the tui profile
+  dsh web                                   boot the web profile (same as: dsh --profile web)
+  dsh rescue --from-default-profile web
+                                            create rescue from the shipped web template, then boot it
+  dsh headless "run the tests"              answer one task, print the result, and exit
+  dsh tui --patch ./extra.yml               boot a custom profile with one extra overlay
+  dsh tui --resume <session>                arguments after the launcher flags reach the app
+  dsh web --help                            the web app's own flags and help
+  dsh plugin --profile tui add <package>    install a plugin into the tui profile
 `
 
 /**
  * Resolve a boot or dump invocation from the launcher flags and the leftover
  * inner arguments.
- * @param program - the command whose options were parsed (the root, or the `web` alias).
+ * @param program - the command whose options were parsed.
  * @param profile - the profile these flags boot.
  * @param options - the launcher flags commander collected.
  * @param args - the leftover arguments, in argv order.
@@ -124,6 +129,7 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar
  * @returns the resolved invocation.
  */
 export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
+  const first = argv[0]
   let resolved: DshInvocation | undefined
   // Annotated, not inferred: the actions below call back into `program`, and an
   // inferred type would be circular through its own chain.
@@ -131,6 +137,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
   program
     .name('dsh')
     .version(version, '-V, --version', 'output the version number')
+    .usage('[--profile] <name> [options] [app-args...]\n       dsh plugin --profile <name> <pnpm-args...>')
     .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
     .addHelpText('after', HELP_EXAMPLES)
     .exitOverride()
@@ -138,11 +145,12 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
     // know; everything from there on belongs to the booted app, including
     // its -h. `dsh -h` with no profile still prints this help, below.
     .helpOption(false)
+    .helpCommand(false)
     .allowUnknownOption()
     .passThroughOptions()
     .enablePositionalOptions()
     .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
-    .option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
+    .option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot', selectProfile)
     .option('--from-default-profile <name>', 'initialize a new custom profile from a shipped profile template')
     .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
     .option('--dump-config', 'print the composed profile tree and exit')
@@ -160,48 +168,25 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
       resolved = resolveBoot(program, profile, options, args)
     })
 
-  /** Reject parent options supplied before a subcommand. */
-  const rejectParentOptions = (command: string): void => {
-    const parent = program.opts<BootOptions & { profile?: string }>()
-    if (parent.profile !== undefined || parent.patch !== undefined
-      || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined
-      || parent.fromDefaultProfile !== undefined) {
-      program.error(
-        `error: ${command} takes none of parent --profile, --from-default-profile, --patch, --dump-config, or --dump-default-config`,
-      )
-    }
+  if (first === 'plugin') {
+    const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
+    plugin
+      .requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)', selectProfile)
+      .allowUnknownOption()
+      .argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
+      .action((args: string[], options: { profile: string }) => {
+        if (options.profile === '') program.error('error: --profile needs a name')
+        rejectElectronProfile(plugin, options.profile)
+        if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
+        resolved = { mode: 'plugin', profile: options.profile, args }
+      })
   }
 
-  const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
-  web
-    .helpOption(false)
-    .allowUnknownOption()
-    .passThroughOptions()
-    .enablePositionalOptions()
-    .argument('[args...]', 'arguments for the web app (see: dsh web --help)')
-    .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
-    .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
-    .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
-    .action((args: string[], options: BootOptions) => {
-      rejectParentOptions('web')
-      resolved = resolveBoot(web, 'web', options, args)
-    })
-
-  const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
-  plugin
-    .requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)')
-    .allowUnknownOption()
-    .argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
-    .action((args: string[], options: { profile: string }) => {
-      rejectParentOptions('plugin')
-      if (options.profile === '') program.error('error: --profile needs a name')
-      rejectElectronProfile(plugin, options.profile)
-      if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
-      resolved = { mode: 'plugin', profile: options.profile, args }
-    })
-
   try {
-    program.parse(argv, { from: 'user' })
+    const expanded = first !== undefined && !first.startsWith('-') && first !== 'plugin'
+      ? ['--profile', ...argv]
+      : argv
+    program.parse(expanded, { from: 'user' })
   } catch (error) {
     return process.exit(error instanceof CommanderError ? error.exitCode : 1)
   }

+ 58 - 6
apps/cli/tests/args.spec.ts

@@ -21,7 +21,7 @@ function exitCode(argv: string[]): number {
 afterEach(() => { vi.restoreAllMocks() })
 
 describe('parseDshArgs', () => {
-  it('routes profile boots and the web alias, handing the rest to the app', () => {
+  it('routes profile boots and shorthand, handing the rest to the app', () => {
     expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
     expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
       .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
@@ -53,7 +53,61 @@ describe('parseDshArgs', () => {
         args: ['--resume', 'abc', '--from-default-profile', 'web'],
       })
     expect(parse(['web', '--from-default-profile', 'web']))
-      .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--from-default-profile', 'web'] })
+      .toEqual({ mode: 'profile', profile: 'web', fromDefaultProfile: 'web', patches: [], args: [] })
+  })
+
+  it.each(['web', 'headless', 'sdk', 'sdk-minimal', 'acp', 'tui', 'custom', 'run', 'help'])('expands %s without looking up profiles', (profile) => {
+    for (const args of [
+      [], ['task', 'words'], ['--help'], ['-h'], ['web'],
+      ['--patch', 'a.yml', '--patch', 'b.yml'],
+      ['--from-default-profile', 'web', '--help'],
+      ['--dump-config'], ['--dump-default-config'],
+      ['--patch', 'a.yml', '--resume', 'id', '--patch', 'late.yml'],
+      ['--', '--help'], ['--', '--', 'task'],
+      ['plugin'], ['--', 'plugin'],
+    ]) {
+      expect(parse([profile, ...args])).toEqual(parse(['--profile', profile, ...args]))
+    }
+  })
+
+  it('reserves leading plugin for management and forwards later command names', () => {
+    expect(parse(['--profile', 'plugin'])).toMatchObject({ mode: 'profile', profile: 'plugin' })
+    expect(parse(['--profile', 'x', 'plugin', 'add', 'y']))
+      .toMatchObject({ mode: 'profile', profile: 'x', args: ['plugin', 'add', 'y'] })
+    expect(parse(['headless', 'web'])).toMatchObject({ profile: 'headless', args: ['web'] })
+    expect(exitCode(['--patch', 'a.yml', 'tui'])).toBe(1)
+    expect(exitCode(['--', 'tui'])).toBe(1)
+  })
+
+  it.each([
+    [''], ['desktop'], ['Desktop'], ['DESKTOP'],
+    ['custom', '--patch='], ['custom', '--from-default-profile='],
+    ['custom', '--dump-config', '--dump-default-config'],
+    ['custom', '--dump-default-config', '--patch', 'a.yml'],
+    ['custom', '--dump-config', 'task'],
+  ])('rejects invalid shorthand %j', (...argv: string[]) => {
+    expect(exitCode(argv)).toBe(1)
+  })
+
+  it.each(['-V', '--version'])('prints the launcher version for shorthand %s', (flag) => {
+    expect(exitCode(['custom', flag])).toBe(0)
+    expect(parse(['custom', 'task', flag])).toMatchObject({ args: ['task', flag] })
+  })
+
+  it.each([
+    ['web', '--profile', 'tui'],
+    ['--profile', 'web', '--profile', 'tui'],
+    ['--profile=web', '--profile=web'],
+    ['plugin', '--profile', 'web', '--profile', 'tui', 'add', 'x'],
+  ])('rejects repeated profile selection %j', (...argv: string[]) => {
+    const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
+    expect(exitCode(argv)).toBe(1)
+    expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain('select a profile only once')
+  })
+
+  it('forwards late profile options to the application', () => {
+    expect(parse(['web', 'task', '--profile', 'tui']))
+      .toMatchObject({ profile: 'web', args: ['task', '--profile', 'tui'] })
   })
 
   it('routes the plugin pnpm forwarder', () => {
@@ -91,10 +145,8 @@ describe('parseDshArgs', () => {
 
   it('rejects missing profile, removed flags, and contradictory inputs', () => {
     expect(exitCode([])).toBe(1)
-    expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
     expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
     expect(exitCode(['-p', 'task'])).toBe(1) // removed
-    expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
     expect(exitCode(['--profile', ''])).toBe(1)
     expect(exitCode(['--profile', 'x', '--from-default-profile='])).toBe(1)
     expect(exitCode(['--profile', 'x', '--from-default-profile'])).toBe(1)
@@ -104,7 +156,6 @@ describe('parseDshArgs', () => {
     expect(exitCode(['--profile', 'x', '--dump-default-config', '--patch', 'p.yml'])).toBe(1)
     expect(exitCode(['--profile', 'x', '--dump-config', 'task'])).toBe(1)
     expect(exitCode(['--bogus'])).toBe(1)
-    expect(exitCode(['--profile', 'x', 'web'])).toBe(1)
     expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
     expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
     expect(exitCode(['web', '--patch='])).toBe(1)
@@ -122,12 +173,13 @@ describe('parseDshArgs', () => {
     expect(exitCode(['--profile', 'desktop', '--dump-config'])).toBe(1)
     expect(exitCode(['plugin', '--profile', 'desktop', 'add', 'x'])).toBe(1)
     expect(exitCode(['plugin', '--profile', 'Desktop', 'add', 'x'])).toBe(1)
-    expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
     expect(exitCode(['--from-default-profile', 'web', 'plugin', '--profile', 'x', 'add', 'y'])).toBe(1)
   })
 
   it('keeps its own help for an invocation with no app to hand it to', () => {
+    const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
     expect(exitCode(['--help'])).toBe(0)
+    expect(stdout.mock.calls.map(([chunk]) => String(chunk)).join('')).not.toContain('help [command]')
     expect(exitCode(['-h'])).toBe(0)
     expect(exitCode(['--version'])).toBe(0)
   })

+ 13 - 12
apps/cli/tests/built-bin.e2e.ts

@@ -341,17 +341,18 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
 }
 
 describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
-  it('requires --profile and rejects removed commands', async () => {
+  it('requires a profile and rejects removed flags', async () => {
     const bare = await runBuiltBin()
     expect(bare.code).toBe(1)
     expect(bare.stdout).toBe('')
     expect(bare.stderr).toContain('--profile <name> is required')
     const help = await runBuiltBin(['--help'])
     expect(help.code).toBe(0)
+    await expect(help.stdout).toMatchFileSnapshot('./expected/launcher-help.txt')
     expect(help.stdout).toContain('dsh --profile web')
     expect(help.stdout).toContain('dsh plugin --profile')
     expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
-    for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
+    for (const removed of [['--config', 'x.yml'], ['-p', 'task'], ['web', '--profile', 'tui']]) {
       const result = await runBuiltBin(removed)
       expect(result.code).toBe(1)
     }
@@ -379,7 +380,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
       expect(wildcardHost.stderr).not.toContain('dsh web: http://')
 
-      const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
+      const headlessHelp = await runBuiltBin(['headless', '--help'], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
       })
@@ -387,7 +388,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(headlessHelp.stderr).toBe('')
       expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
 
-      const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
+      const sdkHelp = await runBuiltBin(['sdk', '--help'], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
       })
@@ -395,7 +396,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(sdkHelp.stderr).toBe('')
       expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
 
-      const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
+      const acpHelp = await runBuiltBin(['acp', '--help'], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
       })
@@ -655,7 +656,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
   it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
     try {
-      const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
+      const result = await runBuiltBin(['nope'], { DSH_HOME: home })
       expect(result.code).toBe(1)
       expect(result.stderr).toContain('profile "nope" does not exist')
       expect(result.stderr).toContain('dsh plugin --profile nope add')
@@ -668,7 +669,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-'))
     try {
       const created = await runBuiltBin(
-        ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
+        ['rescue', '--from-default-profile', 'web', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(created.code).toBe(0)
@@ -688,7 +689,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
 
       const repeated = await runBuiltBin(
-        ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
+        ['rescue', '--from-default-profile', 'web', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(repeated.code).toBe(1)
@@ -697,7 +698,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(repeated.stderr).toContain('omit --from-default-profile to use it')
 
       const reopened = await runBuiltBin(
-        ['--profile', 'rescue', '--help'],
+        ['rescue', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(reopened.code).toBe(0)
@@ -720,7 +721,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
 
       const retried = await runBuiltBin(
-        ['--profile', 'rescue', '--help'],
+        ['rescue', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(retried.code).toBe(0)
@@ -1185,7 +1186,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     afterEach(() => { rmSync(home, { recursive: true, force: true }) })
 
     it('prints the web profile bundle layers without a user layer', async () => {
-      const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
+      const { stdout, code, stderr } = await runBuiltBin(['web', '--dump-default-config'], { DSH_HOME: home })
       expect(code).toBe(0)
       expect(stderr).toBe('')
       expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
@@ -1280,7 +1281,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
 
     it('composes the profile user layer and a --patch overlay in order', async () => {
       // Auto-init the web profile first, then write its user layer.
-      const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
+      const init = await runBuiltBin(['web', '--dump-default-config'], { DSH_HOME: home })
       expect(init.code).toBe(0)
       const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
       writeFileSync(profilePatch, [

+ 30 - 0
apps/cli/tests/expected/launcher-help.txt

@@ -0,0 +1,30 @@
+Usage: dsh [--profile] <name> [options] [app-args...]
+       dsh plugin --profile <name> <pnpm-args...>
+
+dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch
+layers under your own overrides.
+
+Arguments:
+  args                           arguments for the booted profile's app (see:
+                                 dsh --profile <name> --help)
+
+Options:
+  -V, --version                  output the version number
+  --profile <name>               the profile under $DSH_HOME/profiles to boot
+  --from-default-profile <name>  initialize a new custom profile from a shipped
+                                 profile template
+  --patch <path>                 extra patch-list overlay applied after the
+                                 profile layer (repeatable)
+  --dump-config                  print the composed profile tree and exit
+  --dump-default-config          print the profile tree without its user layer
+                                 or --patch overlays and exit
+
+Examples:
+  dsh web                                   boot the web profile (same as: dsh --profile web)
+  dsh rescue --from-default-profile web
+                                            create rescue from the shipped web template, then boot it
+  dsh headless "run the tests"              answer one task, print the result, and exit
+  dsh tui --patch ./extra.yml               boot a custom profile with one extra overlay
+  dsh tui --resume <session>                arguments after the launcher flags reach the app
+  dsh web --help                            the web app's own flags and help
+  dsh plugin --profile tui add <package>    install a plugin into the tui profile

+ 1 - 1
apps/cli/tests/profiles/AGENTS.md

@@ -1,6 +1,6 @@
 # AGENTS.md — Profile integration tests
 
-This tree owns cross-package behavior of shipped `dsh` profiles. Start product scenarios through `apps/cli/src/bin.ts --profile <name>`; a test-only Loader driver is allowed only when the public profile output cannot expose the asserted internal evidence.
+This tree owns cross-package behavior of shipped `dsh` profiles. Start product scenarios through `apps/cli/src/bin.ts` with `--profile <name>` or the `<name>` shorthand; a test-only Loader driver is allowed only when the public profile output cannot expose the asserted internal evidence.
 
 Keep a composition here only when the CLI profile assembly is the subject. Move package-specific Loader configurations and drivers into that package's `tests/fixtures/`. Recorded-session replay belongs under top-level `snapshots/`; other expected output uses `*.expected.e2e.ts` and an owner-local `expected/` directory.
 

+ 1 - 1
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -241,7 +241,7 @@ describe('headless stream-json snapshots', () => {
       tempDirPrefix: 'headless-snapshot-profile-',
       binScript: dshBinScript,
       configPath: headlessOverlayPath,
-      binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, task],
+      binArgs: ['headless', '--patch', headlessOverlayPath, task],
       tsconfigPath,
       env: {
         DSH_PERMISSION_MODE: 'danger-full-access',

+ 4 - 4
apps/web/tests/expected/deepseek-messages-settings/cards.expected.md

@@ -43,7 +43,7 @@
           - textbox "显示名称 1":
             - /placeholder: 显示名称
             - text: DeepSeek-V41-Flash
-          - button "容量 1":
+          - button "模型选项 1":
             - img
           - button "删除模型 1":
             - img
@@ -53,7 +53,7 @@
           - textbox "显示名称 2":
             - /placeholder: 显示名称
             - text: DeepSeek-V4-Flash
-          - button "容量 2":
+          - button "模型选项 2":
             - img
           - button "删除模型 2":
             - img
@@ -63,7 +63,7 @@
           - textbox "显示名称 3":
             - /placeholder: 显示名称
             - text: DeepSeek-V4-Pro
-          - button "容量 3":
+          - button "模型选项 3":
             - img
           - button "删除模型 3":
             - img
@@ -73,7 +73,7 @@
           - textbox "显示名称 4":
             - /placeholder: 显示名称
             - text: DeepSeek-V4-Flash-Vision-Exp
-          - button "容量 4":
+          - button "模型选项 4":
             - img
           - button "删除模型 4":
             - img

+ 12 - 1
apps/web/tests/expected/models-settings/declared-edit.expected.md

@@ -58,8 +58,19 @@
             - text: acme-large
           - textbox "显示名称 1":
             - /placeholder: 显示名称
-          - button "容量 1"
+          - button "模型选项 1" [expanded]
           - button "删除模型 1"
+          - text: 上下文窗口
+          - textbox "上下文窗口 1":
+            - /placeholder: 256K
+          - text: 最大输出 token
+          - textbox "最大输出 token 1":
+            - /placeholder: 32K
+          - text: 图片输入
+          - combobox "图片输入 1":
+            - option "使用默认值"
+            - option "支持" [selected]
+            - option "不支持"
           - button "添加模型"
       - button "取消"
       - button "保存"

+ 16 - 4
apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md

@@ -43,17 +43,29 @@
           - textbox "显示名称 1":
             - /placeholder: 显示名称
             - text: DeepSeek-V41-Flash
-          - button "容量 1":
+          - button "模型选项 1" [expanded]:
             - img
           - button "删除模型 1":
             - img
+          - text: 上下文窗口
+          - textbox "上下文窗口 1":
+            - /placeholder: 1M
+            - text: 1M
+          - text: 最大输出 token 数
+          - textbox "最大输出 token 数 1":
+            - /placeholder: 256K
+          - text: 图片输入
+          - combobox "图片输入 1":
+            - option "默认(仅文本)"
+            - option "支持" [selected]
+            - option "不支持"
           - textbox "模型 ID 2":
             - /placeholder: 模型 ID
             - text: deepseek-v4-flash
           - textbox "显示名称 2":
             - /placeholder: 显示名称
             - text: DeepSeek-V4-Flash
-          - button "容量 2":
+          - button "模型选项 2":
             - img
           - button "删除模型 2":
             - img
@@ -63,7 +75,7 @@
           - textbox "显示名称 3":
             - /placeholder: 显示名称
             - text: DeepSeek-V4-Pro
-          - button "容量 3":
+          - button "模型选项 3":
             - img
           - button "删除模型 3":
             - img
@@ -73,7 +85,7 @@
           - textbox "显示名称 4":
             - /placeholder: 显示名称
             - text: DeepSeek-V4-Flash-Vision-Exp
-          - button "容量 4":
+          - button "模型选项 4":
             - img
           - button "删除模型 4":
             - img

+ 6 - 1
apps/web/tests/expected/onboarding-deepseek-config/models.expected.md

@@ -44,7 +44,7 @@
           - textbox "显示名称 1":
             - /placeholder: 显示名称
             - text: Private Preview
-          - button "容量 1" [expanded]:
+          - button "模型选项 1" [expanded]:
             - img
           - button "删除模型 1":
             - img
@@ -56,6 +56,11 @@
           - textbox "最大输出 token 数 1":
             - /placeholder: 256K
             - text: 64K
+          - text: 图片输入
+          - combobox "图片输入 1":
+            - option "默认(仅文本)"
+            - option "支持" [selected]
+            - option "不支持"
           - button "添加模型":
             - img
             - text: 添加模型

+ 33 - 0
apps/web/tests/models-settings.e2e.ts

@@ -238,12 +238,18 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     expect(await dialog.getByLabel('推理强度').count()).toBe(0)
     await dialog.getByRole('button', { name: '添加模型' }).click()
     await dialog.getByLabel('模型 ID 1').fill('acme-large')
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('default')
+    await dialog.getByLabel('图片输入 1').selectOption('enabled')
     await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
 
     const row = dialog.getByText('Acme Gateway', { exact: true }).first()
     await row.waitFor({ timeout: 10_000 })
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(document).toContain('acme-gateway:')
+    await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
+      inputModalities: ['text', 'image'],
+    })
 
     // The tag follows the adapter's installed catalog: this route is in no
     // catalog, while minimax-cn is — even though both now have profiles.
@@ -269,11 +275,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     expect(await protocol.inputValue()).toBe('openai-completions')
     const name = dialog.getByLabel('显示名称', { exact: true })
     expect(await name.inputValue()).toBe('Acme Gateway')
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('enabled')
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
 
     await protocol.selectOption('anthropic-messages')
     await name.fill('Acme 网关')
+    await dialog.getByLabel('图片输入 1').selectOption('disabled')
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
     await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
     // The adapter re-resolved the route under the new protocol and re-registered
@@ -287,6 +296,30 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(document).toContain('api: anthropic-messages')
     expect(document).toContain('displayName: Acme 网关')
+    await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
+      inputModalities: ['text'],
+    })
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('restores the provider default for image input', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-image-default'))
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
+    await dialog.getByText('自定义设置').click()
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('disabled')
+    await dialog.getByLabel('图片输入 1').selectOption('default')
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await dialog.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 10_000 })
+    await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
+      inputModalities: ['text'],
+    })
+    await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
+    await dialog.getByText('自定义设置').click()
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('default')
+    await dialog.getByRole('button', { name: '取消', exact: true }).click()
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 

+ 18 - 4
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -209,19 +209,24 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     expect(await settings.getByLabel('模型 ID 3').inputValue()).toBe('deepseek-v4-pro')
     expect(await settings.getByLabel('模型 ID 4').inputValue()).toBe('deepseek-v4-flash-vision-exp')
     expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(4)
+    await settings.getByRole('button', { name: '模型选项 1' }).click()
+    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('enabled')
     const defaultModels = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DEFAULT_MODELS_EXPECTED, defaultModels, MODE)
     await settings.getByLabel('显示名称 1').fill('Configured Flash')
+    await settings.getByLabel('图片输入 1').selectOption('disabled')
     await settings.getByRole('button', { name: '保存', exact: true }).click()
     await settings.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 15_000 })
     const savedDefaults = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(savedDefaults).toContain('id: deepseek-flash')
     expect(savedDefaults).toContain('inputModalities:')
     expect(savedDefaults).toContain('- text')
-    expect(savedDefaults).toContain('- image')
     expect(savedDefaults).toContain('systemPromptUpdate: in-history')
     await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
-      name: 'Configured Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
+      name: 'Configured Flash', inputModalities: ['text'], systemPromptUpdate: 'in-history',
+    })
+    await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash-vision-exp')).resolves.toMatchObject({
+      inputModalities: ['text', 'image'],
     })
     await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
     await settings.getByText('自定义设置').click()
@@ -232,10 +237,11 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     const customModelId = settings.getByLabel('模型 ID 1')
     await customModelId.fill('private-preview')
     await settings.getByLabel('显示名称 1').fill('Private Preview')
-    // Capacities live behind the row's own disclosure, as in the pi-ai form.
-    await settings.getByRole('button', { name: '容量 1' }).click()
+    await settings.getByRole('button', { name: '模型选项 1' }).click()
     await settings.getByLabel('上下文窗口 1').fill('131072')
     await settings.getByLabel('最大输出 token 数 1').fill('64K')
+    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('default')
+    await settings.getByLabel('图片输入 1').selectOption('enabled')
 
     await expect.poll(
       () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
@@ -252,6 +258,14 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     expect(document).toContain('contextWindow: 131072')
     expect(document).toContain('maxTokens: 64000')
     expect(document).not.toContain('id: deepseek-flash')
+    await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'private-preview')).resolves.toMatchObject({
+      inputModalities: ['text', 'image'],
+    })
+    await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
+    await settings.getByText('自定义设置').click()
+    await settings.getByRole('button', { name: '模型选项 1' }).click()
+    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('enabled')
+    await settings.getByRole('button', { name: '取消', exact: true }).click()
 
     await page.keyboard.press('Escape')
     // A connected Workspace is what puts a live composer — and its model

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: a900c1777d3c8bd05b3a867715ed86701c07495f
-architecture.zh.md: 8f83839208c5b53d5db36a341ebf48e1ecb43250
+architecture.md: 37aaf83e37fb5cb7e3df4bd6ed034ccd42103919
+architecture.zh.md: 084fa76a045a9ae744228e8e43900f174b26ae9e

+ 1 - 1
docs/architecture.md

@@ -42,7 +42,7 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof
 
 ## Application launch
 
-Every supported Node application starts at the `dsh` CLI with a named profile. The shipped applications are `dsh web` (the deliberate alias for `--profile web`), `dsh --profile headless`, `dsh --profile sdk`, `dsh --profile sdk-minimal`, and `dsh --profile acp`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree.
+Supported Node applications launch through named `dsh` profiles. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile <name>` or `dsh <name>`. `plugin` names the management command; a profile with that name requires `--profile plugin`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree.
 
 Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`.
 

+ 1 - 1
docs/architecture.zh.md

@@ -42,7 +42,7 @@ dsh --profile web --dump-config
 
 ## 应用启动
 
-所有受支持的 Node 应用都从 `dsh` CLI 与具名 profile 启动。随附应用是 `dsh web`(刻意为 `--profile web` 保留的别名)、`dsh --profile headless`、`dsh --profile sdk`、`dsh --profile sdk-minimal` 与 `dsh --profile acp`。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。
+受支持的 Node 应用通过具名 `dsh` profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile <name>` 或 `dsh <name>` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。
 
 Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。
 

+ 2 - 2
packages/client/ui-settings-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: f1314b1db9ee231b2f67777491ac57aefc5ea85a
-README.zh.md: 17861766c52333e90947a6c643c7df8cab4992b4
+README.md: a26cd22f8785f8f2c948ac74560ff85d80097577
+README.zh.md: 82b9e031b477aa83c59acdfbade38f8282adefbd

+ 3 - 1
packages/client/ui-settings-models/README.md

@@ -35,10 +35,12 @@ The primary field on an editor card is a single **API key** input — the page n
 
 ### Editing a provider
 
-The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits.
+The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and image input; unrelated model fields survive edits.
 
 The DeepSeek card edits the shared `llm-deepseek` endpoint, credentials, and model catalog without a protocol selector. When Cordis YAML selects Messages, the public endpoint placeholder is `https://api.deepseek.com/anthropic`. Saving the card preserves protocol configuration.
 
+Expand **Customized settings → Model options → Image input** to declare whether the model supports images. **Supported** writes text and image input; **Not supported** writes text only. DeepSeek's **Default (text only)** removes `inputModalities`; an absent declaration means text only. Pi-ai's **Use default** removes `input` and inherits the installed model catalog or provider default. Selecting **Not supported** or **Default (text only)** for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, which its adapter rejects without image input. Declare support only for models that can actually process images.
+
 ### Adding and deleting providers
 
 The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.

+ 3 - 1
packages/client/ui-settings-models/README.zh.md

@@ -35,10 +35,12 @@ kind: "package-reference"
 
 ### 编辑提供方
 
-收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。
+收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和图片输入;无关的模型字段在编辑后仍会保留。
 
 `llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。
 
+展开**自定义设置 → 模型选项 → 图片输入**,声明模型是否支持图片。选择**支持**会写入文本和图片输入;选择**不支持**会写入仅文本。DeepSeek 的**默认(仅文本)**会移除 `inputModalities`;缺省的声明表示仅文本。Pi-ai 的**使用默认值**会移除 `input`,继承已安装模型目录或提供方的默认值。DeepSeek 选择**不支持**或**默认(仅文本)**时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。仅为实际能够处理图片的模型声明支持。
+
 ### 新增与删除提供方
 
 「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。

+ 10 - 1
packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx

@@ -11,6 +11,7 @@ import {
   IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { en } from './locales.ts'
+import { ModelImageInput } from './ModelImageInput.tsx'
 import styles from './ModelsSection.module.css'
 
 /** One catalog entry kept structurally open so hidden or future fields survive an edit. */
@@ -144,7 +145,7 @@ export interface DeepSeekModelsEditorProps {
 
 /**
  * Render the direct DeepSeek adapter's model catalog: id and display name on
- * each row, capacities behind the row's own disclosure.
+ * each row, capacities and image support behind the row's own disclosure.
  * @param props - effective rows plus the array-level override actions.
  * @returns the catalog editor.
  */
@@ -343,6 +344,14 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
                     <div className={styles['modelAdvanced']}>
                       {capacityField(model, index, 'contextWindow', props.defaultContextWindow)}
                       {capacityField(model, index, 'maxTokens', props.defaultMaxTokens)}
+                      <ModelImageInput
+                        model={model}
+                        field="inputModalities"
+                        position={index + 1}
+                        disabled={props.disabled}
+                        t={props.t}
+                        onChange={(next) => { props.onChange(props.models.map((row, at) => at === index ? next : row)) }}
+                      />
                     </div>
                   )
                   : null}

+ 61 - 0
packages/client/ui-settings-models/src/client/ModelImageInput.tsx

@@ -0,0 +1,61 @@
+/** Image-input declarations shared by the DeepSeek and pi-ai catalog editors. */
+
+import type { ReactNode } from 'react'
+import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
+import type { ModelsKey } from './locales.ts'
+import styles from './ModelsSection.module.css'
+
+/** Props of {@link ModelImageInput}. */
+interface ModelImageInputProps {
+  /** Effective model row, including fields outside the curated editor. */
+  model: DeepSeekModelDraft
+  /** Adapter-owned field; pi-ai inherits capabilities when absent or empty. */
+  field: 'inputModalities' | 'input'
+  /** One-based row position for the accessible label. */
+  position: number
+  /** Prevent changes while read-only or saving. */
+  disabled: boolean
+  /** Section copy. */
+  t: (key: ModelsKey) => string
+  /** Replace this row, preserving unrelated configuration. */
+  onChange: (model: DeepSeekModelDraft) => void
+}
+
+/**
+ * Edit image support while retaining an explicit choice to inherit defaults.
+ * @param props - model declaration and row replacement action.
+ * @returns the labeled image-input selector.
+ */
+export function ModelImageInput({ model, field, position, disabled, t, onChange }: ModelImageInputProps): ReactNode {
+  const modalities = model[field]
+  const value = !Array.isArray(modalities) || modalities.length === 0
+    ? 'default'
+    : modalities.includes('image') ? 'enabled' : 'disabled'
+  return (
+    <label className={styles['modelField']}>
+      <span className={styles['modelFieldLabel']}>{t('modelImageInput')}</span>
+      <select
+        className={`${styles['input']} ${styles['selectInput']}`}
+        aria-label={`${t('modelImageInput')} ${String(position)}`}
+        value={value}
+        disabled={disabled}
+        onChange={(event) => {
+          const choice = event.target.value
+          const next = { ...model }
+          if (choice === 'default') Reflect.deleteProperty(next, field)
+          else next[field] = choice === 'enabled' ? ['text', 'image'] : ['text']
+          // DeepSeek rejects image request limits on a text-only model.
+          if (field === 'inputModalities' && choice !== 'enabled') {
+            Reflect.deleteProperty(next, 'imagePixelBudget')
+            Reflect.deleteProperty(next, 'imageMaxBytes')
+          }
+          onChange(next)
+        }}
+      >
+        <option value="default">{t(field === 'inputModalities' ? 'modelImageDefaultText' : 'modelImageDefault')}</option>
+        <option value="enabled">{t('modelImageEnabled')}</option>
+        <option value="disabled">{t('modelImageDisabled')}</option>
+      </select>
+    </label>
+  )
+}

+ 9 - 2
packages/client/ui-settings-models/src/client/ModelListEditor.tsx

@@ -22,6 +22,7 @@ import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
 import type { ModelsOperations } from './operations.ts'
 import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
 import type { en } from './locales.ts'
+import { ModelImageInput } from './ModelImageInput.tsx'
 import styles from './ModelsSection.module.css'
 
 /**
@@ -163,8 +164,6 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
   const [candidates, setCandidates] = useState<readonly LlmDiscoveredModel[] | undefined>(undefined)
   const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
   const [candidateQuery, setCandidateQuery] = useState('')
-  // Rows carry an id and a name; capacities are the exception, so they stay
-  // folded until asked for rather than crowding every row with four inputs.
   const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set())
   // Capacities are edited as text, so a field's keystrokes are held here rather
   // than re-derived from the parsed count on every change — that would rewrite
@@ -433,6 +432,14 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
                     onChange={(event) => { editCapacity(index, 'maxTokens', event.target.value) }}
                   />
                 </label>
+                <ModelImageInput
+                  model={model}
+                  field="input"
+                  position={index + 1}
+                  disabled={disabled}
+                  t={t}
+                  onChange={(next) => { onChange(models.map((row, at) => at === index ? next : row)) }}
+                />
               </div>
             )
             : null}

+ 2 - 2
packages/client/ui-settings-models/src/client/ModelsSection.module.css

@@ -432,8 +432,8 @@
 }
 
 /* Model list, shared with the pi-ai provider form: one bordered
-   entry per model, id and display name on the row, capacities behind the
-   row's own disclosure. The rules use this stylesheet's token vocabulary —
+   entry per model, id and display name on the row, capacities and image input
+   behind the row's own disclosure. The rules use this stylesheet's token vocabulary —
    `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
    `--dsw-alias-text-primary` are undefined here and would resolve to their
    light-mode literals. */

+ 12 - 2
packages/client/ui-settings-models/src/client/locales.ts

@@ -50,7 +50,12 @@ export const en = {
   contextWindowPlaceholder: 'Uses the provider default',
   maxTokens: 'Max output tokens',
   maxTokensPlaceholder: 'Uses the provider default',
-  modelAdvanced: 'Capacities',
+  modelAdvanced: 'Model options',
+  modelImageInput: 'Image input',
+  modelImageDefault: 'Use default',
+  modelImageDefaultText: 'Default (text only)',
+  modelImageEnabled: 'Supported',
+  modelImageDisabled: 'Not supported',
   addModel: 'Add model',
   removeModel: 'Delete model',
   modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
@@ -160,7 +165,12 @@ export const zh: { [Key in keyof typeof en]: string } = {
   contextWindowPlaceholder: '使用提供方默认值',
   maxTokens: '最大输出 token 数',
   maxTokensPlaceholder: '使用提供方默认值',
-  modelAdvanced: '容量',
+  modelAdvanced: '模型选项',
+  modelImageInput: '图片输入',
+  modelImageDefault: '使用默认值',
+  modelImageDefaultText: '默认(仅文本)',
+  modelImageEnabled: '支持',
+  modelImageDisabled: '不支持',
   addModel: '添加模型',
   removeModel: '删除模型',
   modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',

+ 2 - 1
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -648,6 +648,7 @@ describe('ModelsSection', () => {
     fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
     // Only row 3 is open, so its capacity is addressed by its own label.
     fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
+    fireEvent.change(screen.getByLabelText(`${en.modelImageInput} 3`), { target: { value: 'enabled' } })
     fireEvent.click(screen.getByText(en.apply))
 
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
@@ -658,7 +659,7 @@ describe('ModelsSection', () => {
         path: ['models'],
         value: [
           ...DEFAULT_DEEPSEEK_MODELS,
-          { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
+          { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072, inputModalities: ['text', 'image'] },
         ],
       }],
       0,

+ 50 - 0
packages/client/ui-settings-models/tests/model-image-input.client.spec.tsx

@@ -0,0 +1,50 @@
+// @vitest-environment jsdom
+/** Image capability defaults, explicit choices, and hidden model metadata. */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ModelImageInput } from '../src/client/ModelImageInput.tsx'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+describe.each(['inputModalities', 'input'] as const)('%s image input', (field) => {
+  it.each([
+    [undefined, 'default'],
+    [[], 'default'],
+    [['text'], 'disabled'],
+    [['text', 'image'], 'enabled'],
+    [['image'], 'enabled'],
+  ] as const)('displays %j without materializing an override', (modalities, selected) => {
+    const onChange = vi.fn()
+    render(<ModelImageInput model={{ id: 'preview', [field]: modalities }} field={field} position={2} disabled={false} t={key => en[key]} onChange={onChange} />)
+    expect(screen.getByRole<HTMLSelectElement>('combobox', { name: `${en.modelImageInput} 2` }).value).toBe(selected)
+    expect(onChange).not.toHaveBeenCalled()
+  })
+
+  it('enables images and keeps unrelated metadata', () => {
+    const onChange = vi.fn()
+    const model = { id: 'preview', contextWindow: 123456, systemPromptUpdate: 'in-history' }
+    render(<ModelImageInput model={model} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
+    fireEvent.change(screen.getByRole('combobox'), { target: { value: 'enabled' } })
+    expect(onChange).toHaveBeenCalledWith({ ...model, [field]: ['text', 'image'] })
+    expect(model).not.toHaveProperty(field)
+  })
+
+  it.each(['disabled', 'default'])('selects %s without leaving invalid DeepSeek image limits', (choice) => {
+    const onChange = vi.fn()
+    const model = { id: 'vision', [field]: ['image'], description: 'kept', imagePixelBudget: 'low', imageMaxBytes: 12345 }
+    render(<ModelImageInput model={model} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
+    fireEvent.change(screen.getByRole('combobox'), { target: { value: choice } })
+    expect(onChange).toHaveBeenCalledWith({
+      id: 'vision', description: 'kept',
+      ...choice === 'disabled' ? { [field]: ['text'] } : {},
+      ...field === 'input' ? { imagePixelBudget: 'low', imageMaxBytes: 12345 } : {},
+    })
+    expect(model[field]).toEqual(['image'])
+  })
+
+  it('disables the selector while read-only or saving', () => {
+    render(<ModelImageInput model={{ id: 'preview' }} field={field} position={1} disabled t={key => en[key]} onChange={vi.fn()} />)
+    expect(screen.getByRole<HTMLSelectElement>('combobox').disabled).toBe(true)
+  })
+})

+ 18 - 1
packages/client/ui-settings-models/tests/provider-form.client.spec.tsx

@@ -256,6 +256,22 @@ describe('protocolChoices', () => {
 })
 
 describe('model list editing', () => {
+  it('changes image input without rewriting a neighboring model declaration', async () => {
+    const neighbor = { id: 'vision', input: ['image'], name: 'Kept vision model' }
+    const { mutate } = await mountSection({
+      providers: { openai: { models: [{ id: 'preview' }, neighbor] } },
+    })
+    openEditor('openai')
+    expandModel(1)
+    fireEvent.change(screen.getByLabelText(`${en.modelImageInput} 1`), { target: { value: 'enabled' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(mutate).toHaveBeenCalled() })
+    expect(firstMutate(mutate).ops).toEqual([{
+      op: 'set', path: ['providers', 'openai', 'models'],
+      value: [{ id: 'preview', input: ['text', 'image'] }, neighbor],
+    }])
+  })
+
   it('adds, edits, and removes rows without storing emptied optional fields', async () => {
     const { mutate } = await mountSection()
     openEditor('openai')
@@ -264,6 +280,7 @@ describe('model list editing', () => {
     fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
     expandModel(1)
     fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
+    fireEvent.change(screen.getByLabelText(`${en.modelImageInput} 1`), { target: { value: 'enabled' } })
     fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } })
     // Clearing an optional field must drop it rather than store an empty value.
     fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: '' } })
@@ -273,7 +290,7 @@ describe('model list editing', () => {
     expect(firstMutate(mutate)).toMatchObject({
       ns: 'llm-pi-ai',
       expectedRevision: 3,
-      ops: [{ op: 'set', path: ['providers', 'openai', 'models'], value: [{ id: 'acme-large', contextWindow: 65_536 }] }],
+      ops: [{ op: 'set', path: ['providers', 'openai', 'models'], value: [{ id: 'acme-large', contextWindow: 65_536, input: ['text', 'image'] }] }],
     })
   })
 

+ 2 - 2
packages/llm/llm-deepseek/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
-README.md: 48df51396e60f071503e5babd721bca0dcd18249
-README.zh.md: 9fe86f7ea6b93de847247d4a8f8434930d7d664e
+README.md: 6121a19003a628204bfb5f8a3d6b5d969bd19104
+README.zh.md: 69e5c103e0edd8d16f606ed11a7aaf602ff5944e

+ 1 - 1
packages/llm/llm-deepseek/README.md

@@ -83,7 +83,7 @@ To select Chat Completions explicitly, patch the existing plugin:
     protocol: chat-completions
 ```
 
-`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions inherit this default. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. An explicit `https://api.deepseek.com` override selects the Chat root: remove that override to use the official Messages default, or set it to `https://api.deepseek.com/anthropic`. Chat appends `/chat/completions`; Messages appends `/v1/messages`. Apart from trailing slashes, neither infers or removes custom path suffixes such as `/v1`. Both share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid.
+`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions inherit this default. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. An explicit `https://api.deepseek.com` override selects the Chat root: remove that override to use the official Messages default, or set it to `https://api.deepseek.com/anthropic`. Chat appends `/chat/completions`. Messages and its Files API treat only an exact final `/v1` path segment as the existing Anthropic API version and append `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The official Messages root therefore retains its recommended `/anthropic/v1` request paths without granting compatibility to arbitrary version-like suffixes. Trailing slashes do not change these results. Both protocols share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid.
 
 Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata identifies the Messages format, model, and signatures. Chat requests serialize durable content without those signatures. Invalid Messages replay metadata emits a warning and omits signatures while retaining text and tool history.
 

+ 1 - 1
packages/llm/llm-deepseek/README.zh.md

@@ -83,7 +83,7 @@ kind: "package-reference"
     protocol: chat-completions
 ```
 
-`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合继承该默认值。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`,Messages 追加 `/v1/messages`;除去末尾斜线之外,不推测或删除自定义路径中的 `/v1` 等后缀。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
+`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合继承该默认值。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`。Messages 与其 Files API 仅把末尾严格匹配的 `/v1` 路径段视为已有 Anthropic API 版本,并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。因此,官方 Messages 根地址仍使用推荐的 `/anthropic/v1` 请求路径,同时不为任意版本式后缀提供兼容性。末尾斜线不改变这些结果。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
 
 Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据记录 Messages 格式、模型和签名;Chat 请求只序列化持久化内容,不发送这些签名。无效的 Messages 回放元数据产生警告并省略签名,不丢弃文本或工具历史。
 

+ 5 - 2
packages/llm/llm-deepseek/src/common/file-store.ts

@@ -4,6 +4,7 @@ import type { RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
 import { LlmError } from '@deepseek-ai/dsh-llm'
 import { DeepSeekFilesClient, isFilesQuotaError } from './files-api.ts'
 import type { DeepSeekFileId } from './file-id.ts'
+import { messagesApiRoot } from './messages-api.ts'
 import { deepSeekFileScope, DeepSeekUploadIndex } from './upload-index.ts'
 import type { DeepSeekUploadRecord } from './upload-index.ts'
 import type { DeepSeekProtocol } from './types.ts'
@@ -48,8 +49,10 @@ interface SharedUpload {
 
 /** The Files resource's parent URL distinguishes custom protocol namespaces. */
 function fileScope(connection: DeepSeekFileConnection) {
-  const root = connection.baseURL.replace(/\/+$/u, '')
-  return deepSeekFileScope(connection.protocol === 'messages' ? `${root}/v1` : root, connection.apiKey)
+  return deepSeekFileScope(
+    connection.protocol === 'messages' ? messagesApiRoot(connection.baseURL) : connection.baseURL,
+    connection.apiKey,
+  )
 }
 
 function abortReason(signal: AbortSignal): Error {

+ 5 - 5
packages/llm/llm-deepseek/src/common/files-api.ts

@@ -4,11 +4,9 @@ import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm'
 import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
 import { DeepSeekFileId } from './file-id.ts'
 import type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts'
+import { messagesApiRoot, MESSAGES_FILES_BETA } from './messages-api.ts'
 import type { DeepSeekProtocol } from './types.ts'
 
-/** Required opt-in for Messages file operations and file-referenced image requests. */
-export const MESSAGES_FILES_BETA = 'files-api-2025-04-14'
-
 /** Minimum provider-supported file lifetime. */
 export const MIN_FILE_EXPIRY_SECONDS = 3_600
 /** Maximum provider-supported file lifetime. */
@@ -155,11 +153,13 @@ export class DeepSeekFilesClient {
    * @param options - endpoint, API-key snapshot, and optional test transport.
    */
   constructor(options: FilesApiOptions) {
-    this.baseURL = options.baseURL.replace(/\/+$/u, '')
     this.apiKey = options.apiKey
     this.fetchImpl = options.fetch ?? globalThis.fetch
     this.protocol = options.protocol
-    this.path = this.protocol === 'messages' ? '/v1/files' : '/files'
+    this.baseURL = this.protocol === 'messages'
+      ? messagesApiRoot(options.baseURL)
+      : options.baseURL.replace(/\/+$/u, '')
+    this.path = '/files'
   }
 
   private parseFile(value: unknown, operation: string): DeepSeekFileObject {

+ 14 - 0
packages/llm/llm-deepseek/src/common/messages-api.ts

@@ -0,0 +1,14 @@
+/** Shared DeepSeek Messages API endpoint and header policy. @module dsh-llm-deepseek/messages-api */
+
+/** Required opt-in for Messages file operations and file-referenced image requests. */
+export const MESSAGES_FILES_BETA = 'files-api-2025-04-14'
+
+/**
+ * Resolve the API root without duplicating an explicit provider version path.
+ * @param baseURL - validated configured endpoint root.
+ * @returns the root beneath which Messages resources are exposed.
+ */
+export function messagesApiRoot(baseURL: string): string {
+  const base = baseURL.replace(/\/+$/u, '')
+  return new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`
+}

+ 2 - 2
packages/llm/llm-deepseek/src/protocols/messages/adapter.ts

@@ -8,7 +8,7 @@ import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { catalogModelInfo, modelInfo } from '../../common/model-info.ts'
 import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from '../../common/types.ts'
 import type { DeepSeekFileStore } from '../../common/file-store.ts'
-import { MESSAGES_FILES_BETA } from '../../common/files-api.ts'
+import { MESSAGES_FILES_BETA, messagesApiRoot } from '../../common/messages-api.ts'
 import { FileResolutionFailure, RequestFiles } from '../../common/request-files.ts'
 import { prepareRequestExtensions } from '../../common/request-extensions.ts'
 import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts'
@@ -119,7 +119,7 @@ export class DeepSeekMessagesAdapter extends LlmAdapter {
         ...options.purpose === undefined ? {} : { purpose: options.purpose },
       }, this.dependencies.prepareExtensions)
       signal.throwIfAborted()
-      const response = await fetch(`${connection.baseURL.replace(/\/+$/u, '')}/v1/messages`, {
+      const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, {
         method: 'POST', signal, body: extensions.payload, redirect: 'error',
         headers: {
           ...attributionHeaders(),

+ 1 - 1
packages/llm/llm-deepseek/src/protocols/messages/types.ts

@@ -18,7 +18,7 @@ export interface WireMessage {
   content: WireBlock[]
 }
 
-/** JSON body submitted to /v1/messages. */
+/** JSON body submitted to the resolved Messages endpoint. */
 export interface WireRequest {
   model: string
   stream: true

+ 1 - 0
packages/llm/llm-deepseek/tests/file-store.spec.ts

@@ -88,6 +88,7 @@ describe('DeepSeekFileStore', () => {
     const first = await store.ensureUploaded(VERSION, native, POLICY)
     expect(first.record.scope).toBe(deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey))
     expect(first.record.scope).not.toBe(chat.record.scope)
+    expect((await store.ensureUploaded(VERSION, { ...native, baseURL: `${native.baseURL}/v1/` }, POLICY)).record).toEqual(first.record)
     const reopened = new DeepSeekFileStore({ index, fetch: fetchImpl, now: () => now })
     expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record)
     await reopened.invalidate(VERSION, chat.record.fileId, native)

+ 15 - 0
packages/llm/llm-deepseek/tests/files-api.spec.ts

@@ -53,6 +53,21 @@ describe('DeepSeekFilesClient', () => {
     expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', purpose: 'user_data', expiresAt: createdAt + 3_600 })
   })
 
+  it.each([
+    ['https://provider.example/anthropic/v1', 'https://provider.example/anthropic/v1/files'],
+    ['https://provider.example/v1beta/', 'https://provider.example/v1beta/v1/files'],
+  ])('resolves the Messages Files endpoint from %s', async (baseURL, expected) => {
+    const fetchImpl = vi.fn<typeof fetch>(async () => new Response(JSON.stringify(messagesFile())))
+    const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL, apiKey: 'key', fetch: fetchImpl })
+
+    await client.upload({
+      data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600,
+    })
+
+    expect(fetchImpl).toHaveBeenCalledOnce()
+    expect(requestUrl(fetchImpl.mock.calls[0]![0])).toBe(expected)
+  })
+
   it('maps Messages list cursors, file metadata and deletion without OpenAI-only fields', async () => {
     const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
       const url = new URL(requestUrl(input))

+ 2 - 1
packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts

@@ -18,7 +18,8 @@ import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek'
 import * as Messages from '../../src/index.ts'
-import { DeepSeekFilesClient, MESSAGES_FILES_BETA } from '../../src/common/files-api.ts'
+import { DeepSeekFilesClient } from '../../src/common/files-api.ts'
+import { MESSAGES_FILES_BETA } from '../../src/common/messages-api.ts'
 import { assemble, options, user } from './helpers.ts'
 
 const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL

+ 18 - 0
packages/llm/llm-deepseek/tests/messages/adapter.spec.ts

@@ -91,6 +91,24 @@ describe('direct Messages HTTP', () => {
     expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined()
   })
 
+  it.each([
+    ['https://provider.example', 'https://provider.example/v1/messages'],
+    ['https://provider.example/v1/', 'https://provider.example/v1/messages'],
+    ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'],
+    ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'],
+    ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'],
+    ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'],
+  ])('resolves the Messages endpoint from %s', async (baseURL, expected) => {
+    const fetchImpl = vi.fn<typeof fetch>(async () => new Response(sse(textEvents), {
+      headers: { 'content-type': 'text/event-stream' },
+    }))
+    vi.stubGlobal('fetch', fetchImpl)
+
+    await chunks(adapter({ baseURL }).stream(options()))
+
+    expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected)
+  })
+
   it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => {
     const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : '<html>busy</html>') })
     await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } })