diff --git a/.agents/notes/proposed/architecture/2026-08-08-cordis-web-dynamic-packages.md b/.agents/notes/proposed/architecture/2026-08-08-cordis-web-dynamic-packages.md
new file mode 100644
index 0000000000..472064dfc5
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-08-08-cordis-web-dynamic-packages.md
@@ -0,0 +1,68 @@
+# Agent Note: Cordis Host/Client Dynamic Plugin Runtime
+
+Status: proposed
+
+English | [中文](2026-08-08-cordis-web-dynamic-packages.zh.md)
+
+## Problem
+
+The model needs to extend the current DSH process temporarily without modifying repository source, rebuilding the application, or refreshing the browser. An extension may run in the Host Node.js process, in a Client browser page, or as one plugin whose Host half retrieves data and whose Client half presents it.
+
+This capability cannot be limited to “execute some code.” Before writing code, the model needs to discover the Services, Events, Builtins, Slots, and theme tokens available on both platforms. The user needs to preview the code before deciding whether Client code may enter the page. A single plugin needs immutable versions, retries after failure, and rollback. Asynchronous runtime errors need to return to the model instead of remaining only in server logs or the browser console.
+
+Combining definition, approval, execution, version switching, capability discovery, and UI state into one action creates states that cannot be explained consistently: whether a successful definition also means a successful run; which version remains successful after a failed update; how long a Tool should wait when no page responds; which historical card owns the business UI after the same Package runs multiple times; and whether page-local Client load state can represent process-wide Host state.
+
+## Proposal
+
+### Core principles
+
+- The Host is the sole process-wide authority for Plugins, Packages, Runs, approvals, and version pointers.
+- The Client stores only the current page's approval interaction, load results, Slot contributions, business views, and page-local errors.
+- Define creates only immutable code versions; Run activates only a defined version.
+- A version switch commits `currentPackageId` only after the target Package completes its required Host/Client activation.
+- Before writing code, the model queries capabilities through Inspect Providers. Inspect results assist coding and are not plugin runtime business data.
+- Dynamic Host and Client code both use restricted plain JavaScript contexts and attach reversible side effects to the Cordis lifecycle.
+- Client code requires user authorization before entering a page. Authorization may cover one Package or future versions of the same Plugin.
+- Tool calls do not wait for approval or browser operations that may occur only after the current turn ends. State stores and model steering report asynchronous outcomes.
+
+### Package responsibilities and dependency direction
+
+Four packages under `packages/self-modification/` implement the dynamic runtime:
+
+| Package | npm package | Responsibility |
+| --- | --- | --- |
+| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | Registers the System Prompt, seven model-facing Tools, Host Inspect Providers, `@pluginId` context injection, and Tool presentation metadata |
+| `cordis-host-runner` | `@deepseek-ai/dsh-cordis-host-runner` | Stores the authoritative Registry, allocates IDs, executes Host code, and manages versions, approvals, Runs, private handlers, Inspect routing, and model feedback |
+| `cordis-client-runner` | `@deepseek-ai/dsh-cordis-client-runner` | Synchronizes Inspect manifests in the browser, orchestrates approved Host→Client activation, evaluates Client code, and manages the Guard, Loader/Fiber, timer, styles, and teardown |
+| `ui-cordis` | `@deepseek-ai/dsh-client-ui-cordis` | Renders Define/Run Tool cards, the global Cordis panel, approval controls, version selection, runtime status, and Package-specific business views |
+
+`tool-cordis` depends only on the Host Runner's in-process service and does not import the Client implementation. `ui-cordis` consumes only the Client Runner face and Client-safe wire types and does not import the Host implementation. Existing generated Remote APIs and forwarded events connect Host and Client runtime control; the gateway owns no dynamic Plugin domain logic.
+
+### Domain objects
+
+#### Plugin
+
+A Plugin is a dynamic plugin instance that can be modified over time. It is identified by the branded type `CordisDynamicPluginId`, for example `clock-1`. When creating a Plugin, the model submits only a semantic prefix of 3 to 6 lowercase English letters; the Host appends a process-unique numeric suffix. The model cannot specify the complete `pluginId`.
+
+A Plugin belongs to the Session that defined it. Model-facing Tools can read and operate only Plugins from the current Session. The global Client panel can list Plugins from all Sessions, but each action still executes under the owner Session carried by that row.
+
+#### Package
+
+A Package is an immutable code version under a Plugin. It is identified by `CordisDynamicPackageId`, for example `pkg-2`. It contains a name, a purpose, optional Host code, and optional Client code, with at least one code half present. Every `cordis_define` creates a new Package; an existing Package cannot be modified in place.
+
+One Plugin may own multiple Packages, but at most one physical Run may exist at a time. Whether a Package contains a Host or Client half affects only its activation steps, not its version identity.
+
+#### Plugin Run
+
+A Plugin Run is one concrete activation attempt. It is identified by `CordisDynamicPluginRunId`, for example `run-3`. Every new activation attempt receives a new ID, including an attempt that fails after approval, a retry of the same Package, and a version update. `pluginRunId` associates approval, Host activation, Client loading, private RPC, Tool cards, and errors with the same attempt.
+
+The Host stores the current physical Run separately from `latestRun`. The physical Run is the activation that can currently receive calls and be torn down. `latestRun` records the approval, phase, status of both halves, and diagnostics for the most recent attempt. A failed attempt may leave no live physical Run while remaining available for inspection.
+
+#### Version pointers
+
+- `currentPackageId` is the most recent Package to complete its required activation flow. Stopping the plugin, beginning an update, or failing an update does not clear it.
+- `nextPackageId` is the target Package that is awaiting approval, activating, awaiting a Client, or most recently failed. It is cleared after the target succeeds and is committed as current.
+
+A Host-only Package commits current after the Host successfully establishes its Fiber. A Client-bearing Package commits current after Host activation succeeds and at least one Client successfully establishes the corresponding load. A Fiber that Cordis parks as waiting because a hard dependency is absent is still a successfully established lifecycle object; it is not equivalent to a parse or `apply` failure.
+
+If an update target fails, the old physical Run is not restarted automatically. The previous `currentPackageId` continues to identify the last successful version, and the failed target remains `nextPackageId`. The user or model can retry next, or reactivate current with `mode: "run"` to roll back.
diff --git a/.agents/notes/proposed/architecture/2026-08-08-cordis-web-dynamic-packages.zh.md b/.agents/notes/proposed/architecture/2026-08-08-cordis-web-dynamic-packages.zh.md
new file mode 100644
index 0000000000..a6d961aef6
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-08-08-cordis-web-dynamic-packages.zh.md
@@ -0,0 +1,270 @@
+# Agent Note: Cordis Host/Client 动态插件运行体系
+
+Status: proposed
+
+[English](2026-08-08-cordis-web-dynamic-packages.md) | 中文
+
+## Problem
+
+模型需要在不修改仓库源码、不重新构建应用、不刷新浏览器的前提下,临时扩展当前 DSH 进程。扩展既可能运行在 Host 的 Node.js 进程,也可能运行在 Client 浏览器页面,还可能由 Host 取数、Client 展示,共同组成一个插件。
+
+这项能力不能只是“执行一段代码”。模型需要在写代码前发现两端允许使用的 Service、Event、Builtin、Slot 和主题 token;用户需要先预览代码,再决定是否允许 Client 代码进入页面;同一个插件需要追加不可变版本、失败后重试或回退;运行后的异步错误需要回到模型,而不是只留在服务端日志或浏览器控制台。
+
+如果把定义、审批、运行、版本切换、能力发现和 UI 状态塞进一个动作,会产生无法稳定解释的状态:定义成功是否等于运行成功,升级失败后哪个版本仍是成功版本,页面没有响应时 Tool 应等待多久,同一个 Package 多次运行时哪张历史卡片承载业务 UI,以及 Client 页面局部装载状态是否能代表 Host 的进程级状态。
+
+## Proposal
+
+### 核心原则
+
+- Host 保存 Plugin、Package、Run、审批和版本指针的唯一进程级权威状态。
+- Client 只保存当前页面的审批交互、装载结果、Slot 贡献、业务视图和页面局部错误。
+- Define 只创建不可变代码版本;Run 只激活一个已定义版本。
+- 版本切换只有在目标 Package 完成要求的 Host/Client 激活后才提交 `currentPackageId`。
+- 模型写代码前通过 Inspect Provider 查询能力;Inspect 结果只辅助编码,不作为插件运行时业务数据。
+- Host 与 Client 动态代码都使用受限的 plain JavaScript 上下文,并把可撤销副作用挂到 Cordis 生命周期。
+- Client 代码进入页面前需要用户授权;授权范围可以是单个 Package,也可以是同一 Plugin 的后续版本。
+- Tool 调用不等待当前轮结束后才可能发生的审批或浏览器操作;异步结局通过状态存储和模型 steering 反馈。
+
+### 包职责与依赖方向
+
+动态运行体系由 `packages/self-modification/` 下四个包组成:
+
+| 包 | npm 包名 | 职责 |
+| --- | --- | --- |
+| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | 注册 System Prompt、七个模型 Tool、Host Inspect Provider、`@pluginId` 上下文注入和 Tool 展示元数据 |
+| `cordis-host-runner` | `@deepseek-ai/dsh-cordis-host-runner` | 保存权威 Registry,分配 ID,执行 Host 代码,管理版本、审批、Run、私有 handler、Inspect 路由和模型反馈 |
+| `cordis-client-runner` | `@deepseek-ai/dsh-cordis-client-runner` | 在浏览器同步 Inspect manifest,编排审批后的 Host→Client 激活,求值 Client 代码,管理 Guard、Loader/Fiber、timer、样式和 teardown |
+| `ui-cordis` | `@deepseek-ai/dsh-client-ui-cordis` | 展示 Define/Run Tool 卡片、全局 Cordis 面板、审批控件、版本选择、运行状态和 Package 自定义业务视图 |
+
+`tool-cordis` 只依赖 Host Runner 的进程内服务,不导入 Client 实现。`ui-cordis` 只消费 Client Runner face 和 Client-safe wire 类型,不导入 Host 实现。Host 与 Client 的运行控制通过已有生成 Remote 面和转发事件连接,网关不拥有动态 Plugin 的领域逻辑。
+
+### 领域对象
+
+#### Plugin
+
+Plugin 是可持续修改的动态插件实例,由品牌类型 `CordisDynamicPluginId` 标识,例如 `clock-1`。新建 Plugin 时,模型只提交 3 至 6 位小写英文语义前缀;Host 添加进程内唯一数字后缀。完整 `pluginId` 不能由模型指定。
+
+Plugin 属于定义它的 Session。模型 Tool 只能读取和操作当前 Session 的 Plugin;全局 Client 面板可以列出所有 Session 的 Plugin,但每个动作仍使用该行携带的 owner Session 执行。
+
+#### Package
+
+Package 是 Plugin 下的不可变代码版本,由 `CordisDynamicPackageId` 标识,例如 `pkg-2`。它包含名称、用途、可选 Host 代码和可选 Client 代码,且至少包含一侧。每次 `cordis_define` 都创建新 Package;已有 Package 不允许原地修改。
+
+同一个 Plugin 可以拥有多个 Package,但同一时刻最多只有一个物理 Run。Package 是否含 Host 或 Client 半只决定激活步骤,不改变版本身份。
+
+#### Plugin Run
+
+Plugin Run 是一次具体激活尝试,由 `CordisDynamicPluginRunId` 标识,例如 `run-3`。每次新的激活尝试都会分配新 ID,包括审批后失败、重试同一 Package 和版本更新。`pluginRunId` 把审批、Host 激活、Client 装载、私有 RPC、Tool 卡片和错误关联到同一次尝试。
+
+Host 分开保存当前物理 Run 与 `latestRun`。物理 Run 表示此刻仍可调用和撤销的激活;`latestRun` 表示最近一次尝试的审批、阶段、两侧状态和诊断。一次失败可以没有存活的物理 Run,但仍留下可查询的 attempt。
+
+#### 版本指针
+
+- `currentPackageId` 是最近一次完成要求的激活流程的 Package。停止插件、开始更新或更新失败都不清除它。
+- `nextPackageId` 是正在等待审批、正在激活、等待 Client、或最近失败的目标 Package。目标成功提交为 current 后清除。
+
+Host-only Package 在 Host 成功建立 Fiber 后提交 current。包含 Client 的 Package 在 Host 激活成功且至少一个 Client 成功建立对应装载后提交 current。因硬依赖缺失而被 Cordis park 为 waiting 的 Fiber仍是成功建立的生命周期对象,不等同于解析或 `apply` 失败。
+
+更新目标失败时不自动重启旧物理 Run。旧 `currentPackageId` 继续表示最后成功版本,失败目标保留为 `nextPackageId`。用户或模型可以重试 next,也可以以 `mode: "run"` 重新激活 current 完成回退。
+
+### Host 权威状态与持久性
+
+`DynamicCordisRunnerService` 及其内部 Registry 是当前 DSH 进程内的唯一权威,保存:
+
+- Plugin 的 Session 归属和不可变 Package 集合;
+- `currentPackageId`、`nextPackageId`、物理 Run 和 `latestRun`;
+- 单 Package 授权与 Plugin 跨版本授权;
+- 待处理的 Client 激活请求;
+- Host Fiber、Package 私有 handler、等待中的 Service 和最近诊断;
+- Host 与 Client Inspect Registry 的目录和查询路由。
+
+这些对象不写入配置或磁盘,也不在进程重启后恢复。Session Log 可以保留 Tool 调用、结果和卡片所需元数据,但不会重放动态代码来恢复 Registry。进程重启后历史卡片仍可作为对话记录存在,原 `pluginId` 和 `packageId` 不再可运行。
+
+运行态不作为可恢复状态写入 Session projection。页面刷新或新页面打开不会自动恢复 Client 半;自动恢复会重新引入连接身份、启动期 baseline 和跨页面一致性协议,不属于当前设计。
+
+### Define、Run 与版本切换
+
+`cordis_define` 有两种模式:新建 Plugin 时提交 `idPrefix`;修改现有 Plugin 时提交精确 `pluginId`。代码统一为 `code: { host?, client? }`。Define 只校验参数和 plain JavaScript 语法,记录不可变源码并返回最终 ID。它不执行 `apply`、不产生审批、不改变版本指针,也不隐式运行。
+
+不提供独立 `cordis_update`。`cordis_run` 通过 `mode` 表达激活意图:
+
+| 版本关系 | `mode` |
+| --- | --- |
+| 尚无 `currentPackageId` | `run` |
+| 目标等于 current,包括重启、重试或回退 | `run` |
+| 目标与已有 current 不同 | `update` |
+| 更新失败后重试 `nextPackageId` | `update` |
+
+Run 先验证 Plugin/Package 归属、版本关系和是否已有转换在进行,再创建 `pluginRunId`、写入 `latestRun` 和 `nextPackageId`。
+
+Host-only Package 在 Tool 调用内完成 Host 激活,并同步返回 `running` 或失败。包含 Client 的 Package不在 Tool 调用内等待浏览器终局:未授权时登记审批并返回 `awaiting-approval`;已授权时登记自动 Client 激活并返回 `starting`。这两种返回都表示请求已建立,不表示完整激活成功。
+
+目标真正开始激活时,Host 先停止旧物理 Run,再执行目标 Host 半。Host 成功后才允许 Client 获取精确 `pluginRunId` 对应的源码并装载。Client 成功后 Host 提交版本指针;任何阶段失败都记录到该 attempt,不把旧版本重新启动伪装成目标成功。
+
+`cordis_stop` 撤销当前 Host/Client Run 及待审批请求,但保留 Plugin、Package、授权和版本指针。`cordis_undefine` 先停止,再删除 Plugin、Package、授权和版本指针;删除后历史卡片只显示“插件已移除”。
+
+### Client 审批与授权
+
+包含 Client 代码的 Package 在第一次激活前需要用户授权,因为它将在用户页面中运行模型生成的代码。审批面板提供三个动作:
+
+- 单勾允许当前 Package;同一 Package 后续重跑不再审批,新 Package 仍需审批。
+- 双勾允许当前 Plugin 的后续版本;新 Package、更新、重试和回退不再逐版本审批。
+- 拒绝结束当前请求,不执行 Host 或 Client 代码;模型不得在用户没有新要求时立即重复申请。
+
+授权在用户允许时写入 Host Registry,即使随后发生技术失败也保留。面板直接运行 Package 时,用户点击本身授权该 Package。
+
+待审批行只显示单次允许、跨版本允许和拒绝,不同时提供运行、停止或删除。发现新审批时面板自动展开;自动展开失败或被收起时,固定入口和行状态仍显示待审批数量与状态。
+
+### Client 激活编排
+
+Host 通过 `cordis/request-run` 发送 Client 激活请求。请求只包含请求身份、Session、Plugin、Package、mode、名称、用途和是否需要审批,不广播源码。
+
+获得授权的页面按固定顺序执行:
+
+1. 调用 `runHostHalf`,启动目标 Host 半或绑定同一次 attempt 已启动的 Host Run。
+2. Host 成功后,以 `pluginId + pluginRunId` 调用 `getClientCode`,只取得当前精确 Run 的 Client 源码。
+3. Client Runner 在页面求值插件,建立 Loader entry/Fiber,安装 Guard、样式、Slot 和页面局部状态。
+4. 页面调用 `resolveRequestRun` 或 `settleUserRun` 回报成功、waiting 或失败。
+5. Host 接受仍有效的精确 Run 回报,提交 current 或保存诊断,并广播请求结束,其他页面清理活动。
+
+Host 激活先于 Client,避免 Client 在所需 Host handler 尚未存在时启动。只有本次请求实际创建的 Host Run 才能因本页 Client 失败而撤销;只是绑定既有 Run 的页面没有其所有权。
+
+Client Orchestrator 按 `pluginId` 保存待审批和正在编排的活动,同一个 Plugin 不并发执行两次页面激活。Host inventory 可重建遗漏的待审批项和无需审批的自动激活请求。
+
+Client 装载状态是页面局部事实。Host active 不代表当前页面已装载 Client 半。UI 使用三种主要状态:无物理 Run为灰色“待激活”,Host 已运行但当前页面 Client 未成功装载为黄色“Client 待激活”,当前页面两侧可用为绿色“运行中”。审批中和失败作为额外状态显示。
+
+当前版本不建立 per-connection 身份或多页面法定人数。第一个仍有效的 Client 成功回报可以提交进程级 current;其他页面是否装载由各自页面 store 表示。
+
+### Package 私有 Client→Host 通信
+
+动态 Package 通过私有 JSON 通道从 Client 调用 Host:Host 使用 `harness.handle(method, handler)` 注册当前 Run 的方法,Client 使用 `host.call(method, args)` 调用。每次调用关联 `pluginId + pluginRunId`,Host 拒绝已停止或过期 Run。参数和返回值必须是无损 JSON,不允许函数、React 元素、Context、Service 实例或类对象。
+
+该通道只服务同一 Package 的 Client→Host 调用,不使用公开 Remote Service 或动态代码中的 `ctx.remote`。公开 Remote 面只承载 Runner 自己的控制协议,不向动态 Package 暴露。
+
+### 动态代码、Guard 与生命周期
+
+Host 和 Client 都只执行 plain JavaScript 函数体,不经过 TypeScript、JSX 或 bundler 转译。Host 运行在 `node:vm`,Client 在受限闭包中求值。两端上下文用于减少误用并提供教学错误,不是恶意代码安全边界。
+
+模型默认通过 `ctx.get('serviceName')` 读取可选 Service 并判断 `undefined`。只有 Service 是硬依赖、缺失时 Package 必须 waiting 并在 Service 出现后重新激活时,才在插件对象声明 `inject`。直接访问 `ctx.serviceName` 只在同一插件声明对应 inject 时允许。
+
+Host 与 Client 的 `timer` 都是同名 Cordis Service,使用一致接口,不是全局 Builtin。需要 timer 的插件必须声明 `inject: ['timer']`;React effect 中创建的 timer 把 disposer 作为 cleanup 返回。
+
+所有注册和可撤销副作用由当前 Fiber 拥有。Event listener、Service、Tool、handler、timer、Slot、样式和主题覆盖通过 `ctx.effect()`、`ctx.on()` 或返回 disposer 的官方 API 注册。停止、更新、失败回滚或 undefine 时撤销两端贡献。Theme override 必须按 source 分层并返回 disposer,使卸载后恢复此前主题值。
+
+宿主、DSH、Cordis 及其 Service、Event payload、Slot props、Session/Conversation Snapshot、Tool 状态和其他运行时对象是内部 live data。动态代码不得对这些对象或其子对象执行 `JSON.stringify`、`structuredClone`、递归枚举、全量复制或整体展示;只能读取当前任务所需叶子字段,构造不含宿主引用的最小自有数据。
+
+### Inspect Provider 与 Catalog
+
+能力发现分为三个 Tool:`cordis_inspect_list` 列 Host/Client Provider manifest;`cordis_inspect_query` 执行指定平台的显式只读查询;`cordis_inspect_self` 查询当前 Session 的 Plugin、Package、源码、版本指针和运行诊断。
+
+Host 和 Client 各自拥有 `CordisInspectRegistry`。Provider 注册平台内唯一 ID、说明、method、输入 schema 和输出 schema。Provider method 是显式白名单查询,不是任意 Service 方法透传;Registry 不维护分层 target,也不自动把业务 Service 方法变成可执行 Inspect method。
+
+首批 Provider 为:
+
+| Platform | Provider.method | 数据来源 |
+| --- | --- | --- |
+| Host / Client | `Service.listService` | 各平台 Service 静态 Catalog |
+| Host / Client | `Event.listEvents` | 各平台 Event 静态 Catalog |
+| Host / Client | `Builtin.listBuiltins` | evaluator/Guard 附近的手工定义 |
+| Host | `Tool.listTools` | 当前 Agent 真正可见的 Tool Registry |
+| Client | `Slots.listSubTree` | Slot 静态 Catalog与页面 live subtree/occupants |
+| Client | `Theme.listTokens` | ThemeService 的只读 inspect export |
+
+Client Registry 变化后向 Host 同步完整 manifest,不按 Session 保存重复目录。Host query 本地执行;Client query 由 Host 广播 request ID,页面调用本地 Provider 后回送。Host 只接受第一个通过输出 schema 校验的成功结果;失败页面不抢占请求。没有页面成功回答时 Tool 保持 pending,直到后续成功或 Tool call 取消。
+
+Inspect 数据只用于写代码前确认能力、签名、类型和挂载协议。插件运行时需要业务数据时必须调用实际 Service 或监听实际 Event,不能缓存、展示或依赖 Inspect/Catalog 返回值。
+
+`CordisCatalogProjector` 使用 TypeRT 分别生成 Host/Client Service 与 Event Catalog;Slot AST 生成器扫描 `SlotMap`、注册选项、standard props、owner props 和引用类型;Slots Provider 查询时合并静态 Catalog 与 live tree。Theme token 由 ThemeService 导出,Builtin 在 evaluator/Guard 附近手工维护,Tool schema 来自 Registry。
+
+Catalog 扫描真实源码签名,再应用 model-visible 白名单。白名单可以隐藏 Service、成员、`@deprecated` API、Runner 自身服务和 `cordis/*` 控制 Event,但不能改写剩余 API 的方法名、参数和返回类型。Guard 可以拒绝参数、固定来源或屏蔽成员,但必须尊重源码签名。
+
+模型可见 owner JSDoc 只要求完整 description、每个参数的 `@param`、非 void 返回的 `@returns`、Event 的 `@mode`,以及 Slot/props 字段说明。调用推荐、反例和跨能力选择放入 Skill,不在 Catalog 增加重复 example 字段。
+
+### 模型指导分层
+
+模型指导分为四层:
+
+- System Prompt 保存稳定运行模型、两端限制、生命周期、审批、版本指针、最低代码规范和七个 Tool 的使用地图。Skill 不可用时它仍须支持最低限度正确实现。
+- `cordis-plugin-development` Skill 保存需求导航、能力组合、推荐和反例,不复制完整 schema。
+- 每个 Tool description 只说明该动作的前置条件、参数语义、同步/异步结果和下一步。
+- Provider/Catalog 返回当前精确名称、签名、参数、Slot props、token 和运行时查询结果。
+
+System Prompt 要求先加载 Skill,再 list/query,之后 define/run。Skill 中 React 示例必须注册到 Slot,不能从 `apply()` 直接返回 React Element;示例使用 `React.createElement`、正确 `ctx.get()`/`inject`、可逆 effect 和最小 JSON RPC。
+
+### `@pluginId` 与 Tool UI
+
+输入系统为当前 Session 注册 `@pluginId` mention。选择后只注入 Plugin 身份、默认基准 Package、版本指针、活动 Run 和最近状态,不注入源码。默认基准依次选择 next、current、最近定义的 Package。模型必须先用 `cordis_inspect_self` 读取源码,再以 existing 模式追加 Package;引用失效时不能静默创建替代 Plugin。
+
+`cordis_define` 卡片以 Host/Client 两个子页签展示代码。`cordis_run` 卡片由 `pluginRunId` 关联精确 attempt,并读取 Client store 显示待审批、Client 待激活、运行中、失败、已被后续 Run 替代或 Plugin 已移除。
+
+Package 可以向 `tool.view.cordis` 注册 `key: "self"`。运行时把 self 绑定为 `pluginId + packageId`;业务 Slot key 不含 `pluginRunId`,但 owner props 仍提供精确 Run 身份。同一 Package 最新 Run 卡片承载业务 UI,更早卡片显示已有更新运行。卡片通过 store 响应变化,不扫描后续 Session Log,也不互相通知。
+
+全局 Cordis 面板使用一个固定入口,按当前会话和其他会话分组。面板标题和收起操作固定,只有列表滚动。普通行可选择 Package并运行、停止或删除;失败更新可重试 next 或选择 current 回退;待审批行只提供两个允许动作和拒绝。
+
+### 错误与模型反馈
+
+跨 Host/Client 的技术错误保留原始 `message`,并在错误对象提供时保留 `stack`。结构化诊断包含 `pluginId`、`packageId`、`pluginRunId` 和阶段:approval、host-load、host-apply、client-load、client-apply 或 client-render。
+
+Host/Client Guard、Host 求值与 handler、Client 求值与 apply、Slot `onEntryError` 和 React ErrorBoundary 都把错误回到 owning Agent。Client 控制台同时以 `console.error` 打印原始 error 对象。渲染错误属于精确 Run,不污染不可变 Package。
+
+模型发起的异步 Run 在成功、拒绝或技术失败后使用 `agent.steer` 唤醒 owning Agent。技术失败要求读取诊断、在同一 Plugin 修正并自主重试;用户拒绝则禁止自动重复申请。用户在面板手动运行、停止或移除通过 context injection 告知下一 step,但不主动唤醒模型。
+
+## Alternatives considered
+
+**Define 与 Run 合并。** 这会失去“已定义但未运行”的可预览状态,把语法错误、审批、运行错误和重试混成一个动作,因此拆为不可变 Define 和独立 Run。
+
+**Package ID 同时作为 Plugin ID。** 单层 ID 无法表达稳定实例下追加不可变版本,更新只能 stop、undefine、重新 define,历史卡片和 `@` 引用也无法保持同一对象,因此采用 Plugin、Package、Run 三层身份。
+
+**提供独立 `cordis_update`。** Update 的装载、审批、UI、诊断和 Run 相同,独立 Tool 只复制协议,因此合并到 `cordis_run mode:"update"`。
+
+**更新失败后自动恢复旧物理 Run。** 自动恢复会把“目标失败”和“旧版本重新成功”混成一个结果。当前设计保留旧 current 指针但不自动重启,让用户明确选择重试 next 或 run current。
+
+**让 `cordis_run` 阻塞到用户审批和 Client 终局。** 审批或页面操作可能只能在当前模型轮结束后发生,阻塞会形成死锁,并在无页面时无限占用 Tool。当前设计立即返回,通过 store、Inspect 和 steering 报告终局。
+
+**Host 广播源码并用超时等待 Client ack。** 广播会在授权前把代码发给所有页面;超时无法区分没有页面、页面慢和用户未操作;Host 还要维护补偿式回滚。当前协议只广播元数据,由获准页面按精确 Run 拉取源码。
+
+**页面启动时自动恢复所有 Host active Package。** 这要求连接身份、启动期 baseline 和跨页面一致性。当前设计接受页面局部 Client 状态,用户可在面板重新装载。
+
+**通过公开 Remote Service 或 `ctx.remote` 连接 Package 两半。** 这会把动态 Package 暴露到产品级 RPC 面。Package 私有 `harness.handle`/`host.call` 足以承载 Client→Host JSON 调用,并能按 `pluginRunId` 拒绝陈旧请求。
+
+**把所有 Service 方法自动暴露成 Inspect query。** 这会把能力发现变成业务调用代理,绕过插件审批和生命周期。Provider 只暴露策展的只读查询,Service Catalog 只描述业务方法签名。
+
+**把完整 API 写进 System Prompt 或 Skill。** 固化文本会漂移并占用上下文。System Prompt 保留稳定规则,Skill 负责需求导航,精确签名和运行时目录由 Provider/Catalog 返回。
+
+**要求 Slot owner 在运行时注册 props schema。** Slot props 已存在于 TypeScript 类型和 JSDoc 中,重复注册会制造第二份权威。当前设计用 Slot AST Catalog 提取静态协议,只在查询时合并 live tree。
+
+**把运行态写入 Session Log 并在 replay 恢复。** 动态代码和 Fiber 是进程局部对象,恢复要求重新执行历史代码并重新解释审批。Session 只保留模型可见记录,Registry 和页面 Run 不恢复。
+
+**让历史 Run 卡片扫描后续 Session Log。** 这会让 Tool view 依赖全量日志顺序和后续消息结构。页面 card index/store 已能按 Package 告知旧卡片被替代或 Plugin 被删除。
+
+## Acceptance criteria
+
+- 新 Plugin 只能由 3 至 6 位小写英文前缀创建,最终 Plugin、Package 和 Run ID 由 Host 分配并使用品牌类型。
+- `cordis_define` 只做参数和 plain JavaScript 语法检查,返回不可变 Package;同一 Plugin 可以追加版本,旧源码保持可 inspect。
+- `cordis_run` 严格校验 run/update;Host-only 同步完成,Client-bearing 返回 `awaiting-approval` 或 `starting`,不等待页面终局。
+- 单勾只授权当前 Package,双勾授权同一 Plugin 后续版本;授权在技术失败后仍保留,拒绝不执行两侧代码。
+- Host 先激活,Client 后取精确 Run 源码;Client 成功前不提交 Client-bearing Package 的 current,失败后 current/next 可用于重试和回退。
+- 一个 Plugin 同时最多一个物理 Run;stop 撤销两端贡献但保留定义和指针,undefine 删除全部 Package、授权和状态。
+- 当前页面能区分“待激活”“Client 待激活”和“运行中”,待审批时只显示审批动作。
+- `tool.view.cordis` 的 self 绑定 Plugin + Package;同 Package 最新 Run 卡片独占业务 UI,旧卡片和已删除 Plugin 有明确退化状态。
+- Host/Client Guard 拒绝 import、JSX、未声明 Service 和不可用全局;Service、timer、Slot、样式、Tool、handler 和主题覆盖随 Run teardown。
+- Package 私有 RPC 只允许 Client→Host 无损 JSON,并拒绝陈旧 `pluginRunId`。
+- Inspect list 一次返回 Host/Client manifest;query 只调用显式只读方法,Client 查询等待首个 schema-valid 成功结果或取消。
+- Service/Event Catalog 分 Host/Client 生成并应用白名单,`@deprecated` API、Runner 自身服务和 `cordis/*` 控制 Event 不向模型暴露;Slot query 合并静态 props 与 live subtree。
+- `cordis_inspect_self` 分层返回列表、Package 摘要和精确源码/诊断;`@pluginId` 不直接注入源码且更新留在同一 Plugin。
+- 异步技术失败、Host handler、Client Guard 和 React 渲染错误保留 message/stack 并 steering owning Agent;用户面板操作只注入下一 step context。
+- System Prompt、Skill、Tool description 和 Provider/Catalog 按本 Note 分层,Skill 不可用时 Prompt 仍足以生成最低限度正确的插件。
+- 相关工作区 `pnpm run build` 通过;实现阶段补齐 Host/Client lifecycle、版本、审批、Inspect、Guard、Tool 卡片与真实应用快照覆盖。
+
+## Risks
+
+- **进程重启丢失全部动态对象。** 历史 Tool 卡片仍在,但 Registry 不恢复;用户必须重新 define。
+- **多页面状态不是强一致系统。** 第一个有效 Client 成功结果可以提交 current,各页面的 Client 装载和渲染状态仍可能不同;当前不引入连接身份、法定人数或页面聚合。
+- **Client Inspect 可能长期 pending。** Host 保存最近 manifest,但没有页面成功执行 Provider 时不能用旧数据伪装 live 结果;多个页面都失败时请求等待到取消。
+- **跨版本授权扩大信任范围。** 双勾允许同一 Plugin 后续 Package 无需再次审批;UI 必须清楚区分单次和跨版本授权。
+- **失败更新可能留下 current 指向旧版本但旧版本未运行。** current 表示最后成功版本,不表示当前物理 Run;UI、Inspect 和提示必须同时展示 active、current 和 next。
+- **受限上下文不是安全沙箱。** Host Service、文件、命令、网络和 Client UI 都是真实能力;白名单与审批降低误用,不隔离恶意代码。
+- **Catalog、Guard 和源码可能漂移。** 生成器、白名单和 owner JSDoc 必须共同维护;Guard 的隐藏策略不能产生另一套签名。
+- **Builtin 依赖手工声明。** React、harness、host、styles 和 Context 方法没有统一可扫描入口,注入实现与 Provider 定义必须放在同一维护位置。
+- **Provider 输出 schema 当前允许较宽的 JSON。** 首版优先完成 Provider 所有权、输入校验和 Host/Client 路由;更窄的输出 schema 后续再收紧。
+- **Host 与 Client Guard 存在平行实现。** 两侧开放环境和 Cordis 类型面不同,当前保留各自实现;公共规格只有在能减少代码且不隐藏安全策略时再提取。
diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml
index 598418c3b6..367338697c 100644
--- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml
+++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml
@@ -244,6 +244,10 @@
# trust boundary, not a sandbox — see this file's header.
- id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis'
+- id: cordis-client-runner
+ name: '@deepseek-ai/dsh-cordis-client-runner'
+- id: ui-cordis
+ name: '@deepseek-ai/dsh-client-ui-cordis'
# The composition-authoring skill travels with this preset rather than living
# in the user's skill root: it documents THIS deployment's two planes, and a
diff --git a/apps/cli/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md
new file mode 100644
index 0000000000..7856fcc7dc
--- /dev/null
+++ b/apps/cli/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md
@@ -0,0 +1,417 @@
+---
+name: cordis-plugin-development
+description: Create, modify, debug, or extend dynamic Cordis Plugins, including Host Services and Events, Client Slot and theme UI, Package-private Client-to-Host calls, dynamic Tools, version updates, approval failures, and runtime diagnostics. Use this Skill to route a user request to the correct platform and Inspect Provider, then define, run, repair, or roll back the Plugin.
+---
+
+# Develop Dynamic Cordis Plugins
+
+First determine whether a capability belongs on Host or Client, then query the real interface before writing code. Never infer a complete API from a Service name, Event payload, Slot props, theme token, or example.
+
+## Standard workflow
+
+1. Call `cordis_inspect_list` to obtain the Providers, methods, and schemas currently registered on Host and Client.
+2. Select the smallest set of `cordis_inspect_query` calls needed to read the exact Services, Events, Builtins, Slots, Theme tokens, or Tools that the implementation will use.
+3. For a new Plugin, design its first Package. To modify an existing Plugin, first use `cordis_inspect_self(pluginId, packageId)` to read the base source and diagnostics.
+4. Write plain JavaScript in `code.host`, `code.client`, or both, then call `cordis_define`.
+5. Call `cordis_run` with the final `pluginId` and `packageId` returned by define.
+6. Handle approval, waiting, Client loading, and render failures from the Run card, steering messages, or `cordis_inspect_self`.
+7. Use `cordis_stop` to disable the Plugin temporarily. Use `cordis_undefine` only when it is no longer needed.
+
+Do not wait in the same turn for user approval or asynchronous browser results. After `cordis_run` returns `awaiting-approval` or `starting`, end the current Tool flow and wait for the system to report the final outcome through state updates and steering.
+
+## Tool usage guidance
+
+| Tool | Use it when | Do not |
+| --- | --- | --- |
+| `cordis_inspect_list` | Discover current Host/Client Providers and method schemas in one call; refresh after the runtime capability directory changes | Hard-code Provider names and skip list; treat a manifest as business data |
+| `cordis_inspect_query` | Confirm exact Service methods, Event modes, Builtins, Slots, tokens, or Tool schemas before writing code | Use it instead of calling a real Service from the Plugin; assume a Client query will finish without a responding page |
+| `cordis_inspect_self` | List current Plugins, inspect version pointers, or read exact Package source and runtime diagnostics | Fetch all source just to build a list; use it to modify or start a Plugin |
+| `cordis_define` | Create a Plugin's first version or append an immutable Package to an existing Plugin; let the user preview the code first | Expect define to execute `apply`, request approval, or update current |
+| `cordis_run` | Activate an exact Package; use `run` for first activation, restart, or rollback, and `update` to switch versions | Use `run` to switch versions implicitly; treat pending or starting as success |
+| `cordis_stop` | Pause current effects while preserving Packages, grants, and version pointers for later use | Use stop to mean permanent deletion |
+| `cordis_undefine` | Permanently remove a Plugin and all of its Packages and clear historical business views | Call it while rollback, inspection, or restart is still needed |
+
+## Choose a platform
+
+| Requirement | Preferred platform | Inspect first |
+| --- | --- | --- |
+| Files, commands, processes, or networking | Host | `fs`, `bash`, `subprocess`, `pty`, and `web` in `Service.listService` |
+| Agents, durable Session data, or Host lifecycle | Host | The relevant Service and `Event.listEvents` |
+| Register a dynamic Tool callable in the next model step | Host | `harness` in `Builtin.listBuiltins`, plus `Tool.listTools` |
+| Page theme, layout, or current page state | Client | `Theme.listTokens` and Client `Service.listService` |
+| Conversation Snapshot or session/workspace lists | Client | The target Slot's standard props and owner props |
+| Settings pages, sidebars, input areas, overlays, or Tool cards | Client | `Slots.listSubTree` |
+| Fetch on Host and display on Client | Both | Host Service + `harness.handle`; Client Slot + `host.call` |
+
+Prefer the capability closest to the data owner. If Slot props already provide the Conversation Snapshot, do not fetch it again through Host. If only the Package's own styles need to change, do not override the global theme. If only a small entry point is needed, do not replace an entire product UI region.
+
+## Provider navigation
+
+Select methods from the actual `cordis_inspect_list` result. Common initial methods include:
+
+- `Service.listService`: without `service`, returns every callable Service with its purpose and exact method signatures. Query the selected `service` again for access rules, structured method descriptions/parameters/returns, and only its referenced types.
+- `Event.listEvents`: without `event`, returns every Event with its purpose, dispatch mode, and exact listener signature. Query the selected `event` again for its structured listener contract and only its referenced types; a Waterfall listener must call `next()`.
+- `Builtin.listBuiltins`: returns evaluator-provided symbols and signatures that cannot be obtained through `ctx.get()`.
+- `Slots.listSubTree`: without `root`, returns compact live trees with each Slot's purpose, kind, scope, registration keys, replacement risk, and children. With an exact `root`, it also returns that selected Slot's full contract, props, and current occupants while keeping descendants compact.
+- `Theme.listTokens`: returns theme tokens that may currently be queried and overridden; it does not modify the theme.
+- `Tool.listTools`: returns Tool schemas actually visible to the current Agent, including dynamically registered Tools.
+
+Provider names, methods, and inputs must come from the current list result. The Service/Event Catalog describes which interfaces this version permits; it does not guarantee that a Service is currently mounted. At runtime, use real Services and Events rather than caching or displaying Catalog query results.
+
+## Execution environment
+
+Both `code.host` and `code.client` are plain JavaScript function bodies that return a Cordis Plugin. They are not compiled by TypeScript, JSX, or a bundler.
+
+Do not use:
+
+- `import`, `require`, TypeScript types, `as`, decorators, or JSX;
+- globals not confirmed by `Builtin.listBuiltins`;
+- guessed access to `window`, `document`, `process`, `Buffer`, `fetch`, or native timers.
+
+Client React code must use `React.createElement(...)`.
+
+Correct:
+
+```js
+return {
+ apply(ctx) {
+ const slots = ctx.get('slots')
+ if (slots === undefined) return
+ slots.inject('tool.view.cordis', () => slots.register(
+ { name: 'tool.view.cordis', key: 'self' },
+ () => React.createElement('div', null, 'Hello'),
+ ))
+ },
+}
+```
+
+Incorrect:
+
+```jsx
+return {
+ apply(ctx) {
+ return
Hello
+ },
+}
+```
+
+JSX is not the only problem in this example. `apply()` registers lifecycle contributions and cannot return a React Element as the Plugin result. UI must be registered in a queried Slot.
+
+## Access Services
+
+Read optional capabilities with `ctx.get(name)` by default and handle their absence:
+
+```js
+return {
+ apply(ctx) {
+ const service = ctx.get('serviceName')
+ if (service === undefined) return
+ service.someMethod()
+ },
+}
+```
+
+Declare `inject` only when a Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears:
+
+```js
+return {
+ inject: ['requiredService'],
+ apply(ctx) {
+ ctx.requiredService.someMethod()
+ },
+}
+```
+
+Do not overuse `inject` merely to avoid an `undefined` check. Do not access `ctx.requiredService` without declaring the injection; the Guard rejects undeclared dependencies.
+
+## Manage side effects
+
+Every contribution must be removed after the Plugin is stopped, updated, or removed. Prefer Cordis lifecycle APIs:
+
+- Use `ctx.on()` to register Event listeners.
+- Use `ctx.effect()` to own an external subscription that returns a disposer.
+- Retain disposers returned by Cordis Service, Tool, Slot, timer, and theme APIs.
+- Do not create process-wide or page-wide side effects at module scope or outside `apply()`.
+
+Recommended:
+
+```js
+return {
+ apply(ctx) {
+ const service = ctx.get('serviceName')
+ if (service === undefined) return
+ ctx.effect(() => service.subscribe((value) => {
+ console.log(value)
+ }))
+ },
+}
+```
+
+If `subscribe()` does not return a disposer, first query whether the Service provides a supported cleanup mechanism. Do not assume unload automatically removes arbitrary third-party callbacks.
+
+## Host and Client timers
+
+On both platforms, the timer is a Service named `timer` with the same interface; it is not a Builtin. Query `{ "service": "timer" }` through the corresponding platform's `Service.listService` before using it. Declare `inject: ['timer']` before using the timer mixin.
+
+One-shot delay:
+
+```js
+return {
+ inject: ['timer'],
+ apply(ctx) {
+ const onClick = () => {
+ ctx.timeout(() => console.log('done'), 300)
+ }
+ // Pass onClick to a queried Slot UI.
+ },
+}
+```
+
+Periodic work in a React component:
+
+```js
+return {
+ inject: ['timer'],
+ apply(ctx) {
+ function Clock() {
+ React.useEffect(() => ctx.interval(() => console.log('tick'), 1000), [])
+ return React.createElement('div', null, 'Running')
+ }
+ // Register Clock in a queried Slot.
+ },
+}
+```
+
+Incorrect:
+
+```js
+return {
+ apply(ctx) {
+ ctx.timeout(() => console.log('invalid'), 300)
+ },
+}
+```
+
+```js
+setTimeout(() => console.log('invalid'), 300)
+```
+
+The first example does not declare the timer hard dependency. The second uses a global timer that does not exist.
+
+## Listen to Events
+
+Query the Event Provider first to confirm the platform, parameter order, return value, and `mode`.
+
+Ordinary emit Event:
+
+```js
+return {
+ apply(ctx) {
+ ctx.on('some/event', (payload) => {
+ console.log(payload)
+ })
+ },
+}
+```
+
+The last parameter of a Waterfall Event is `next`. Unless the listener intentionally stops downstream processing, it must call and return it:
+
+```js
+return {
+ apply(ctx) {
+ ctx.on('some/waterfall', (payload, next) => {
+ console.log(payload)
+ return next()
+ })
+ },
+}
+```
+
+## Register Client UI
+
+Query `Slots.listSubTree` without `root` to choose a target from the compact purpose and topology tree, then query the exact Slot with `root` before writing its registration. The exact result determines:
+
+- the Slot's purpose in the layout;
+- whether its registration protocol is `single`, `list`, `keyed`, or `chain`;
+- registration options;
+- scope standard props and business owner props;
+- current occupants, replacement risks, and descendant Slots.
+
+Use `ctx.get('slots')` and handle its absence. Then use `slots.inject` to wait for the Slot declaration and call `slots.register` inside the callback:
+
+```js
+return {
+ apply(ctx) {
+ const slots = ctx.get('slots')
+ if (slots === undefined) return
+ slots.inject('target.slot', () => slots.register(
+ { name: 'target.slot', id: 'my-view' },
+ (props) => React.createElement('div', null, String(props.someValue)),
+ ))
+ },
+}
+```
+
+`ctx.get('slots')` does not require an injection. Do not rewrite it as `ctx.slots` unless `inject: ['slots']` is declared:
+
+```js
+return {
+ apply(ctx) {
+ ctx.slots.register({ name: 'target.slot' }, () => null)
+ },
+}
+```
+
+Do not guess an `id`, `key`, selector, or props before querying the Slot protocol. Do not default to root-level `root`, `sidebar`, `conversation`, or `details` Slots; replacing an entire occupant also removes the descendant Slots it declares.
+
+### Settings pages
+
+A full settings UI should usually register its own section through `settings.section` to obtain a complete content area. `settings.general.item` is only appropriate for one compact, general-purpose preference. Query the actual subtree, options, and props for both, then select the narrowest entry point that is still sufficient.
+
+Dynamic Plugins are temporary and process-local, so their settings UI does not need persistent storage. Do not add durable settings or another persistence mechanism for it. Register the UI in the appropriate settings Slot and keep any transient interaction state in memory for the lifetime of the Plugin.
+
+### Session and page data
+
+A session-scoped Slot may provide `useSession`, `useSessions`, `useWorkspaces`, `useProjection`, input state, or actions through standard props. Follow the query result and prefer owner or standard props directly; do not add a Host RPC for data already present there.
+
+Select only the fields that the UI actually needs. Do not copy or render an entire Conversation Snapshot, Session, Tool call, or Slot props object.
+
+### Cordis Run-specific panel
+
+To place interactive UI in the latest `cordis_run` card, register `tool.view.cordis` with `key: 'self'`:
+
+```js
+return {
+ apply(ctx) {
+ const slots = ctx.get('slots')
+ if (slots === undefined) return
+ slots.inject('tool.view.cordis', () => slots.register(
+ { name: 'tool.view.cordis', key: 'self' },
+ (props) => React.createElement('div', null, `Package ${props.packageId}`),
+ ))
+ },
+}
+```
+
+At runtime, `self` binds to `pluginId + packageId`. Do not include `pluginRunId` in the key. When the same Package runs multiple times, the latest Run card hosts the UI and older cards automatically degrade.
+
+### Ordinary Tool cards
+
+To customize the call card for an ordinary model Tool, query `tool.call.toolview`. Its key is the Tool name; registering an existing key may replace the product's default card. When customizing only a newly added Tool, first verify its schema with `Tool.listTools`, then query the complete `ToolCallOwnerProps`.
+
+### Overlays and local entry points
+
+- For toasts, status notices, and frame-wide overlays, query `shell.overlay` first; observe its pointer-events and ordering rules.
+- For small sidebar actions, prefer additive inner Slots such as `sidebar.footer.action`; do not replace the entire sidebar.
+- For supplementary content after a conversation turn, query `conversation.chat.turnTail` and register according to its returned chain selector and fallback rules.
+
+## Themes and styles
+
+Determine the scope of the change first:
+
+1. Global theme: first query `Theme.listTokens`, then query `{ "service": "theme" }` through Client `Service.listService`. Supply light and dark values for each override as required by the query, and retain the returned disposer.
+2. The Package's own components: use `styles.insert(css)` and prefer theme CSS variables for colors.
+3. New visible content: choose a Slot first, then decide between local CSS and global tokens.
+
+Do not manipulate `document.body`, `window`, or hard-coded product DOM selectors. The theme Service changes tokens but does not create UI. Slots create UI but do not replace the theme system.
+
+## Call Host from Client
+
+Host registers a Package-private method with `harness.handle(method, handler)`, and Client invokes it with `host.call(method, args)`. This is Client→Host JSON RPC.
+
+Host:
+
+```js
+return {
+ apply(ctx) {
+ harness.handle('read-state', async (args) => {
+ return { value: args.key }
+ })
+ },
+}
+```
+
+Client:
+
+```js
+return {
+ async apply(ctx) {
+ const result = await host.call('read-state', { key: 'demo' })
+ console.log(result.value)
+ },
+}
+```
+
+Arguments and return values must be lossless JSON. Do not pass functions, React elements, class instances, Contexts, Services, or other runtime objects; return `null` when there is no response data. Do not register a public Remote Service or use `ctx.remote` for Package-private communication.
+
+## Register a dynamic model Tool
+
+Host can use `harness` to register a Tool callable in the next model step. First query the current `harness` signature with Host `Builtin.listBuiltins`, then inspect existing Tool names and schemas with `Tool.listTools` to avoid conflicts.
+
+Tool arguments and return values must be JSON-compatible. `execute` owns the business result; render and presentation own only what the model and native UI see. Tool registration must belong to the current Plugin Fiber so it is automatically removed after stop or update.
+
+## Handle internal live data
+
+Service instances, Event payloads, Slot props, Session and Conversation Snapshots, Tool state, and other DSH/Cordis objects are internal live data.
+
+Do not:
+
+- call `JSON.stringify` or `structuredClone` on these objects or their descendants;
+- recursively enumerate, fully copy, or display them as a whole;
+- place Host objects in the Package's long-lived state or RPC return values.
+
+Read only the leaf fields required by the current feature. Extract the minimum strings, numbers, booleans, and other scalar values before constructing owned JSON.
+
+## Versions, approval, and repair
+
+- A Plugin is the stable instance identified by `pluginId`.
+- A Package is an immutable code version identified by `packageId`.
+- Every activation attempt has its own `pluginRunId`.
+- `currentPackageId` is the latest successful version; it does not imply that the Plugin is currently running.
+- `nextPackageId` is the target awaiting approval, activating, awaiting Client activation, or most recently failed.
+
+Choose the `cordis_run` mode as follows:
+
+| Current state | Target | mode |
+| --- | --- | --- |
+| No current | Any Package under the Plugin | `run` |
+| Has current | The same Package | `run` |
+| Has current | A different Package | `update` |
+| Update failed | `nextPackageId` | `update` to retry |
+| Update failed | `currentPackageId` | `run` to roll back |
+
+An unauthorized Client Package returns `awaiting-approval`. A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains after a technical runtime failure. An authorized Package returns `starting` and completes asynchronously in the browser.
+
+After a technical failure:
+
+1. Use `cordis_inspect_self(pluginId, packageId)` to read the failed version's source and exact diagnostics.
+2. If the error involves an unknown capability, list and query the corresponding Provider again.
+3. Define a new Package under the same Plugin; do not overwrite the failed Package.
+4. Run again with the new `packageId` and the correct mode.
+
+Do not retry automatically after the user rejects approval. A failed update does not automatically restore the old physical Run; explicitly run current when recovery is required.
+
+## Modify @pluginId
+
+When the user identifies a target with `@pluginId`, do not create another Plugin. The injected context contains only identity, version pointers, and the default base Package, not source code.
+
+Modify it as follows:
+
+1. Read the base Package with `cordis_inspect_self(pluginId, packageId)`.
+2. Preserve the Host or Client half that does not need to change and modify only the target code.
+3. Call `cordis_define` with `plugin.kind: 'existing'` and the original `pluginId`.
+4. Use the returned `packageId`; when current exists, activate the new version with `update` in the usual case.
+
+If the reference is unavailable, explain that the Plugin was removed, belongs to another Session, or was lost on process restart. Do not create a same-named replacement.
+
+## Common failure checks
+
+| Failure | Check first |
+| --- | --- |
+| `service "x" is not declared` | Whether code uses `ctx.x` without declaring `inject: ['x']` on the Plugin object; switch to `ctx.get('x')` with an absence check or declare a true hard dependency |
+| `cannot get property "timer" without inject` | Query the timer Service and declare `inject: ['timer']` |
+| Client parse failure | Whether the code uses JSX, TypeScript, import, or an unavailable global |
+| Slot registration failure | Whether the live subtree was queried, the Slot exists, and options, key, or selector satisfy the returned protocol |
+| UI loads but the page reports an error | Inspect the `client-render` diagnostic and stack; the error belongs to an exact Run, so define a new Package to repair it |
+| `host.call` failure | The Host handler name, current `pluginRunId`, JSON arguments, and real Service dependencies inside the handler |
+| Update failure | Preserve current/next semantics; repair next and update, or run current to roll back |
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 277a6cfcce..a0dabd3cdd 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -27,6 +27,9 @@
"@deepseek-ai/dsh-agent-tool-presentation": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-base": "workspace:^",
+ "@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-compaction-basic": "workspace:^",
diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts
index ac439bf9d8..4a627b219f 100644
--- a/apps/web/tests/cordis-tool-round.e2e.ts
+++ b/apps/web/tests/cordis-tool-round.e2e.ts
@@ -1,7 +1,14 @@
// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real
-// model through inspect, mount, and unmount; replay pins the same shipped Web
-// composition, durable calls, generic rows, highlighted Plugin source, and
-// conversation accessibility tree.
+// model through inspect, define, run, and stop; replay pins the same shipped Web
+// composition, durable calls, generic rows, the define card's own source view,
+// and conversation accessibility tree.
+//
+// The approval is never in the fixture. The fixture pins what the MODEL said;
+// tools execute for real, so `cordis_run` genuinely blocks on a person and this
+// test is that person — which is what lets the run/approve boundary be asserted
+// instead of assumed. The package therefore carries a browser half whose only
+// job is to be visible (`[data-snapshot-probe]`): its absence before the answer
+// and presence after it is the v3 user gate, proven rather than described.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
@@ -17,12 +24,21 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
-const CORDIS_TOOLS = ['cordis_inspect', 'cordis_mount', 'cordis_unmount'] as const
-const MOUNT_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
-const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". '
- + `Then call cordis_mount with this exact code: ${JSON.stringify(MOUNT_CODE)}. `
- + 'Read its returned id and call cordis_unmount with that exact id. '
- + 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.'
+const CORDIS_TOOLS = ['cordis_runtime_inspect', 'cordis_package_inspect', 'cordis_define', 'cordis_run', 'cordis_stop'] as const
+const PACKAGE_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
+// The browser half is the PROBE this scenario turns on: it renders a marker into
+// the frame-wide overlay, so "did the plugin actually run in this page" becomes a
+// DOM fact. A host-only package would sidestep the approval round trip entirely
+// (the host runs those immediately), which would drop the v3 user gate out of
+// coverage — the one thing this scenario exists to prove.
+const CLIENT_CODE = 'return { inject: ["slots"], apply(ctx) { ctx.slots.register('
+ + '{ name: "shell.overlay", id: "snapshot-probe" }, '
+ + '() => React.createElement("div", { "data-snapshot-probe": "loaded" })) } }'
+const PROMPT = 'Use only Cordis tools. First call cordis_runtime_inspect with what "temporary". '
+ + 'Then call cordis_define with name "snapshot noop", purpose "does nothing, for the snapshot", '
+ + `code exactly ${JSON.stringify(PACKAGE_CODE)} and client exactly ${JSON.stringify(CLIENT_CODE)}. `
+ + 'Read its returned id and call cordis_run with that exact id, then cordis_stop with the same id. '
+ + 'After all four calls succeed, reply exactly CORDIS_UI_DONE and stop.'
function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
const turnEnd = events.findLast(
@@ -82,6 +98,22 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
+
+ // `cordis_run` blocks host-side on a person's answer — no timer, no default.
+ // The approval is the TEST's action in every mode: the fixture pins what the
+ // model said, and the gate is a real round trip through the real panel.
+ const badge = page.locator('[data-cordis-badge]')
+ await expect.poll(() => badge.getAttribute('data-cordis-awaiting'), { timeout: 90_000 }).toBe('true')
+ await badge.click()
+ const approve = page.locator('[data-cordis-approve]').first()
+ await approve.waitFor({ timeout: 10_000 })
+ // The one assertion this scenario cannot give up: the model asking to run is
+ // NOT the plugin running. Until a person answers, the browser half has not
+ // been fetched, evaluated, or mounted anywhere on this page.
+ expect(await page.locator('[data-snapshot-probe]').count()).toBe(0)
+ await approve.click()
+ await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 30_000 }).toBe(1)
+
const sessionId = await settled
if (MODE === 'record') {
assertCompleteCordisLifecycle(sessionEvents)
@@ -100,20 +132,30 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1)
- const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first()
+ const inspectRow = page.locator('[data-tool="cordis_runtime_inspect"]').filter({ hasText: 'Inspect' }).first()
await inspectRow.waitFor({ timeout: 10_000 })
- const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
- await mountRow.waitFor({ timeout: 10_000 })
+ // cordis_define does NOT go through the generic row: ui-cordis registers a
+ // keyed toolview for it, and a keyed hit replaces the generic card. So the
+ // title here is the CARD's ("Cordis Plugin"), and the expanded body is the
+ // card's own two code sections rather than a generic args dump.
+ const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
+ await defineRow.waitFor({ timeout: 10_000 })
// The whole summary row is the expand toggle (unified tool-row interaction).
- await mountRow.locator('[aria-expanded]').first().click()
- await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
- .toContain(MOUNT_CODE)
+ await defineRow.locator('[aria-expanded]').first().click()
+ await expect.poll(() => defineRow.textContent(), { timeout: 10_000 }).toContain(PACKAGE_CODE)
+ await expect.poll(() => defineRow.textContent()).toContain('data-snapshot-probe')
- const unmountRow = page.locator('[data-tool="cordis_unmount"]').filter({ hasText: 'Unmount temporary Plugin' }).first()
- await unmountRow.waitFor({ timeout: 10_000 })
- await expect.poll(() => unmountRow.textContent()).toContain('dyn-')
- await expect(unmountRow.getAttribute('data-state')).resolves.toBe('ok')
+ const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run dynamic package' }).first()
+ await runRow.waitFor({ timeout: 10_000 })
+ await expect.poll(() => runRow.textContent()).toContain('dyn-')
+
+ const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop dynamic package' }).first()
+ await stopRow.waitFor({ timeout: 10_000 })
+ await expect.poll(() => stopRow.textContent()).toContain('dyn-')
+ await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')
+ // Stopping withdraws the browser half from every page, probe included.
+ await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 15_000 }).toBe(0)
})
it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index 3890a13426..f568637925 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -70,6 +70,7 @@ import SessionStore, {
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import * as CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
// Empty type imports carry the webServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -460,8 +461,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Promise> {
const disposers: Array<() => Promise> = []
try {
- for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote, messageFeedbackRemote]) {
+ for (const contribution of [
+ commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote,
+ ]) {
disposers.push(await ctx.remote.$mount(contribution))
}
} catch (error) {
for (const dispose of disposers.reverse()) await dispose()
throw error
}
+ // Unwound in reverse mount order, so a namespace never outlives one mounted
+ // after it.
return async () => {
for (const dispose of disposers.reverse()) await dispose()
}
diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts
index 8a9fa716b1..6572c11938 100644
--- a/packages/api/remotes/src/index.ts
+++ b/packages/api/remotes/src/index.ts
@@ -8,6 +8,7 @@ import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
// makes the shape assertion below judge real signatures rather than an empty
// event vocabulary.
import type {} from '@deepseek-ai/dsh-commands/types'
+import type {} from '@deepseek-ai/dsh-cordis-host-runner/types'
import type {} from '@deepseek-ai/dsh-credentials/types'
import type {} from '@deepseek-ai/dsh-llm/types'
import type {} from '@deepseek-ai/dsh-agent-presets/types'
diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts
index 5992b0e410..2174336532 100644
--- a/packages/api/remotes/src/remote-events.ts
+++ b/packages/api/remotes/src/remote-events.ts
@@ -18,6 +18,12 @@ export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
+ 'cordis/request-run',
+ 'cordis/request-run-resolved',
+ 'cordis/dynamic-package',
+ 'cordis/dynamic-retract',
+ 'cordis/inspect-query',
+ 'cordis/inspect-query-resolved',
'llm/adapters-updated',
'settings/document-updated',
] as const
diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json
index 6bdbb45f24..0f5fc31211 100644
--- a/packages/api/remotes/tsconfig.client.json
+++ b/packages/api/remotes/tsconfig.client.json
@@ -22,6 +22,10 @@
},
{
"path": "../../credentials/credentials"
+
+ },
+ {
+ "path": "../../self-modification/cordis-host-runner"
},
{
"path": "../../goal/goal"
diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json
index 5e083339f8..6c3131944f 100644
--- a/packages/api/remotes/tsconfig.host.json
+++ b/packages/api/remotes/tsconfig.host.json
@@ -37,6 +37,9 @@
{
"path": "../../session/session-persistence"
},
+ {
+ "path": "../../self-modification/cordis-host-runner"
+ },
{
"path": "../../settings/settings"
},
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index 7e7b5f95ef..13c77e2926 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -98,6 +98,9 @@
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
+ - id: cordis-host-runner
+ name: '@deepseek-ai/dsh-cordis-host-runner'
+
# Ordinary provider for the parsed Web flags. Its plugin-level injection
# waits for cmdlineArgs; no launcher metadata or special row kind is needed.
- id: web-startup
@@ -164,6 +167,9 @@
- id: client-runtime
name: '@deepseek-ai/dsh-client-runtime'
+ - id: cordis-client-runner
+ name: '@deepseek-ai/dsh-cordis-client-runner'
+
- id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme'
@@ -195,6 +201,9 @@
- id: ui-tool
name: '@deepseek-ai/dsh-client-ui-tool'
+ - id: ui-cordis
+ name: '@deepseek-ai/dsh-client-ui-cordis'
+
# Durable workflow lifecycle as an independent Chat node after the
# existing generic workflow tool row.
- id: ui-workflow-run
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 392106619b..c41c9c645a 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
@@ -82,6 +83,8 @@
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^",
+ "@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
+ "@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-web-frontend": "workspace:^",
"@deepseek-ai/dsh-host-frontend-static": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts
index b45d873e97..c5680afacf 100644
--- a/packages/client/runtime/src/client/slots.ts
+++ b/packages/client/runtime/src/client/slots.ts
@@ -18,13 +18,26 @@ import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type {
- LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
+ LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
- /** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */
+ /**
+ * The built-in render-tree root hole (seeded by SlotCore): the one slot the
+ * shell itself renders, and the ancestor of every other seat. OCCUPIED by
+ * ui-layout's AppFrame, which declares the sidebar, conversation, details,
+ * and shell.overlay seats inside it.
+ *
+ * DO NOT register here. This is a single slot, so a second entry does not
+ * sit beside the frame — it shadows it, and a dynamically registered entry
+ * is assigned a lower priority than the shipped one, which makes it the
+ * winner: the page would render your component alone, with every seat the
+ * frame declares gone. For a surface of your own that floats over the whole
+ * app, register into `shell.overlay` instead (a list slot: additive, and
+ * click-through until your entry opts into pointer events).
+ */
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
}
}
@@ -274,6 +287,43 @@ export class SlotRegistry extends Service {
return this._core.entries(key)
}
+ /**
+ * Shadowing winners per cell for a key: the first live (non-abdicated)
+ * entry of each cell in priority order — what outlets render; chain keys
+ * pass through unchanged (election consumes every entry). The raw
+ * {@link SlotsService.entries} view stays the inspection surface. Fresh
+ * array per call, not a uSES getSnapshot source.
+ * @param key - SlotMap key.
+ * @returns the winning entry per occupied cell.
+ */
+ entriesOfSlot(key: keyof SlotMap & string): readonly StoredEntry[] {
+ return this._core.entriesOfSlot(key)
+ }
+
+ /**
+ * Export the current JSON-safe Slot declaration tree for read-only inspection.
+ * @param root - exact live Slot root; omitted returns all roots.
+ * @returns selected Slot trees.
+ */
+ snapshot(root?: string): LiveSlotNode[] {
+ return this._core.snapshot(root)
+ }
+
+ /**
+ * Observe entry boundary crashes (every render-time entry failure the
+ * boundaries contain, abdicating or not) — the supervision seam for
+ * plugins mirroring contribution health. Fires synchronously per report,
+ * after the registry mutated for abdicating crashes. Callers own the
+ * disposer (wire it through ctx.effect for fiber-lifetime cleanup, as with
+ * {@link SlotsService.subscribe}).
+ * @param fn - called with the slot key, the crashed entry, the crash
+ * cause, and `abdicated`: whether the crash retired the entry from its cell.
+ * @returns unsubscribe.
+ */
+ onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void {
+ return this._core.onEntryError(fn)
+ }
+
/**
* Look up a declared spec (register-declared or the built-in 'root').
* @param key - SlotMap key.
@@ -353,6 +403,8 @@ export class SlotRegistry extends Service {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
entriesOf: key => this._core.entries(key),
+ entriesOfSlot: key => this._core.entriesOfSlot(key),
+ reportEntryError: (key, entry, error, info) => { this._core.reportEntryError(key, entry, error, info) },
specOf: key => this._core.specDynamic(key),
isLive: entry => this._core.isLive(entry),
storeOf: (entry, scopeKey) =>
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index fd3ef38817..21259146a7 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -33,16 +33,32 @@ export interface ComposerAttachment {
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
- * Strict-session body inside the resident conversation scrollport. It
- * owns the per-session draft mirror and active view ring.
+ * The entire body of one session: taking this seat means rendering that
+ * session's conversation yourself. The occupant also owns the per-session
+ * draft mirror and the active view ring, so a replacement inherits both
+ * duties and an empty one leaves a blank session pane — nothing here
+ * degrades gracefully. To ADD rather than replace, take a seat inside the
+ * flow instead: `conversation.view` for a whole tab, the input regions for
+ * composer chrome.
*/
'conversation.session': { kind: 'single'; scope: 'session' }
- /** Strict-session header above the resident conversation scrollport. */
+ /**
+ * The strip above the session's scrollport: title, view tabs, and the
+ * action row. Taking this seat means rendering all three yourself, and it
+ * also collapses `conversation.session.header.actions` — that additive
+ * seat is declared by whoever occupies this one, so replacing the header
+ * takes every action entry down with it.
+ */
'conversation.session.header': { kind: 'single'; scope: 'session' }
/**
- * Session-header actions contributed by feature plugins. Entries render
- * by ascending `order`; negative values are reserved for static session
- * context that precedes interactive actions.
+ * One button in the session header's action row — the additive way to put
+ * a per-session control beside the title without replacing the header.
+ * Entries render by ascending `order`; negative values are reserved for
+ * static session context that precedes interactive actions. The owner
+ * passes nothing: everything a control needs comes from the framework
+ * session kit (`sessionId`, `useSession`, `useInput`, `inputActions`) and
+ * from the registrant's own inject face, so an empty owner share means
+ * self-sufficient, not starved.
*/
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
@@ -95,7 +111,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
scope: 'session'
owner: AssistantActionOwnerProps
}
- /** Selected Tool call output inside the details panel. */
+ /**
+ * The body of the details panel for the tool call the user selected —
+ * one occupant, so taking it means rendering every tool's output, not just
+ * the ones you know. The owner passes a frozen `block` whose two lifecycle
+ * forms must both be handled: branch on `'kind' in block` (a settled
+ * `ToolResultNode` has it, a still-running call does not), and treat
+ * `cwd` as display-only, for shortening workspace-rooted paths.
+ * A per-tool renderer belongs in the keyed `tool.call.toolview` seat
+ * instead; this one is the whole panel.
+ */
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
@@ -124,15 +149,41 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// ui-input-trigger, so the type arrives transitively). The runtime declaration
// (children table in apply.ts) stays here with the other input slots.
/**
- * Stacked strip above the input (queue rows / GoalBar / attachments;
- * entries coexist in fixed order).
+ * A full-width row of its own, stacked above the composer card — the seat
+ * for anything that needs a line to itself (queue rows, a todo strip, a
+ * goal bar). Pick this over the three seats below when your content wraps
+ * or carries prose; pick `conversation.composer.dock` for an ambient
+ * readout under the card, and `conversation.input.left` /
+ * `.right` for a small control INSIDE the card's tool row.
+ * Read only `session`/`input` off the owner share ({@link InputZone}) —
+ * both are point-in-time snapshots re-rendered for you, never subscribe.
*/
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
- /** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
+ /**
+ * The band under the composer card, inside the bar's width column — the
+ * seat for an ambient readout about the conversation (the shipped stats
+ * line lives here). Same {@link InputZone} owner share as the other
+ * regions. Anything the user must click belongs in the tool row instead
+ * (`conversation.input.left` / `.right`); anything needing its own line
+ * above the card belongs in `conversation.input.dock`.
+ */
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
- /** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
+ /**
+ * The left end of the tool row INSIDE the composer card, after the
+ * resident chrome (access mode, plan, attach) — the seat for a small
+ * always-visible control. Entries sit beside that chrome, never replace
+ * it. Same {@link InputZone} owner share; use `.right` for a control that
+ * belongs next to the send button, and the docks for anything taller than
+ * one row.
+ */
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
- /** Tool-row right region inside the input card. */
+ /**
+ * The right end of the same tool row, before the primary send button —
+ * the seat for a control the user reaches on the way to sending (the
+ * model select sits in its own named seat just left of here). Same
+ * {@link InputZone} owner share and the same one-row height budget as
+ * `conversation.input.left`.
+ */
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
/**
* The default composer body: a single slot rendered as the composer
@@ -149,15 +200,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/**
- * The Plan-mode status seat in the composer tool row (left group,
- * right of the access-mode control). Declared by the composer-bar
- * entry; empty until a plan plugin registers (no placeholder
- * fallback).
+ * The named plan-status seat in the composer tool row, immediately right
+ * of the access-mode control — one occupant, so taking it means rendering
+ * the plan affordance yourself. The owner passes only `locked` (see
+ * {@link InputControlOwnerProps}): honour it by refusing interaction, and
+ * take everything else from the framework session kit or your own inject.
+ * Unoccupied, the seat renders nothing at all — the bar paints no
+ * placeholder, so an absent plan plugin costs no layout.
*/
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
/**
- * The model-select seat in the composer tool row (right group). Same
- * empty-until-registered contract as the plan seat.
+ * The named model-select seat at the right end of the composer tool row,
+ * left of the send button — one occupant, so taking it means rendering the
+ * whole model affordance yourself. Same `locked`-only owner share and same
+ * renders-nothing-while-empty contract as the plan seat. Note the composer
+ * deliberately keeps this seat LIVE while it refuses text for a
+ * model-related block: every such block is one the user clears by picking
+ * a model here.
*/
'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
}
diff --git a/packages/client/ui-input-trigger/src/client/contract.ts b/packages/client/ui-input-trigger/src/client/contract.ts
index 311333cad3..887ef1cb18 100644
--- a/packages/client/ui-input-trigger/src/client/contract.ts
+++ b/packages/client/ui-input-trigger/src/client/contract.ts
@@ -10,8 +10,16 @@ import type { InputTriggerController } from './controller.ts'
/** The `ctx.inputTriggers` service face. */
export interface InputTriggerServiceContract {
- /** Register one trigger source; effect disposer. Duplicate (trigger, name) throws. */
+ /**
+ * Register one trigger source; duplicate trigger/name pairs throw.
+ * @param src - source that discovers and resolves slash or reference candidates.
+ * @returns effect disposer removing this source.
+ */
registerSource(src: InputTriggerSource): () => void
- /** Resolve the per-session controller for one session scope (lazy; dies with the scope). */
+ /**
+ * Resolve the lazy controller owned by one session scope.
+ * @param actx - session-scoped Client context.
+ * @returns controller that dies with that scope.
+ */
sessionOf(actx: ClientContext): InputTriggerController
}
diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts
index b804fa249c..c56d83bfbb 100644
--- a/packages/client/ui-layout/src/client/index.ts
+++ b/packages/client/ui-layout/src/client/index.ts
@@ -36,11 +36,51 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// there); these four are the frame's children, declared by the same
// register() call that contributes AppFrame. Session owners never pass
// sessionId: the framework injects it as a standard prop.
+ /**
+ * The whole left column. OCCUPIED by ui-sidebar's SidebarRoot, which
+ * declares the workspace and settings seats inside it — registering here
+ * replaces the navigation column outright rather than adding to it, and
+ * the seats it declares disappear with it. To add something to the
+ * sidebar, register into one of those inner seats instead.
+ *
+ * The occupant receives the frame's live column state (collapsed, width)
+ * and is expected to render the compact control rail while collapsed.
+ */
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
- // Current-session-optional: the occupant owns both the no-session hero
- // and live conversation states without changing its React identity.
+ /**
+ * The whole center column, across both the no-session hero and a live
+ * conversation. OCCUPIED by ui-conversation's ConversationRoot, which
+ * declares the session body, composer, and input seats inside it —
+ * registering here replaces the entire conversation surface (and removes
+ * every seat it declares) rather than adding to it.
+ *
+ * Current-session-optional: the occupant owns both states without
+ * changing its React identity, so it keeps its own state across a session
+ * switch. It receives no owner props; session facts arrive through the
+ * framework hooks of the `session-maybe` scope.
+ */
'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps }
+ /**
+ * The right details column, shown when the layout opens it. OCCUPIED by
+ * ui-conversation's DetailsPanel, which declares the tool-details seat
+ * inside it — registering here replaces the column and takes that seat
+ * with it. Absent an occupant the column renders nothing.
+ *
+ * No owner props: the framework injects the session id and hooks for the
+ * `session` scope, and `ctx.layout` owns whether the column is open.
+ */
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
+ /**
+ * Frame-wide floating layer, above every column and outside their scroll
+ * containers. Deliberately generic and unowned by any feature: a badge, a
+ * toast stack or a status pill all belong here, and entries order among
+ * themselves. The layer itself is click-through — entries opt back into
+ * pointer events — so an occupant never blocks the app underneath.
+ *
+ * This is the additive seat for a frame-wide surface of your own: a fresh
+ * `id` is added beside the shipped entries instead of replacing them.
+ */
+ 'shell.overlay': { kind: 'list'; scope: 'root' }
}
}
@@ -83,6 +123,7 @@ export function apply(ctx: ClientContext): void {
'sidebar': { kind: 'single', scope: 'root' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
+ 'shell.overlay': { kind: 'list', scope: 'root' },
},
// Exclusive store: the factory itself — the framework instantiates per
// entry and delivers useStore/actions to AppFrame as standard props.
diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx
index 2606f54e62..549dc31997 100644
--- a/packages/client/ui-primitives/src/icons/index.tsx
+++ b/packages/client/ui-primitives/src/icons/index.tsx
@@ -601,6 +601,24 @@ export const IconCodeOutline16 = ({ size = 16, className }: IconProps) => (
)
+/** ic_ds_cordis_plugin_outline_14 */
+export const IconCordisPluginOutline14 = ({ size = 14, className }: IconProps) => (
+
+)
+
/** ic_ds_api_outline (figma extract) */
export const IconApiOutline14 = ({ size = 14, className }: IconProps) => (