Browse Source

refactor: separate command identity and composer file action ownership

creatixchu 1 month ago
parent
commit
996278e6ce
52 changed files with 467 additions and 206 deletions
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml
  2. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md
  3. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
  5. 5 5
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
  6. 5 5
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
  7. 2 2
      docs/subsystems/commands.i18n.yaml
  8. 4 0
      docs/subsystems/commands.md
  9. 4 0
      docs/subsystems/commands.zh.md
  10. 4 4
      packages/client/connection/src/client/fixture.ts
  11. 2 2
      packages/client/ui-commands/README.i18n.yaml
  12. 3 3
      packages/client/ui-commands/README.md
  13. 3 3
      packages/client/ui-commands/README.zh.md
  14. 4 3
      packages/client/ui-commands/src/client/directory.ts
  15. 1 25
      packages/client/ui-commands/src/client/index.ts
  16. 3 9
      packages/client/ui-commands/src/client/locales.ts
  17. 13 59
      packages/client/ui-commands/src/client/presentation.ts
  18. 59 0
      packages/client/ui-commands/src/client/resolution.ts
  19. 14 14
      packages/client/ui-commands/src/client/service.ts
  20. 5 15
      packages/client/ui-commands/tests/browser-plugin.client.spec.ts
  21. 23 0
      packages/client/ui-commands/tests/directory.client.spec.ts
  22. 38 12
      packages/client/ui-commands/tests/service.client.spec.ts
  23. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  24. 2 0
      packages/client/ui-conversation/README.md
  25. 2 0
      packages/client/ui-conversation/README.zh.md
  26. 23 0
      packages/client/ui-conversation/src/client/apply.ts
  27. 3 10
      packages/client/ui-conversation/src/client/contract/input.ts
  28. 19 11
      packages/client/ui-conversation/src/client/input/facade.ts
  29. 17 1
      packages/client/ui-conversation/src/client/input/hub.ts
  30. 2 0
      packages/client/ui-conversation/src/client/locales.ts
  31. 3 4
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  32. 46 1
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  33. 30 2
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  34. 2 0
      packages/compaction/command-compact/src/index.ts
  35. 1 0
      packages/compaction/command-compact/tests/command-compact.spec.ts
  36. 1 0
      packages/compaction/command-compact/tests/loader-composition.spec.ts
  37. 2 0
      packages/feedback/command-feedback/src/index.ts
  38. 1 0
      packages/feedback/command-feedback/tests/command-feedback.spec.ts
  39. 2 0
      packages/goal/command-goal/src/index.ts
  40. 1 0
      packages/goal/command-goal/tests/command-goal.spec.ts
  41. 2 2
      packages/interaction/commands/README.i18n.yaml
  42. 2 2
      packages/interaction/commands/README.md
  43. 2 2
      packages/interaction/commands/README.zh.md
  44. 12 0
      packages/interaction/commands/src/brand.ts
  45. 6 1
      packages/interaction/commands/src/index.ts
  46. 3 1
      packages/interaction/commands/src/types.ts
  47. 7 3
      packages/interaction/commands/tests/commands.spec.ts
  48. 2 0
      packages/interaction/permission-presets/src/index.ts
  49. 2 0
      packages/plan/plan-mode/src/index.ts
  50. 1 1
      packages/plan/plan-mode/tests/plan-mode.spec.ts
  51. 2 0
      packages/session-query/session-log-export/src/index.ts
  52. 1 0
      packages/session-query/session-log-export/tests/loader-composition.host.spec.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.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/architecture/2026-09-10-command-identities-and-composer-file-action.md
+2026-09-10-command-identities-and-composer-file-action.md: 696c3486c5e007416da3fd5e7be1d4b41cb5f582
+2026-09-10-command-identities-and-composer-file-action.zh.md: 2ce803bad5d39a6d4f40b665fb4ef7456e987912

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md

@@ -0,0 +1,33 @@
+# Agent Note: Command identities and composer-owned File action
+
+Status: implemented
+
+English | [中文](2026-09-10-command-identities-and-composer-file-action.zh.md)
+
+## Problem
+
+Matching a command's English description to a client dictionary makes punctuation changes affect localization and inserted command tokens. A same-name override can also copy that description without implementing the first-party command. The File menu entry needs the composer's live attachment policy, including mount, lock, and submission state; a separate command-plugin check cannot determine that state.
+
+## Decision
+
+The command registry preserves an optional branded `CommandDefinitionId` as `definitionId` on the effective definition and descriptor. First-party producers choose their package name as the stable identity. Scoped shadowing selects the complete descriptor and never inherits the shadowed definition's identity. The identifier is discovery metadata, not an authorization claim, and does not enter command lifecycle events.
+
+The client command directory resolves input through its private `resolution.ts`. Exact registered names take priority; Chinese and English aliases select only the corresponding first-party definition in the effective Session catalog. Menu claims use the current locale's spelling; typed claims retain the supplied spelling; submissions use the resolved registered name. `presentation.ts` owns only sections, labels, descriptions, and icons. Resolution helpers and section constants are not exported from the plugin entrypoint.
+
+Conversation registers the File action through the injected command service and owns its localized label. The mounted input binds its file-dialog opener and one live availability query. Both menu filtering and invocation use that query, so lock, unmount, subagent, and submission state apply consistently. The binding and dispatch remain package-internal callbacks; no cross-plugin pick-files event is needed. The assembly uses a narrow structural action-registration face because command UI consumes Conversation's input types; a reverse compiler-project dependency would form a cycle. Its registration test checks against the command plugin's contribution type.
+
+This note supersedes only identity matching, input-resolution placement, and File-action ownership in the [composer menu decision](../feature/2026-09-08-composer-menu-sections-and-localized-rows.md). That note retains the menu, scrolling, claim-retention, and composition-timing decisions.
+
+## Alternatives considered
+
+**Match names and English descriptions.** Copy is editable and can be duplicated by unrelated definitions. A stable identity separates presentation selection from copy without moving localized text onto the Host.
+
+**Match names alone.** An agent-scoped override may deliberately provide a different command under the same name. It must not inherit first-party presentation or aliases unless it explicitly carries that identity.
+
+**Keep the File action in the command plugin.** The input owns attachment acceptance and the DOM lifetime. Maintaining a second availability condition splits one policy between owners.
+
+**Export helpers for reuse.** No production consumer needs the section constants or resolution helper as a plugin API. Tests import internal modules directly.
+
+## Consequences
+
+First-party producers and client identity mappings must agree on stable identifiers; third-party definitions may omit them. Display copy can evolve independently. Registry tests cover descriptor preservation and shadowing; client tests cover description edits in both locales, alias resolution, and exact-name priority. Composer tests cover live availability, opener replacement, unmount, and action-registration disposal. The existing Session-driven menu and command scenarios continue to own assembled browser output.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 命令标识与输入框拥有的文件动作
+
+Status: implemented
+
+[English](2026-09-10-command-identities-and-composer-file-action.md) | 中文
+
+## 问题
+
+用命令的英文说明匹配客户端词典,会让标点修改影响本地化和填入的命令 token。同名覆盖也可能复制这段说明,却没有实现内置命令的行为。「文件」菜单项需要输入框实时的附件策略,包括挂载、锁定和提交状态,命令插件中的另一套检查无法确定这些状态。
+
+## 决策
+
+命令注册表在有效定义和描述符上保留可选的品牌类型 `CommandDefinitionId` 字段 `definitionId`。内置命令的提供方用包名作为稳定标识。作用域覆盖选择完整描述符,不继承被遮蔽定义的标识。该标识是发现元数据,不代表授权,也不进入命令生命周期事件。
+
+客户端命令目录通过内部的 `resolution.ts` 解析输入。精确注册名优先,中英文别名只选择会话有效目录中对应的内置定义。菜单认领使用当前语言的写法,手输认领保留原写法,提交使用解析到的注册名。`presentation.ts` 只负责分节、标题、说明和图标。解析辅助函数与小节常量不从插件入口导出。
+
+Conversation 通过注入的命令服务注册「文件」动作,并负责其本地化标题。已挂载输入框绑定文件选择器回调和一个实时可用性查询。菜单过滤与实际调用使用同一个查询,统一处理锁定、卸载、subagent 和提交状态。绑定与调用留在包内回调中,不需要跨插件的文件选择事件。组装层使用窄的结构化动作注册接口,因为命令 UI 已消费 Conversation 的输入类型,反向增加编译项目依赖会形成循环。注册测试使用命令插件的贡献项类型进行校验。
+
+本记录只接管[输入框菜单决策](../feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md)中的标识匹配、输入解析归属和文件动作归属。原记录继续负责菜单、滚动、认领保留和组合输入时序的决策。
+
+## 考虑过的替代方案
+
+**按名称和英文说明匹配。** 文案可以修改,也可以被无关定义复制。稳定标识将展示选择与文案分开,无需把本地化文本移到宿主。
+
+**只按名称匹配。** agent(智能体)作用域覆盖可能刻意在同名下提供另一种命令。除非它显式携带对应标识,否则不应继承内置展示或别名。
+
+**把文件动作留在命令插件。** 输入框负责附件接收和 DOM 生命周期。维护另一套可用性条件会把同一策略分给两个模块。
+
+**导出辅助函数以供复用。** 没有生产调用方需要把小节常量或解析辅助函数作为插件 API。测试直接导入内部模块。
+
+## 后果
+
+内置命令提供方与客户端映射必须使用一致的稳定标识,第三方定义可以省略标识。显示文案可以独立调整。注册表测试覆盖描述符保留和作用域覆盖,客户端测试覆盖两种语言下修改说明、别名解析和精确名称优先。输入框测试覆盖实时可用性、回调替换、卸载和动作注册的 dispose(资源释放)。现有会话驱动的菜单和命令场景继续负责组装后的浏览器输出。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.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-08-composer-menu-sections-and-localized-rows.md
-2026-09-08-composer-menu-sections-and-localized-rows.md: ab381d2e3ab62cc272a3984468163a657b717da1
-2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 8fe15119ac96ac7d1aac6206a7c423ef35d72fd2
+2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e
+2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224

+ 5 - 5
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md

@@ -12,11 +12,11 @@ The composer's `+` button and a typed `/` listed every command in Host registrat
 
 `ui-commands` owns the menu's presentation in `src/client/presentation.ts`: an Add section (`file`, `goal`, `plan`, `feedback`) and a Commands section (`compact`, `permission`, `model`, `export`), each in usage order, with rows outside both lists closing Commands in catalog order. The section lists are keyed by name; an empty query returns the sectioned rows, and a typed query returns the flat ranking of every visible row so the best match is always first.
 
-The six built-in Host commands get a client face from the `command` dictionaries — title, description, glyph, and claim token — when the catalog description equals the canonical English text; a scoped override or third-party command of the same name keeps its own description and only its section position follows the name. The English dictionary is byte-equal to the Host descriptors, and the `feedback` and `goal` descriptors are capitalized to match the other four. Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration.
+The six built-in Host commands get their localized title, description, glyph, and claim token from the client. Stable identity selection and input resolution follow the [command identity decision](../architecture/2026-09-10-command-identities-and-composer-file-action.md). Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration.
 
-A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. `resolveCommandName` maps every localized token of every dictionary back to its command name, so `/计划` typed under any locale reaches the `plan` descriptor on Space and Enter, and a bare `/压缩` executes as `/compact`.
+A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. The effective Session catalog resolves Chinese and English aliases through the same input path in every locale.
 
-The File row is an `action` contribution, a new `CommandUiSpec` kind whose bare invocation consumes the trigger token and runs a client callback without submitting anything; it therefore never refuses an attachment-carrying draft. The row raises the scoped `slash/input-pick-files` event, which `ui-conversation` routes to the composer's hidden file input through `ComposerKeyboard.bindFilePicker` under the same gate as drop intake; the paperclip button is removed. The `+` button's accessible name and tooltip read "Add files or run commands".
+The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands".
 
 `ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
 
@@ -28,7 +28,7 @@ The menu uses a 400 px border-box height cap, which fits both headings and the e
 
 **Restore placeholders directly on native composition end.** Browsers can deliver that event before Lexical reconciles the final text. The shared editor binding keeps command hints and ordinary placeholders hidden while either native or editor composition remains active, and reevaluates visibility after an editor commit, including a cancellation that changes no text. Keyboard submit guards retain their separate post-composition window.
 
-**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; the client already owns product copy for every other surface (the locale-owned client UI copy decision), and the equality guard keeps a scoped or third-party descriptor verbatim.
+**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; localized product copy belongs to the client.
 
 **Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete.
 
@@ -40,4 +40,4 @@ The menu uses a 400 px border-box height cap, which fits both headings and the e
 
 ## Consequences
 
-The menu, the Host catalog, and the localized face are three separate facts joined by name and by the canonical English description; adding a built-in Host command means adding its `label.*`, `description.*`, and `token.*` keys, its glyph in `presentation.ts`, and its section position. The web e2e goldens and every expected ARIA snapshot that showed the paperclip or the `Commands` button changed in the same PR. A third-party command named like a built-in one is positioned by name but keeps its own description and gets no glyph, which is the intended reading of "the same name, another command".
+The menu and localized presentation remain client-owned, while the Host owns effective command definitions. Adding a localized first-party command requires its identity mapping, dictionary entries, icon, and menu position. Session-driven browser scenarios and owner-local ARIA expectations cover the menu. Unrelated same-name definitions keep their own copy and no first-party glyph. The command identity decision owns this distinction and the File action's lifecycle.

+ 5 - 5
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md

@@ -12,11 +12,11 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列
 
 `ui-commands` 在 `src/client/presentation.ts` 里拥有菜单的展示:「添加」小节(`file`、`goal`、`plan`、`feedback`)与「指令」小节(`compact`、`permission`、`model`、`export`),各按使用频次排列,不在两个清单里的行按目录顺序排在「指令」末尾。小节清单按名字索引;空查询返回分节的行,输入查询后返回所有可见行的平铺排序,最佳匹配始终在第一位。
 
-六个内置宿主命令在目录说明与规范英文文案相同时,从 `command` 词典取得客户端展示面:标题、说明、图标与声明 token;同名的作用域覆盖或第三方命令保留自己的说明,只有小节位置跟随名字。英文词典与宿主描述符逐字节相同,`feedback` 与 `goal` 的描述符改为首字母大写,与另外四个一致。贡献项携带 `label()`、`description()` 与 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。
+六个内置宿主命令从客户端取得本地化标题、说明、图标和认领 token。稳定标识选择与输入解析遵循[命令标识决策](../architecture/2026-09-10-command-identities-and-composer-file-action.zh.md)。贡献项携带 `label()`、`description()` 和 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。
 
-菜单选中填入当前语言的声明 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输的 token 保留手输的写法作为声明,因为 composer 从它持有的 token 之后读取参数。`resolveCommandName` 把每本词典里的每个本地化 token 映射回命令名,因此在任何语言下键入的 `/计划` 在空格与回车裁决中都到达 `plan` 描述符,裸 `/压缩` 执行为 `/compact`。
+菜单选中填入当前语言的认领 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输 token 保留原写法作为认领,因为输入框从它持有的 token 之后读取参数。会话有效目录在所有界面语言下通过同一输入路径解析中英文别名。
 
-「文件」行是一个 `action` 贡献项,这是 `CommandUiSpec` 新增的种类:裸调用消费掉触发 token 后运行一个客户端回调,不提交任何内容,因此绝不会拒绝带附件的草稿。该行触发会话作用域的 `slash/input-pick-files` 事件,`ui-conversation` 经 `ComposerKeyboard.bindFilePicker` 把它路由到 composer 隐藏的文件输入框,门槛与拖放入口相同;回形针按钮移除。`+` 按钮的无障碍名称与提示改为「添加文件或调用指令」。
+「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。
 
 `ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
 
@@ -28,7 +28,7 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列
 
 **在原生组合输入结束事件中直接恢复占位文字。** 浏览器可能先发送该事件,Lexical 随后才完成最终文字更新。共享编辑器绑定在原生输入法或编辑器仍处于组合输入时隐藏命令提示和普通占位文字,并在编辑器提交更新后重新判断显隐,包括没有文字变化的取消操作。键盘提交保护保留独立的组合输入结束后保护时段。
 
-**在宿主侧本地化说明。** 宿主没有语言设置,目录又被所有客户端共享;客户端已经拥有其他所有界面的产品文案(客户端界面文案归语言所有的决策),相等性判断还能让作用域覆盖或第三方描述符原样保留。
+**在宿主侧本地化说明。** 宿主没有语言设置,目录由所有客户端共享,本地化产品文案属于客户端。
 
 **输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。
 
@@ -40,4 +40,4 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列
 
 ## 后果
 
-菜单、宿主目录与本地化展示面是三份独立事实,靠名字与规范英文说明连接;新增一个内置宿主命令意味着补上它的 `label.*`、`description.*`、`token.*` 键、`presentation.ts` 里的图标与小节位置。Web e2e 基准与所有曾显示回形针或 `Commands` 按钮的 ARIA 快照在同一 PR 内更新。与内置命令同名的第三方命令按名字定位,但保留自己的说明且没有图标,这正是「同名的另一个命令」应有的解读。
+菜单与本地化展示由客户端负责,宿主负责有效命令定义。新增本地化内置命令需要对应的标识映射、词典条目、图标和菜单位置。会话驱动的浏览器场景与归属模块的 ARIA 预期覆盖菜单。无关的同名定义保留自己的文案,不获得内置图标。命令标识决策负责这一划分和文件动作的生命周期。

+ 2 - 2
docs/subsystems/commands.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/subsystems/commands.md
-commands.md: 4705048e06796792d8e013450b72035b1f87315b
-commands.zh.md: f3af9f6e81745514efd1e073868292a02f90dca8
+commands.md: dc6ad74da872166fbf584dcab8e1ed21878c243a
+commands.zh.md: 57a60a87f78cd264acdbffa3f7514b4ac287073c

+ 4 - 0
docs/subsystems/commands.md

@@ -33,6 +33,8 @@ interface CommandInputDescriptor {
 ```ts type-equiv
 /** Plugin-owned command registration. */
 interface CommandDefinition {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */
@@ -97,6 +99,8 @@ Adapters receive handler-free immutable descriptors after scope resolution. `par
 ```ts type-equiv
 /** Handler-free immutable command view returned to UI adapters. */
 interface CommandDescriptor {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */

+ 4 - 0
docs/subsystems/commands.zh.md

@@ -33,6 +33,8 @@ interface CommandInputDescriptor {
 ```ts type-equiv
 /** Plugin-owned command registration. */
 interface CommandDefinition {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */
@@ -97,6 +99,8 @@ type CommandResult =
 ```ts type-equiv
 /** Handler-free immutable command view returned to UI adapters. */
 interface CommandDescriptor {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */

+ 4 - 4
packages/client/connection/src/client/fixture.ts

@@ -34,7 +34,7 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
 // Type-only: the brand constructor is host-side; the fixture casts at its
 // wire-fabrication boundary (the schema layer's one-cast-point posture).
-import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
+import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands/brand'
 import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types'
 import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
 import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
@@ -2262,9 +2262,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         value: [
           { name: 'compact', description: 'fixture:压缩当前会话上下文' },
           { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
-          { name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
-          { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
-          { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
+          { definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
+          { definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
+          { definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
         ],
       }
     },

+ 2 - 2
packages/client/ui-commands/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-commands/README.md
-README.md: c8d4dfbb121aa3ef70b45086e47177a9becdc49b
-README.zh.md: e600fc14e44a8ac622e74c901a02ae37ff55ad03
+README.md: 147cce99ab24ac3c1a8a111b4b95f4175f89d26b
+README.zh.md: 11f47e1e64915198295ee20ee6e051c413d30dc8

+ 3 - 3
packages/client/ui-commands/README.md

@@ -29,11 +29,11 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so
 
 ### Kinds and decorations
 
-A contribution is a client-owned command — a host-name collision fails loudly. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, which submits nothing and therefore never refuses an attachment-carrying draft. The menu's File row is this package's own action; it opens the composer's file picker through the scoped `slash/input-pick-files` event wherever the composer accepts files, never on a subagent. A decoration adds a bare-invocation popup or action to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles; prefixes rank first, and a typed query lists the matches flat without the section headings.
+A contribution is a client-owned command; a host-name collision fails loudly. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, without submitting a message. Business packages own their actions and availability; the composer registers File through this same API. A decoration adds a bare-invocation popup or action to an existing host command while preserving its catalog row, argument claim, and lifecycle logging; it never fires without a matching host row. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles, with prefixes first and no section headings.
 
 ### Built-in row faces
 
-Host descriptors carry English text only, so `src/client/presentation.ts` owns the client face of the six built-in Host commands: a catalog row whose description equals the canonical English text gets its localized title, description, glyph, and claim token from the `command` dictionaries, while a scoped override or third-party command of the same name keeps its own description and only its section position follows the name; rows outside both section lists close the Commands section in catalog order. Under Chinese, picking Plan fills the composer with `/计划 ` and the submission still executes `/plan `; a typed `/计划` or `/目标` resolves the same way on Space and Enter in every locale, so a draft written under one locale submits under another. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass, so a locale change reaches the next menu open without re-registration.
+First-party command definitions carry stable `definitionId` values. The client selects their localized titles, descriptions, icons, and input spellings by identity; changing a Host description cannot change that selection. Same-name overrides without the matching identity keep their own copy and receive no first-party aliases. Chinese and English spellings resolve through the same effective Session catalog in every locale, preserving the typed spelling in the draft and submitting the registered Host name. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass. Empty-query section order follows names, with unlisted rows closing Commands.
 
 ### Attachment-carrying submissions
 
@@ -47,7 +47,7 @@ When the composer submits with images or generic files, only a host command decl
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution. `presentation.ts` holds the section lists, the built-in row faces, and the localized-token resolution; `candidates` sections an empty query and ranks a typed one through the shared `rankByName`.
+`src/client/contract.ts` defines contribution and decoration registration. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin.
 
 </details>
 

+ 3 - 3
packages/client/ui-commands/README.zh.md

@@ -29,11 +29,11 @@ kind: "package-reference"
 
 ### 种类与装饰
 
-贡献项是客户端自有命令——与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用在消费掉触发 token 之后运行的一个回调,它不提交任何内容,因此绝不会拒绝带附件的草稿。菜单里的「文件」行就是本包自己的动作;只要 composer 接受文件,它就经会话作用域的 `slash/input-pick-files` 事件打开 composer 的文件选择器,子智能体上不提供。装饰为**已存在的**宿主命令添加裸调用弹窗或动作:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名与标题的子序列;前缀排名最高,输入查询后匹配项平铺列出,不再带小节标题。
+贡献项是客户端自有命令,与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用消费触发 token 后运行回调,不提交消息。业务包负责自己的动作及可用性,输入框通过同一 API 注册「文件」。装饰为已有宿主命令添加裸调用弹窗或动作,并保留其目录行、参数认领与生命周期记录;没有匹配的宿主行时不触发。菜单查询按顺序、不区分大小写地模糊匹配命令名与标题的子序列,前缀优先,不显示小节标题。
 
 ### 内置行的展示面
 
-宿主描述符只携带英文文案,因此 `src/client/presentation.ts` 拥有六个内置宿主命令在客户端的展示面:目录行的说明与规范英文文案相同时,它从 `command` 词典取得本地化的标题、说明、图标与声明 token;同名的作用域覆盖或第三方命令保留自己的说明,只有小节位置跟随名字;不在两个小节清单里的行按目录顺序排在「指令」小节末尾。中文下选中「计划」会在 composer 填入 `/计划 `,提交仍执行 `/plan `;键入的 `/计划` 或 `/目标` 在任何语言下的空格与回车裁决中都同样解析,因此在一种语言下写的草稿在另一种语言下照样能提交。贡献项自带 `label`、`description` 与 `icon`,每次生成候选项时读取,语言切换后下一次打开菜单即生效,无需重新注册。
+内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。
 
 ### 带附件提交
 
@@ -47,7 +47,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
 <details>
 <summary>实现细节——点击展开</summary>
 
-`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。`presentation.ts` 持有小节清单、内置行的展示面与本地化 token 的解析;`candidates` 对空查询分小节、对输入的查询经共享的 `rankByName` 排序。
+`src/client/contract.ts` 定义贡献项和装饰的注册接口。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。
 
 </details>
 

+ 4 - 3
packages/client/ui-commands/src/client/directory.ts

@@ -7,6 +7,7 @@
  */
 import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { resolveCommand } from './resolution.ts'
 
 export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
 
@@ -46,15 +47,15 @@ export class CommandDirectory {
   }
 
   /**
-   * Synchronous exact-name lookup over one session's hot snapshot.
+   * Synchronous command lookup over one Session's ready catalog; exact names precede localized aliases.
    * @param sessionId - session key.
-   * @param name - command name without the leading slash.
+   * @param name - typed command spelling without the leading slash.
    * @returns the descriptor, or undefined when absent or the entry is not ready.
    */
   resolve(sessionId: SessionId, name: string): CommandDescriptor | undefined {
     const entry = this.entries.get(sessionId)
     if (entry === undefined || entry.state !== 'ready') return undefined
-    return entry.commands.find(c => c.name === name)
+    return resolveCommand(name, entry.commands)
   }
 
   /** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */

+ 1 - 25
packages/client/ui-commands/src/client/index.ts

@@ -15,7 +15,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
-import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { CommandUiRuntime } from './service.ts'
 import type { PopupSelectInjected } from './PopupSelectView.tsx'
 import { PopupSelectView } from './PopupSelectView.tsx'
@@ -32,8 +31,6 @@ export type {
   SelectConfirmation, SelectOption,
 } from './contract.ts'
 export type { CommandKey } from './locales.ts'
-export { SECTION_ROWS, resolveCommandName } from './presentation.ts'
-export type { MenuSection } from './presentation.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -55,33 +52,12 @@ const NS = 'command'
 export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale']
 
 /**
- * Client plugin body: mount the service, register the menu's File row (an
- * action opening the composer's file picker wherever the composer accepts
- * files — never on a subagent), then register the popupSelect shell into the
- * input overlay once its declarer is up.
+ * Mount the command service and its per-session popupSelect overlay.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries')
   ctx.plugin(CommandUiRuntime)
-  const t = ctx.locale.bind(NS)
-  ctx.inject(['commandUi', 'sessions'], (scope: ClientContext) => {
-    const command = scope.commandUi
-    const sessions = scope.get('sessions') as ISessions
-    scope.effect(() => command.register({
-      name: 'file',
-      label: () => t('label.file'),
-      icon: IconPaperclipOutline16,
-      available: session => sessions.subagentAddress(session.sessionId) === undefined,
-      ui: {
-        kind: 'action',
-        run: (session) => {
-          const actx = sessions.scope(session.sessionId)
-          if (actx !== undefined) actx.bail(actx, 'slash/input-pick-files')
-        },
-      },
-    }), 'ui-commands: File row')
-  })
   ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => {
     const command = scope.commandUi
     const sessions = scope.get('sessions') as ISessions

+ 3 - 9
packages/client/ui-commands/src/client/locales.ts

@@ -1,15 +1,14 @@
 /**
  * `command` namespace dictionaries: the composer menu's section headings,
  * the client face (title, description, claim token) of the built-in Host
- * commands whose catalog descriptors carry English text only, the File row's
- * title, and the popupSelect shell's copy.
+ * commands whose catalog descriptors carry English text only, and the
+ * popupSelect shell's copy.
  */
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
   'section.add': '添加',
   'section.commands': '指令',
-  'label.file': '文件',
   'label.goal': '目标',
   'label.plan': '计划',
   'label.feedback': '反馈',
@@ -41,15 +40,10 @@ export const zh = {
 /** The command namespace key union. */
 export type CommandKey = keyof typeof zh
 
-/**
- * English dictionary, checked complete against the zh key set. Each
- * `description.*` entry is byte-equal to the Host descriptor it localizes:
- * that equality is how a catalog row is recognized as the built-in command.
- */
+/** English dictionary, checked complete against the zh key set. */
 export const en = {
   'section.add': 'Add',
   'section.commands': 'Commands',
-  'label.file': 'File',
   'label.goal': 'Goal',
   'label.plan': 'Plan',
   'label.feedback': 'Feedback',

+ 13 - 59
packages/client/ui-commands/src/client/presentation.ts

@@ -1,12 +1,4 @@
-/**
- * Design-owned presentation of the composer menu (design doc for #3567):
- * the two sections with their usage order, and the client face of the
- * built-in Host commands — localized title, description, claim token, and
- * icon — whose catalog descriptors carry English text only. A catalog row is
- * the built-in command when its description equals the canonical English
- * text; a scoped override or third-party command of the same name keeps its
- * own description and title, and only its section position follows the name.
- */
+/** Composer menu grouping, localized labels, descriptions, and icons. */
 import type { ComponentType } from 'react'
 import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import {
@@ -15,14 +7,16 @@ import {
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
-import { en, zh } from './locales.ts'
+import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
 import type { CommandKey } from './locales.ts'
+import { builtinCommandName } from './resolution.ts'
+import type { BuiltinCommandName } from './resolution.ts'
 
 /** The menu's two sections. */
 export type MenuSection = 'add' | 'commands'
 
 /** Row names per section, highest usage first; rows outside both lists close the Commands section in catalog order. */
-export const SECTION_ROWS: Readonly<Record<MenuSection, readonly string[]>> = {
+const SECTION_ROWS: Readonly<Record<MenuSection, readonly string[]>> = {
   add: ['file', 'goal', 'plan', 'feedback'],
   commands: ['compact', 'permission', 'model', 'export'],
 }
@@ -31,22 +25,20 @@ export const SECTION_ROWS: Readonly<Record<MenuSection, readonly string[]>> = {
 interface HostFace {
   readonly label: CommandKey
   readonly description: CommandKey
-  readonly token: CommandKey
   readonly icon: ComponentType<IconProps>
 }
 
 /** One built-in Host command's face, keyed by its dictionary entries. */
-function hostFace(name: string, icon: ComponentType<IconProps>): readonly [string, HostFace] {
+function hostFace(name: BuiltinCommandName, icon: ComponentType<IconProps>): readonly [BuiltinCommandName, HostFace] {
   return [name, {
-    label: `label.${name}` as CommandKey,
-    description: `description.${name}` as CommandKey,
-    token: `token.${name}` as CommandKey,
+    label: `label.${name}`,
+    description: `description.${name}`,
     icon,
   }]
 }
 
 /** Built-in Host commands whose client face this package owns. */
-const HOST_FACES: ReadonlyMap<string, HostFace> = new Map([
+const HOST_FACES: ReadonlyMap<BuiltinCommandName, HostFace> = new Map([
   hostFace('goal', IconGoalOutline16),
   hostFace('plan', IconPlanOutline14),
   hostFace('feedback', IconSendOutline16),
@@ -55,60 +47,22 @@ const HOST_FACES: ReadonlyMap<string, HostFace> = new Map([
   hostFace('export', IconDownloadOutline16),
 ])
 
-/** The face of the built-in command a catalog row is, or undefined when the description is not the canonical English text. */
-function builtinFace(name: string, description: string): HostFace | undefined {
-  const face = HOST_FACES.get(name)
-  return face !== undefined && en[face.description] === description ? face : undefined
-}
-
 /**
  * The localized menu face of a catalog row.
- * @param name - catalog command name.
- * @param description - the catalog description.
+ * @param descriptor - effective Host command descriptor.
  * @param t - the `command` namespace translator.
  * @returns title, description, and glyph for a built-in command; undefined
  * for any other row, which keeps its catalog description.
  */
 export function builtinRowFace(
-  name: string,
-  description: string,
+  descriptor: CommandDescriptor,
   t: TranslateNS<'command'>,
 ): Pick<InputTriggerCandidate, 'label' | 'description' | 'icon'> | undefined {
-  const face = builtinFace(name, description)
+  const name = builtinCommandName(descriptor)
+  const face = name === undefined ? undefined : HOST_FACES.get(name)
   return face === undefined ? undefined : { label: t(face.label), description: t(face.description), icon: face.icon }
 }
 
-/**
- * The claim token of a catalog row in the current locale (the text the
- * composer shows after a pick): the localized token of a built-in command,
- * the name itself for any other row.
- * @param name - catalog command name.
- * @param description - the catalog description.
- * @param t - the `command` namespace translator.
- * @returns the token without its leading slash.
- */
-export function claimToken(name: string, description: string, t: TranslateNS<'command'>): string {
-  const face = builtinFace(name, description)
-  return face === undefined ? name : t(face.token)
-}
-
-/** Every localized claim token of every dictionary → the command name it stands for. */
-const TOKEN_ALIASES: ReadonlyMap<string, string> = new Map(
-  [...HOST_FACES].flatMap(([name, face]) =>
-    [zh[face.token], en[face.token]].filter(token => token !== name).map(token => [token, name] as const)),
-)
-
-/**
- * The command name a typed token stands for: a localized claim token of a
- * built-in command in any dictionary resolves to that command, so a draft
- * written under one locale still submits under another.
- * @param token - the typed name without its leading slash.
- * @returns the catalog command name.
- */
-export function resolveCommandName(token: string): string {
-  return TOKEN_ALIASES.get(token) ?? token
-}
-
 /**
  * Arrange the empty-query menu: the Add section, then the Commands section,
  * each in usage order, with unlisted rows closing Commands in their input

+ 59 - 0
packages/client/ui-commands/src/client/resolution.ts

@@ -0,0 +1,59 @@
+/** Command identity and localized input spelling over the effective Host catalog. */
+import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import { en, zh } from './locales.ts'
+
+const BUILTINS = {
+  goal: '@deepseek-ai/dsh-command-goal',
+  plan: '@deepseek-ai/dsh-plan-mode',
+  feedback: '@deepseek-ai/dsh-command-feedback',
+  compact: '@deepseek-ai/dsh-command-compact',
+  permission: '@deepseek-ai/dsh-permission-presets',
+  export: '@deepseek-ai/dsh-session-log-export',
+} as const
+
+/** Names whose first-party definitions have localized client presentation. */
+export type BuiltinCommandName = keyof typeof BUILTINS
+
+/**
+ * Identify a first-party definition without interpreting its display copy.
+ * @param descriptor - effective Host descriptor after scoped shadowing.
+ * @returns its first-party name, or undefined for another definition.
+ */
+export function builtinCommandName(descriptor: CommandDescriptor): BuiltinCommandName | undefined {
+  return (Object.keys(BUILTINS) as BuiltinCommandName[])
+    .find(name => descriptor.definitionId === BUILTINS[name])
+}
+
+/**
+ * Select the input spelling for a menu-picked command.
+ * @param descriptor - effective Host descriptor.
+ * @param t - command-namespace translator.
+ * @returns localized spelling for a known definition, otherwise its registered name.
+ */
+export function claimToken(descriptor: CommandDescriptor, t: TranslateNS<'command'>): string {
+  const name = builtinCommandName(descriptor)
+  return name === undefined ? descriptor.name : t(`token.${name}`)
+}
+
+const TOKEN_ALIASES = new Map(
+  (Object.keys(BUILTINS) as BuiltinCommandName[]).flatMap(name =>
+    [zh[`token.${name}`], en[`token.${name}`]].map(token => [token, name] as const)),
+)
+
+/**
+ * Resolve typed spelling against the current Session's effective definitions.
+ * @param token - typed name without its leading slash.
+ * @param descriptors - effective descriptors in the Session's ready catalog.
+ * @returns the matching descriptor; aliases never select an unrelated scoped override.
+ */
+export function resolveCommand(
+  token: string,
+  descriptors: readonly CommandDescriptor[],
+): CommandDescriptor | undefined {
+  const exact = descriptors.find(descriptor => descriptor.name === token)
+  if (exact !== undefined) return exact
+  const name = TOKEN_ALIASES.get(token)
+  if (name === undefined) return undefined
+  return descriptors.find(descriptor => descriptor.definitionId === BUILTINS[name])
+}

+ 14 - 14
packages/client/ui-commands/src/client/service.ts

@@ -29,7 +29,8 @@ import type { CommandContribution, CommandDecoration, CommandUiContract } from '
 import type { CommandDescriptor } from './directory.ts'
 import { CommandDirectory } from './directory.ts'
 import { PopupSelectController } from './popup.ts'
-import { builtinRowFace, claimToken, resolveCommandName, sectionRows } from './presentation.ts'
+import { builtinRowFace, sectionRows } from './presentation.ts'
+import { claimToken } from './resolution.ts'
 import type { TokenSegment } from './popup.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -201,7 +202,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
       seen.add(c.name)
       rows.push({
         name: c.name,
-        ...(builtinRowFace(c.name, c.description, this.t) ?? { description: c.description }),
+        ...(builtinRowFace(c, this.t) ?? { description: c.description }),
         ...(c.input !== undefined ? { hint: c.input.hint } : {}),
       })
     }
@@ -239,7 +240,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
       this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
       return 'handled'
     }
-    if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc.name, desc.description, this.t)) }
+    if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc, this.t)) }
     // Menu-pick execute consumes the trigger span before the detached run
     // (scoped event; the input owns the CAS guard).
     this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
@@ -250,9 +251,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
   /** Decision table, space column: hot-key sync check; only host leadingInput claims. */
   private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
     if (!token.startsWith('/')) return undefined
-    const name = resolveCommandName(token.slice(1))
-    if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space
-    const desc = this.directory.resolve(session.sessionId, name)
+    if (this.live.contributions.has(token.slice(1))) return undefined // popup and action kinds never claim on space
+    const desc = this.directory.resolve(session.sessionId, token.slice(1))
     if (desc === undefined || desc.input === undefined) return undefined
     return { claim: this.leadingClaim(desc, session, token.slice(1)) }
   }
@@ -284,23 +284,23 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     const ws = trimmed.search(/\s/)
     const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
     const bare = ws === -1
-    const name = resolveCommandName(token.slice(1))
-    if (name === '') return undefined
-    // The Host line always carries the catalog name.
-    const canonical = `/${name}${trimmed.slice(token.length)}`
+    const typedName = token.slice(1)
+    if (typedName === '') return undefined
     const refuseAttachments = (): never => {
-      throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
+      throw new Error(this.t('notice.attachmentsUnsupported', { command: typedName }))
     }
-    const contribution = this.live.contributions.get(name)
+    const contribution = this.live.contributions.get(typedName)
     if (contribution !== undefined && contribution.available(session)) {
       if (!bare) return undefined
       if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments()
-      this.invoke(name, contribution.ui, session, { via: 'enter', token })
+      this.invoke(typedName, contribution.ui, session, { via: 'enter', token })
       return 'handled'
     }
     await this.directory.ensureReady(session.sessionId, signal)
-    const desc = this.directory.resolve(session.sessionId, name)
+    const desc = this.directory.resolve(session.sessionId, typedName)
     if (desc === undefined) return undefined
+    const name = desc.name
+    const canonical = `/${name}${trimmed.slice(token.length)}`
     // Bare enter on a decorated host command opens its popup; an argued line
     // never consults the decoration (the claim/detached paths below own it).
     if (bare) {

+ 5 - 15
packages/client/ui-commands/tests/browser-plugin.client.spec.ts

@@ -7,7 +7,7 @@
  * (HMR safety), and the service satisfies the frozen CommandUiContract.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished } from 'vitest'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -76,23 +76,13 @@ describe('apply', () => {
     expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
   })
 
-  it('registers the File row: an action that consumes the token and opens the scoped file picker; absent on a subagent', async () => {
-    const { sources, mint } = await bench()
+  it('provides no File action without its composer owner', async () => {
+    const { fiber, sources } = await bench()
+    onTestFinished(() => fiber.dispose())
     const source = sources.get('/ command')!
     const req = { query: '', position: 'leading' as const, drilled: false, signal: new AbortController().signal }
-    const rows = await source.candidates({ sessionId: sid('s1') }, req)
-    expect(rows).toEqual([{ name: 'file', label: 'File', icon: expect.any(Function) as never, section: 'Add' }])
+    expect(await source.candidates({ sessionId: sid('s1') }, req)).toEqual([])
     expect(await source.candidates({ sessionId: sid('child') }, req)).toEqual([])
-    const scope = mint('s1')
-    const picks: string[] = []
-    scope.ctx.on('slash/input-consume-token', () => { picks.push('consume'); return true })
-    scope.ctx.on('slash/input-pick-files', () => { picks.push('pick-files'); return true })
-    const span = { start: 0, end: 5, draftRev: 1 }
-    expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('s1') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled')
-    expect(picks).toEqual(['consume', 'pick-files'])
-    // A session without a live scope runs nothing and throws nothing.
-    expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('gone') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled')
-    expect(picks).toHaveLength(2)
   })
 
   it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {

+ 23 - 0
packages/client/ui-commands/tests/directory.client.spec.ts

@@ -7,6 +7,7 @@
  */
 import { describe, expect, it } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandDirectory } from '../src/client/directory.ts'
 
@@ -52,6 +53,28 @@ function bench() {
 }
 
 describe('status and resolve (per key)', () => {
+  it('resolves bilingual aliases by definition identity and retains exact-name priority', async () => {
+    const goal = {
+      definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'),
+      name: 'objective',
+      description: 'Reworded description.',
+    }
+    const exact = { name: 'goal', description: 'independent exact-name entry' }
+    const { dir, pull } = bench()
+    const refreshed = dir.refresh(S1)
+    pull(S1, 0).resolve([goal])
+    await refreshed
+    expect(dir.resolve(S1, '目标')).toEqual(goal)
+    expect(dir.resolve(S1, 'goal')).toEqual(goal)
+    expect(dir.resolve(S1, '计划')).toBeUndefined()
+    expect(dir.resolve(S1, 'unregistered')).toBeUndefined()
+    const next = dir.refresh(S1)
+    pull(S1, 1).resolve([goal, exact])
+    await next
+    expect(dir.resolve(S1, 'goal')).toEqual(exact)
+    expect(dir.resolve(S1, '目标')).toEqual(goal)
+  })
+
   it('starts cold and resolves nothing', () => {
     const { dir } = bench()
     expect(dir.status(S1)).toBe('cold')

+ 38 - 12
packages/client/ui-commands/tests/service.client.spec.ts

@@ -8,8 +8,9 @@
  * lifecycle, and the directory invalidation event subscriptions.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
@@ -18,6 +19,7 @@ import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, Input
 import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts'
 import type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandUiRuntime } from '../src/client/service.ts'
+import { en, zh, type CommandKey } from '../src/client/locales.ts'
 
 const sid = (k: string): SessionId => k as SessionId
 
@@ -257,7 +259,7 @@ describe('candidates', () => {
   it('localizes canonical built-in and contribution descriptions on every candidate request', async () => {
     let locale = 'zh'
     const commands: CommandDescriptor[] = [
-      { name: 'compact', description: 'Compact older conversation history' },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' },
       { name: 'goal', description: 'scoped goal override' },
       { name: 'custom', description: 'plugin-authored copy' },
     ]
@@ -292,14 +294,14 @@ describe('candidates', () => {
   })
 
   describe('menu presentation (design doc for #3567)', () => {
-    /** The shipped catalog in Host registration order, with the canonical English descriptions. */
+    /** First-party definitions plus an unrelated command, in Host registration order. */
     const SHIPPED: CommandDescriptor[] = [
-      { name: 'compact', description: 'Compact older conversation history' },
-      { name: 'export', description: 'Download this Session log as a ZIP archive' },
-      { name: 'feedback', description: 'Record feedback about this session', input: { hint: '<text>' } },
-      { name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
-      { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
-      { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-session-log-export'), name: 'export', description: 'Download this Session log as a ZIP archive' },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-feedback'), name: 'feedback', description: 'Record feedback about this session', input: { hint: '<text>' } },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
       { name: 'deploy', description: 'third-party command' },
     ]
     const Glyph = () => null
@@ -345,11 +347,35 @@ describe('candidates', () => {
       expect(rows[8]).toEqual({ name: 'deploy', description: 'third-party command', section: 'command:section.commands' })
     })
 
-    it('a Host row of a built-in name with another description is not the built-in command', async () => {
-      const commands: CommandDescriptor[] = [{ name: 'goal', description: 'scoped goal', input: { hint: 'x' } }]
+    it('a same-name override keeps its own presentation even when it copies the first-party description', async () => {
+      const commands: CommandDescriptor[] = [{ name: 'goal', description: en['description.goal'], input: { hint: 'x' } }]
       const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
       const [row] = await source.candidates(proj('s1'), req(''))
-      expect(row).toEqual({ name: 'goal', description: 'scoped goal', hint: 'x', section: 'command:section.add' })
+      expect(row).toEqual({ name: 'goal', description: en['description.goal'], hint: 'x', section: 'command:section.add' })
+      expect(source.matchSpace!(proj('s1'), '/目标')).toBeUndefined()
+      expect(await source.matchEnter!(proj('s1'), '/目标 x', new AbortController().signal, { attachments: 0 })).toBeUndefined()
+      expect(source.matchSpace!(proj('s1'), '/goal')).toHaveProperty('claim.name', 'goal')
+    })
+
+    it.each([['en', en], ['zh', zh]] as const)('description edits preserve menu claims and bilingual parsing under %s', async (_locale, dictionary) => {
+      const commands = SHIPPED.map(command => ({ ...command, description: command.description + '.' }))
+      const { fiber, source, warm } = await bench({
+        commands: () => Promise.resolve({ commands }),
+        translate: (_namespace, key) => dictionary[key as CommandKey],
+      })
+      onTestFinished(() => fiber.dispose())
+      await warm(proj('s1'))
+      const rows = await source.candidates(proj('s1'), req(''))
+      for (const name of ['goal', 'plan', 'feedback'] as const) {
+        expect(rows.find(row => row.name === name)?.label).toBe(dictionary[`label.${name}`])
+        const picked = menuPick(source, name, proj('s1'))
+        expect(picked).toHaveProperty('claim.token', `/${dictionary[`token.${name}`]} `)
+        for (const spelling of [en[`token.${name}`], zh[`token.${name}`]]) {
+          expect(source.matchSpace!(proj('s1'), `/${spelling}`)).toHaveProperty('claim.name', name)
+          expect(await source.matchEnter!(proj('s1'), `/${spelling} text`, new AbortController().signal, { attachments: 0 }))
+            .toHaveProperty('claim.token', `/${spelling} `)
+        }
+      }
     })
 
     it('a typed query ranks rows flat by name or label and drops the section headings', async () => {

+ 2 - 2
packages/client/ui-conversation/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-conversation/README.md
-README.md: e1f1b304798c348c2f005558c51331d041a63f24
-README.zh.md: ccdf4fbff4b347e3a471b292ee91b8322b109f88
+README.md: 8d68dfdb61943c7c45b38d9244134edecc86a593
+README.zh.md: b1d548ba8fc9276a6864548802a45796e74c0c7a

+ 2 - 0
packages/client/ui-conversation/README.md

@@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re
 <a id="shell-and-standard-props"></a>
 ## Shell and standard props
 
+The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
+
 Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty.
 
 Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.

+ 2 - 0
packages/client/ui-conversation/README.zh.md

@@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 <a id="shell-and-standard-props"></a>
 ## Shell 与标准 props
 
+输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
+
 已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
 
 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。

+ 23 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -2,6 +2,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -92,6 +93,17 @@ interface WorkspaceNavigation {
   ): Promise<void>
 }
 
+/** Action registration used by the composer without importing its command-UI consumer. */
+interface FileCommandRegistry {
+  register(contribution: {
+    name: string
+    label(): string
+    icon: typeof IconPaperclipOutline16
+    available(session: { sessionId: SessionId }): boolean
+    ui: { kind: 'action'; run(session: { sessionId: SessionId }): void }
+  }): () => void
+}
+
 /** Resolve the session-scoped Conversation action face, failing loud. */
 function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
   const scoped = sessions.scope(id)
@@ -197,6 +209,17 @@ export function apply(ctx: Context, config: Config = Config({})): void {
   const inputHub = new InputHub(ctx, t)
   const composerBlocks = new ComposerBlockRegistry()
 
+  ctx.inject(['commandUi'], (scope) => {
+    const commands = scope.get('commandUi') as FileCommandRegistry
+    scope.effect(() => commands.register({
+      name: 'file',
+      label: () => t('input.file'),
+      icon: IconPaperclipOutline16,
+      available: session => inputHub.canPickFiles(session.sessionId),
+      ui: { kind: 'action', run: (session) => { inputHub.pickFiles(session.sessionId) } },
+    }), 'ui-conversation: File action')
+  })
+
   // Conversation assembly and input share the Session binding lifecycle. The
   // source roster is installed before any consuming Slot entry.
   ctx.uiSession.provide({

+ 3 - 10
packages/client/ui-conversation/src/client/contract/input.ts

@@ -176,12 +176,6 @@ declare module '@deepseek-ai/cordis' {
      * @mode bail
      */
     'slash/input-insert-text'(request: InsertTextRequest): true | undefined
-    /**
-     * Open the composer's file picker for the scoped session (the menu's
-     * File row); true when a mounted composer accepting files opened it.
-     * @mode bail
-     */
-    'slash/input-pick-files'(): true | undefined
   }
 }
 
@@ -298,12 +292,11 @@ export interface ComposerKeyboard {
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   dismissPopup(): void
   /**
-   * Bind the composer's file-picker opener for the scoped pick-files event;
-   * the composer decides inside `open` whether intake is currently allowed.
-   * @param open - opens the native file dialog.
+   * Bind the mounted composer's file action and live intake availability.
+   * @param picker - availability query and native file-dialog opener.
    * @returns the unbind disposer.
    */
-  bindFilePicker(open: () => void): () => void
+  bindFilePicker(picker: { available(): boolean; open(): void }): () => void
 }
 
 /** One independently addressable row projected from the transient queue snapshot. */

+ 19 - 11
packages/client/ui-conversation/src/client/input/facade.ts

@@ -21,7 +21,7 @@ import { registerPlainText } from '@lexical/plain-text'
 import { createEmptyHistoryState, registerHistory } from '@lexical/history'
 import { mergeRegister } from '@lexical/utils'
 import type {
-  ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
+  ArbitrateKey, ArbitrateOutcome, CommandClaim, ComposerKeyboard, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
   Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
   SubmitOutcome, TokenSpan,
@@ -156,7 +156,7 @@ export class SessionInputShell implements SessionInput {
   /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
   private mirrorFn: ((text: string) => void) | undefined
   /** The mounted composer's file-picker opener (scoped pick-files event target). */
-  private filePickerFn: (() => void) | undefined
+  private filePicker: Parameters<ComposerKeyboard['bindFilePicker']>[0] | undefined
   /** Live lexicon subscription disposer; undefined until the controller resolves. */
   private lexiconOff: (() => void) | undefined
   /** Default sends retained until admission settles or scope disposal releases their attachments. */
@@ -613,24 +613,32 @@ export class SessionInputShell implements SessionInput {
   }
 
   /**
-   * Bind the composer's file-picker opener (ComposerKeyboard face; unbind on unmount).
-   * @param open - opens the native file dialog.
+   * Bind the mounted composer's file action and live intake availability.
+   * @param picker - availability query and native file-dialog opener.
    * @returns the unbind disposer.
    */
-  bindFilePicker(open: () => void): () => void {
-    this.filePickerFn = open
+  bindFilePicker(picker: Parameters<ComposerKeyboard['bindFilePicker']>[0]): () => void {
+    this.filePicker = picker
     return () => {
-      if (this.filePickerFn === open) this.filePickerFn = undefined
+      if (this.filePicker === picker) this.filePicker = undefined
     }
   }
 
   /**
-   * Open the bound file picker (scoped pick-files event listener body).
-   * @returns whether a composer was bound to open it.
+   * Read the mounted composer's live file-intake availability.
+   * @returns false when no accepting composer is mounted.
+   */
+  canPickFiles(): boolean {
+    return this.filePicker?.available() === true
+  }
+
+  /**
+   * Open the native file dialog when the mounted composer accepts files.
+   * @returns whether the opener was called.
    */
   pickFiles(): boolean {
-    if (this.filePickerFn === undefined) return false
-    this.filePickerFn()
+    if (this.filePicker === undefined || !this.filePicker.available()) return false
+    this.filePicker.open()
     return true
   }
 

+ 17 - 1
packages/client/ui-conversation/src/client/input/hub.ts

@@ -121,7 +121,6 @@ export class InputHub implements SessionInputResolver {
           shell.consumeToken(req.guard) ? true : undefined),
         actx.on('slash/input-insert-text', req =>
           shell.insertText(req.text, req.span, req.continue === true) ? true : undefined),
-        actx.on('slash/input-pick-files', () => shell.pickFiles() ? true : undefined),
       ]
       return () => {
         for (const off of offs) off()
@@ -159,6 +158,23 @@ export class InputHub implements SessionInputResolver {
     return this.shell(id)
   }
 
+  /**
+   * Query file intake without creating a Session input.
+   * @param id - target Session.
+   * @returns whether its mounted composer currently accepts files.
+   */
+  canPickFiles(id: SessionId): boolean {
+    return this.shells.get(id)?.canPickFiles() === true
+  }
+
+  /**
+   * Open the target composer's file dialog under its live intake policy.
+   * @param id - target Session.
+   */
+  pickFiles(id: SessionId): void {
+    this.shells.get(id)?.pickFiles()
+  }
+
   /**
    * Resolve the optional slash controller for composer chrome that launches
    * the shared candidate menu without typing a trigger.

+ 2 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -20,6 +20,7 @@ export const zh = {
   'placeholder.workspace': '选择一个工作区开始',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
   'input.commands': '添加文件或调用指令',
+  'input.file': '文件',
   'input.stop': '停止生成',
   'input.send': '发送消息',
   'input.send.queue': '排队发送',
@@ -185,6 +186,7 @@ export const en = {
   'placeholder.workspace': 'Choose a workspace to start',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
   'input.commands': 'Add files or run commands',
+  'input.file': 'File',
   'input.stop': 'Stop generating',
   'input.send': 'Send message',
   'input.send.queue': 'Queue message',

+ 3 - 4
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -273,12 +273,11 @@ export const InputBar = memo(function InputBar({
     intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
   }
 
-  // The menu's File row opens the same hidden input the drop path feeds,
-  // under the drop gate the row itself cannot see.
   useEffect(() => {
     if (keyboard === undefined) return
-    return keyboard.bindFilePicker(() => {
-      if (gate.current.canAcceptDrop) fileInputRef.current?.click()
+    return keyboard.bindFilePicker({
+      available: () => gate.current.canAcceptDrop && fileInputRef.current !== null,
+      open: () => { fileInputRef.current?.click() },
     })
   }, [keyboard])
 

+ 46 - 1
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -1,5 +1,6 @@
 // @vitest-environment jsdom
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -113,6 +114,50 @@ async function bench() {
 }
 
 describe('Conversation inject API', () => {
+  it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => {
+    const b = await bench()
+    onTestFinished(() => b.runtime.dispose())
+    const contributions = new Map<string, CommandContribution>()
+    const registry = {
+      register: (contribution: CommandContribution) => {
+        contributions.set(contribution.name, contribution)
+        return () => { contributions.delete(contribution.name) }
+      },
+    } satisfies Pick<CommandUiContract, 'register'>
+    b.runtime.ctx.provide('commandUi', registry)
+    await vi.waitFor(() => { expect(contributions.has('file')).toBe(true) })
+    const file = contributions.get('file')!
+    const target = { sessionId: ROOT }
+    expect(file.label!()).toBe('文件')
+    expect(file.available(target)).toBe(false)
+    expect(file.available({ sessionId: 'missing' as SessionId })).toBe(false)
+    if (file.ui.kind !== 'action') throw new Error('File must be an action')
+    file.ui.run({ sessionId: 'missing' as SessionId })
+    const keyboard = b.composerApi(ROOT).keyboard!
+    const open = vi.fn()
+    let available = true
+    const unbind = keyboard.bindFilePicker({ open, available: () => available })
+    expect(file.available(target)).toBe(true)
+    file.ui.run(target)
+    expect(open).toHaveBeenCalledOnce()
+    available = false
+    expect(file.available(target)).toBe(false)
+    file.ui.run(target)
+    expect(open).toHaveBeenCalledOnce()
+    const replacement = vi.fn()
+    const removeReplacement = keyboard.bindFilePicker({ open: replacement, available: () => true })
+    unbind()
+    expect(file.available(target)).toBe(true)
+    file.ui.run(target)
+    expect(replacement).toHaveBeenCalledOnce()
+    removeReplacement()
+    expect(file.available(target)).toBe(false)
+    file.ui.run(target)
+    expect(replacement).toHaveBeenCalledOnce()
+    await b.feature.dispose()
+    expect(contributions.size).toBe(0)
+  })
+
   it('assembles the target-neutral read face without Session side effects', async () => {
     const b = await bench()
     const { injected } = b.conversationApi(ROOT)

+ 30 - 2
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -952,9 +952,10 @@ describe('running and lock semantics', () => {
     expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
     expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(true)
     expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
-    // The menu's File row reaches the same gate: a bound picker stays shut.
     const click = vi.spyOn(HTMLInputElement.prototype, 'click')
-    expect(shell.pickFiles()).toBe(true)
+    onTestFinished(() => { click.mockRestore() })
+    expect(shell.canPickFiles()).toBe(false)
+    expect(shell.pickFiles()).toBe(false)
     expect(click).not.toHaveBeenCalled()
     click.mockRestore()
     fireEvent.click(button)
@@ -994,11 +995,38 @@ describe('running and lock semantics', () => {
     expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
     // The menu's File row opens the hidden input through the bound picker.
     const click = vi.spyOn(HTMLInputElement.prototype, 'click')
+    onTestFinished(() => { click.mockRestore() })
+    expect(shell.canPickFiles()).toBe(true)
     expect(shell.pickFiles()).toBe(true)
     expect(click).toHaveBeenCalledOnce()
     click.mockRestore()
   })
 
+  it.each([
+    ['removed session', { disabled: true }],
+    ['inert composer', { inert: true }],
+    ['blocked composer', { blocked: { reason: 'waiting' } }],
+  ])('%s hides and refuses the File action', (_name, state) => {
+    const { shell, view } = bench({ ...state, addFiles: () => null })
+    const input = view.container.querySelector<HTMLInputElement>('input[type="file"]')!
+    const open = vi.spyOn(input, 'click')
+    expect(shell.canPickFiles()).toBe(false)
+    expect(shell.pickFiles()).toBe(false)
+    expect(open).not.toHaveBeenCalled()
+  })
+
+  it('file action availability follows mount and live composer state', () => {
+    const { props, view, shell } = bench({ addFiles: () => null })
+    expect(shell.canPickFiles()).toBe(true)
+    view.rerender(<InputBar {...props} disabled />)
+    expect(shell.canPickFiles()).toBe(false)
+    view.rerender(<InputBar {...props} />)
+    expect(shell.canPickFiles()).toBe(true)
+    view.unmount()
+    expect(shell.canPickFiles()).toBe(false)
+    expect(shell.pickFiles()).toBe(false)
+  })
+
   it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
     const { button, interruptButton, textarea, stop, view } = bench({
       running: true,

+ 2 - 0
packages/compaction/command-compact/src/index.ts

@@ -4,6 +4,7 @@
  */
 
 import type { Context } from '@deepseek-ai/cordis'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import { ManualCompactionError } from '@deepseek-ai/dsh-compaction'
 import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
 
@@ -98,6 +99,7 @@ export function apply(ctx: Context): void {
     // invocation can enter while already-started handler promises quiesce.
     yield async () => { await Promise.allSettled(active) }
     yield ctx.commands.register({
+      definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'),
       name: 'compact',
       description: 'Compact older conversation history',
       handler,

+ 1 - 0
packages/compaction/command-compact/tests/command-compact.spec.ts

@@ -162,6 +162,7 @@ describe('@deepseek-ai/dsh-command-compact registration', () => {
     const loader = Object.create(Loader.prototype) as Loader
     expect(loader.unwrapExports(commandCompact)).toBe(commandCompact)
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
+      definitionId: '@deepseek-ai/dsh-command-compact',
       name: 'compact',
       description: 'Compact older conversation history',
     })

+ 1 - 0
packages/compaction/command-compact/tests/loader-composition.spec.ts

@@ -120,6 +120,7 @@ describe('command-compact real Loader composition', () => {
       reserveTurnAdmission: () => () => undefined,
     } as unknown as Agent
     expect(context.commands.list(agent)).toContainEqual({
+      definitionId: '@deepseek-ai/dsh-command-compact',
       name: 'compact',
       description: 'Compact older conversation history',
     })

+ 2 - 0
packages/feedback/command-feedback/src/index.ts

@@ -9,6 +9,7 @@
  */
 
 import type { Context } from '@deepseek-ai/cordis'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
 import type { Session } from '@deepseek-ai/dsh-session'
 import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
@@ -116,6 +117,7 @@ export class SessionFeedbackService extends TypertRemoteService {
 export function apply(ctx: Context): void {
   ctx.plugin(SessionFeedbackService)
   ctx.commands.register({
+    definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-feedback'),
     name: 'feedback',
     description: 'Record feedback about this session',
     input: { hint: '<text>' },

+ 1 - 0
packages/feedback/command-feedback/tests/command-feedback.spec.ts

@@ -95,6 +95,7 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => {
     expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback)
 
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
+      definitionId: '@deepseek-ai/dsh-command-feedback',
       name: 'feedback',
       description: 'Record feedback about this session',
       input: { hint: '<text>' },

+ 2 - 0
packages/goal/command-goal/src/index.ts

@@ -4,6 +4,7 @@
  */
 
 import type { Context } from '@deepseek-ai/cordis'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
 import { GoalError } from '@deepseek-ai/dsh-goal'
 import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
@@ -188,6 +189,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman
 /** Register the Codex-shaped `/goal` command for every composed command adapter. */
 export function apply(ctx: Context): void {
   ctx.commands.register({
+    definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'),
     name: 'goal',
     description: 'Set or view the goal for a long-running task',
     input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },

+ 1 - 0
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -98,6 +98,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => {
     expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
 
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
+      definitionId: '@deepseek-ai/dsh-command-goal',
       name: 'goal',
       description: 'Set or view the goal for a long-running task',
       input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },

+ 2 - 2
packages/interaction/commands/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/interaction/commands/README.md
-README.md: 21ee4617a974e05f1872d295389feff7ba6a2130
-README.zh.md: 17c40e231fd98aec4465e0288f6298807c82306c
+README.md: bfcb82786a71a938ed793deb591ff45d32ffe1e7
+README.zh.md: 33e128f12ff765ee00adc7ae907da957c06244d8

+ 2 - 2
packages/interaction/commands/README.md

@@ -29,7 +29,7 @@ Compose this service when an interactive UI should let users drive agent-side be
 
 ### Registering a command
 
-A plugin registers a command with `ctx.commands.register()`: a lowercase name, a description shown in discovery, an optional `input` hint, and a handler that runs against the receiving agent.
+A plugin registers a command with `ctx.commands.register()`: a lowercase name, a discovery description, an optional `input` hint, and a handler. An optional branded `definitionId` gives the definition a stable, plugin-namespaced identity for adapters; it is independent of display copy and execution `commandId`. The effective descriptor carries only the selected definition's identity, so a scoped override never inherits the shadowed registration's identity.
 
 ```text
 ctx.commands.register({
@@ -81,7 +81,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi
 |---|---|
 | [`src/index.ts`](src/index.ts) | `CommandRuntime` service: registration, scoping, dispatch, lifecycle events |
 | [`src/types.ts`](src/types.ts) | Command definition, descriptor, execution, and result types |
-| [`src/brand.ts`](src/brand.ts) | `CommandId` brand for lifecycle pairing ids |
+| [`src/brand.ts`](src/brand.ts) | Stable command-definition identities and per-execution lifecycle ids |
 | [`src/invariant.ts`](src/invariant.ts) | Invariant companion pairing `command/run` with `command/done` per session log |
 
 ### Lifecycle events

+ 2 - 2
packages/interaction/commands/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 注册命令
 
-插件用 `ctx.commands.register()` 注册命令:小写名称、在发现界面中展示的描述、可选的 `input` 提示,以及针对接收 agent 运行的处理器。
+插件通过 `ctx.commands.register()` 注册命令,提供小写名称、发现界面中的说明、可选的 `input` 提示和处理器。可选的品牌类型字段 `definitionId` 为适配器提供带插件命名空间的稳定定义标识,它独立于显示文案和每次执行的 `commandId`。有效描述符只携带被选中定义的标识,作用域覆盖不会继承被遮蔽注册项的标识。
 
 ```text
 ctx.commands.register({
@@ -81,7 +81,7 @@ ctx.commands.register({
 |---|---|
 | [`src/index.ts`](src/index.ts) | `CommandRuntime` 服务:注册、作用域、分派、生命周期事件 |
 | [`src/types.ts`](src/types.ts) | 命令定义、描述符、执行与结果类型 |
-| [`src/brand.ts`](src/brand.ts) | 生命周期配对 id 的 `CommandId` brand |
+| [`src/brand.ts`](src/brand.ts) | 稳定命令定义标识和每次执行的生命周期 id |
 | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:按会话日志配对 `command/run` 与 `command/done` |
 
 ### 生命周期事件

+ 12 - 0
packages/interaction/commands/src/brand.ts

@@ -12,6 +12,18 @@
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
 
+/** Stable, plugin-owned identity of a command definition, independent of its name and copy. */
+export type CommandDefinitionId = Branded<'CommandDefinitionId'>
+
+/**
+ * Brand a plugin-namespaced command definition identity.
+ * @param id - stable identity chosen by the registering plugin.
+ * @returns the same string, branded; no validation is performed.
+ */
+export function CommandDefinitionId(id: string): CommandDefinitionId {
+  return id as CommandDefinitionId
+}
+
 /**
  * Pairs one command execution's `command/run`/`command/done` lifecycle
  * records with each other and with the `command.execute` admission response.

+ 6 - 1
packages/interaction/commands/src/index.ts

@@ -15,6 +15,7 @@ import { SessionSeq } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
 import { CommandId } from './brand.ts'
+import type { CommandDefinitionId } from './brand.ts'
 import type {
   CommandDescriptor,
   CommandExecution,
@@ -23,7 +24,7 @@ import type {
   CommandSubmitAttachment,
 } from './types.ts'
 
-export { CommandId } from './brand.ts'
+export { CommandDefinitionId, CommandId } from './brand.ts'
 export type * from './types.ts'
 
 export const name = 'commands'
@@ -58,6 +59,8 @@ export interface CommandInvocation {
 
 /** Plugin-owned command registration. */
 export interface CommandDefinition {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */
@@ -205,6 +208,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
     })
   }
   const normalized = Object.freeze({
+    ...definition.definitionId === undefined ? {} : { definitionId: definition.definitionId },
     name: definition.name,
     description: definition.description,
     ...input === undefined ? {} : { input },
@@ -212,6 +216,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
     handler: definition.handler,
   })
   const descriptor = Object.freeze({
+    ...normalized.definitionId === undefined ? {} : { definitionId: normalized.definitionId },
     name: normalized.name,
     description: normalized.description,
     ...normalized.input === undefined ? {} : { input: normalized.input },

+ 3 - 1
packages/interaction/commands/src/types.ts

@@ -8,7 +8,7 @@
  */
 
 import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
-import type { CommandId } from './brand.ts'
+import type { CommandDefinitionId, CommandId } from './brand.ts'
 import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
 
 /** One browser-submitted command attachment: encoded image input or a staged file receipt. */
@@ -55,6 +55,8 @@ export interface CommandExecution {
 
 /** Handler-free immutable command view returned to UI adapters. */
 export interface CommandDescriptor {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */

+ 7 - 3
packages/interaction/commands/tests/commands.spec.ts

@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
 import type { Scope } from '@deepseek-ai/dsh-scope'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import CommandRuntime, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
+import CommandRuntime, { CommandDefinitionId, parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
 import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
 
 function command(name: string, text = `ran:${name}`): CommandDefinition {
@@ -58,6 +58,7 @@ describe('CommandRuntime', () => {
     const ctx = await mount()
     const { agent } = await mintAgentScope(ctx, 'a')
     const definition: CommandDefinition = {
+      definitionId: CommandDefinitionId('example/inspect'),
       name: 'inspect',
       description: 'Inspect state',
       input: { hint: '<target>' },
@@ -67,6 +68,7 @@ describe('CommandRuntime', () => {
 
     const listed = ctx.commands.list(agent)
     expect(listed).toEqual([{
+      definitionId: CommandDefinitionId('example/inspect'),
       name: 'inspect',
       description: 'Inspect state',
       input: { hint: '<target>' },
@@ -74,7 +76,7 @@ describe('CommandRuntime', () => {
     expect(Object.isFrozen(listed)).toBe(true)
     expect(Object.isFrozen(listed[0])).toBe(true)
     expect(Object.isFrozen(listed[0]?.input)).toBe(true)
-    expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
+    expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect', definitionId: CommandDefinitionId('example/inspect') })
     expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
   })
 
@@ -91,16 +93,18 @@ describe('CommandRuntime', () => {
     const ctx = await mount()
     const { scope, agent } = await mintAgentScope(ctx, 'a')
     const other = { id: 'other' as SessionId } as Agent
-    ctx.commands.register(command('shared', 'global'))
+    ctx.commands.register({ ...command('shared', 'global'), definitionId: CommandDefinitionId('example/shared') })
     scope.ctx.commands.register(command('shared', 'scoped'))
 
     expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
     expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
+    expect(ctx.commands.list(agent)[0]).not.toHaveProperty('definitionId')
     expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
     expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result)
       .toEqual({ kind: 'success', text: 'scoped' })
 
     await scope.dispose()
+    expect(ctx.commands.list(agent)[0]?.definitionId).toBe('example/shared')
     expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result.text).toBe('global')
   })
 

+ 2 - 0
packages/interaction/permission-presets/src/index.ts

@@ -11,6 +11,7 @@
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import z from '@deepseek-ai/schemastery'
 import { z as zod } from 'zod'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -254,6 +255,7 @@ export class PermissionPresetService extends Service {
     // activates only when a command registry is composed.
     ctx.inject(['commands'], (commandCtx) => {
       commandCtx.commands.register({
+        definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'),
         name: 'permission',
         description: 'Switch the permission preset (sandbox mode + approval policy)',
         input: { hint: '<preset>' },

+ 2 - 0
packages/plan/plan-mode/src/index.ts

@@ -23,6 +23,7 @@
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import { z as zod } from 'zod'
 import type { ZodType } from 'zod'
 import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
@@ -223,6 +224,7 @@ export class PlanModeController extends Service {
     // The command child activates only when a command registry is composed.
     ctx.inject(['commands'], (commandCtx) => {
       commandCtx.commands.register({
+        definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'),
         name: 'plan',
         description: 'Enter or leave plan mode',
         input: { hint: '[off|message]', attachments: true },

+ 1 - 1
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -620,7 +620,7 @@ describe('/plan', () => {
     const plainSteer = vi.fn()
     ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
     expect(ctx.commands.list(plainAgent)).toEqual([
-      { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
+      { definitionId: '@deepseek-ai/dsh-plan-mode', name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
     ])
 
     const signal = new AbortController().signal

+ 2 - 0
packages/session-query/session-log-export/src/index.ts

@@ -1,6 +1,7 @@
 /** Session-log download command and Host-owned streaming route. */
 
 import type { Context } from '@deepseek-ai/cordis'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import Schema from '@deepseek-ai/schemastery'
 import { brandString } from '@deepseek-ai/dsh-brand'
 import type {} from '@deepseek-ai/dsh-attachment'
@@ -76,6 +77,7 @@ const REQUESTED: CommandResult = {
  */
 export function apply(ctx: Context, config: Config = {}): void {
   ctx.effect(() => ctx.commands.register({
+    definitionId: CommandDefinitionId('@deepseek-ai/dsh-session-log-export'),
     name: 'export',
     description: 'Download this Session log as a ZIP archive',
     handler: invocation => Promise.resolve(invocation.rawInput.trim() === ''

+ 1 - 0
packages/session-query/session-log-export/tests/loader-composition.host.spec.ts

@@ -61,6 +61,7 @@ describe('session-log-download real Loader composition', () => {
       .create(SessionId('loader-session-export'), { meta: { createdAt: 1 } })
     const agent = { session, status: 'idle', options: {} } as unknown as Agent
     expect(context.commands.list(agent)).toContainEqual({
+      definitionId: '@deepseek-ai/dsh-session-log-export',
       name: 'export', description: 'Download this Session log as a ZIP archive',
     })
     const execution = await context.commands.execute(agent, '/export', [], new AbortController().signal)