Merge remote-tracking branch 'origin/master' into feat/py-types-code-mode

This commit is contained in:
Chinesezjc
2026-08-02 03:58:33 +08:00
154 changed files with 5342 additions and 867 deletions

View File

@@ -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-07-30-package-manager-native-repository-cache.md
2026-07-30-package-manager-native-repository-cache.md: f8a6706065a936ca4a9abf2a50d266a60f09b252
2026-07-30-package-manager-native-repository-cache.zh.md: b1fea3d655f8d7aeb466744dc27bbf4ba69993ec

View File

@@ -0,0 +1,47 @@
# Agent Note: Package-manager-native repository cache
Status: implemented
English | [中文](2026-07-30-package-manager-native-repository-cache.zh.md)
## Problem
A standalone Harness app cannot rely on a developer-owned SDK project to declare and install repository dependencies. Loading a configured GitHub repository therefore needs a persistent fetch, preparation, and cache boundary, but implementing Git transport, hosted-source syntax, package preparation, and a content store inside DSH would duplicate a package manager. Requiring a separately installed package manager would make a config-only feature depend on host setup.
The cache also needs an update identity. A mutable branch name cannot both remain permanently cached and reflect later commits without an independent refresh protocol.
## Decision
Vendored `@cordisjs/plugin-loader/repository` exports `RepositoryCache`, a generic Node-only package helper with no DSH plugin-format knowledge. Keeping it on a subpath prevents browser consumers of the Loader's main entry from traversing Node filesystem and child-process imports. The caller supplies a package-manager-native source specifier and a cache root. DSH-specific callers own accepted source syntax, path selection, and the cache-root location; the [SDK project dependency workflow](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation) remains a separate path owned by the developer project's selected package manager.
The Loader carries an exact runtime dependency on `pnpm@11.7.0` and invokes that package's JavaScript entry with the current Node executable. It never discovers a global executable or delegates through Corepack. Each cache miss creates an isolated project with one dependency named `repository`; pnpm owns Git/GitHub resolution, fetching, its content-addressed store, dependency installation, and lifecycle scripts in the repository's dependency graph.
The isolated workspace sets `dangerouslyAllowAllBuilds: true`. A configured repository and its dependency graph are trusted executable code: lifecycle scripts may run before DSH reads any declared assets. The child receives ordinary host process state needed by Git and pnpm, but ambient credential-shaped (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`) variables are removed. No OAuth, token forwarding, or private-repository authentication contract is added.
The SHA-256 of the exact specifier names the cache entry. Concurrent same-process requests share one task. Installation occurs in a sibling temporary directory; only a successful install with a package directory and marker is atomically renamed into the final key. Failed staging is removed, and a competing process's already-published valid entry wins. A later process validates the marker and package directory before returning the stable `node_modules/repository` path.
An identical specifier permanently reuses its published entry. The caller changes the ref or another part of the specifier to request a new generation; the cache does not poll remotes, reinterpret mutable refs, expire entries, or garbage-collect old generations.
## Alternatives considered
**Implement GitHub download, archive extraction, preparation, and caching directly.** Rejected under the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md): pnpm already owns hosted Git syntax, Git execution, lifecycle policy, and a shared content store. A second resolver would add more code while still needing package semantics.
**Require `pnpm` on `PATH` or invoke Corepack.** Rejected because changing one app config must be sufficient on every supported installation. Pinning and shipping the CLI also makes the preparation policy reviewable and independent of the host's package-manager version.
**Resolve a branch or tag again on every startup.** Rejected because it turns startup into a network refresh, changes code without a config diff, and makes rollback depend on remote state. Explicit ref changes preserve auditability even when a user deliberately chooses a mutable ref.
**Disable repository lifecycle scripts.** Rejected because common plugin repositories need a declarative `prepare` step to validate and package their plugin subdirectory. The trust boundary is explicit configuration of executable source, not an incomplete illusion that only static files can run.
**Introduce a Cordis repository service.** Rejected because cache lookup has no runtime contribution registry or provider variation. A small helper lets the later host own Cordis lifecycle and HMR without adding a service seam prematurely.
## Consequences
- Standalone apps carry pnpm's approximately 18.6 MB unpacked runtime instead of requiring a global tool or owning a Git/package implementation.
- A repository author may use ordinary package preparation, and a malicious configured repository or dependency can execute code with the scrubbed child environment and the user's filesystem authority.
- Exact specifiers make startup deterministic after the first successful install; changing cached code requires a config/ref change.
- Failed installs leave no published cache entry and may be retried. Published corruption fails loud instead of silently reinstalling under the same identity.
- Cache generations consume disk until a future explicit cache-management policy removes them.
## Testing
`packages/ui/app-boot/tests/repository-cache.spec.ts` covers same-process single-flight, cross-instance cache reuse, exact-specifier separation, failed-stage cleanup and retry, and boundary validation. Its real local-Git case invokes the bundled pnpm, runs the fixture repository's `prepare` script, and reads the prepared file from the installed cache entry without network access.

View File

@@ -0,0 +1,47 @@
# Agent Note: 包管理器原生仓库缓存
Status: implemented
[English](2026-07-30-package-manager-native-repository-cache.md) | 中文
## 问题
独立运行的 Harness 应用不能依赖开发者自有的 SDK 工程来声明并安装仓库依赖。因此,加载配置中的 GitHub 仓库需要一道持久的获取、准备与缓存边界;但如果在 DSH 内实现 Git 传输、托管来源语法、包package准备流程和内容存储就会重复实现包管理器。若要求用户另行安装包管理器则只需修改配置即可使用的功能还会依赖宿主环境的额外配置。
缓存还需要明确更新标识。若没有独立的刷新协议,可变分支名无法既永久缓存,又反映后续 commit。
## 决策
vendor 中的 `@cordisjs/plugin-loader/repository` 导出 `RepositoryCache`:一个不包含 DSH 插件格式知识、仅限 Node 使用的通用包辅助工具。把它保留在子路径上,可以避免 Loader 主入口的浏览器消费方在解析依赖时遍历到 Node 文件系统和子进程 import。调用方提供包管理器原生的来源 specifier 和缓存根目录。DSH 专属调用方负责规定可接受的来源语法、路径选择与缓存根目录位置;[SDK 工程依赖工作流](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation)仍是另一条路径,由开发者工程选定的包管理器负责。
Loader 将 `pnpm@11.7.0` 作为固定版本的运行时依赖,并使用当前 Node 可执行文件调用该包的 JavaScript 入口。它绝不探测全局可执行文件,也不经 Corepack 调用。每次缓存未命中都会创建一个隔离工程,其中只有一个名为 `repository` 的依赖Git 与 GitHub 来源的解析和获取、pnpm 自身的内容寻址 store、依赖安装以及仓库依赖图中的生命周期脚本均由 pnpm 负责。
隔离工作区设置 `dangerouslyAllowAllBuilds: true`。用户配置的仓库及其依赖图都属于受信任的可执行代码DSH 读取任何已声明资产之前,生命周期脚本就可能运行。子进程会收到 Git 与 pnpm 所需的常规宿主进程状态,但会移除环境中名称形似凭据(`KEY``PASSWORD``SECRET``TOKEN`)的变量。该机制不新增 OAuth、token 转发或私有仓库认证契约。
缓存项以精确 specifier 的 SHA-256 命名。同一进程内针对相同 specifier 的并发请求共享一项任务。安装在同级临时目录中进行;只有安装成功且存在包目录和标记时,系统才会把暂存目录原子重命名为最终键对应的目录。失败的暂存目录会被删除;如果另一进程已发布有效项,则以该项为准。后续进程会先校验标记与包目录,再返回稳定的 `node_modules/repository` 路径。
相同的 specifier 会永久复用已发布项。调用方通过修改 ref 或 specifier 的其他部分来请求新的缓存代次;缓存不会轮询远端、重新解释可变 ref、让条目过期也不会垃圾回收旧代次。
## 曾考虑的替代方案
**直接实现 GitHub 下载、归档解压、准备与缓存。** 根据[依赖政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳pnpm 已负责托管 Git 语法、Git 执行、生命周期政策和共享内容存储。第二套解析器会增加更多代码,却仍需实现包语义。
**要求 `pnpm` 位于 `PATH` 上,或调用 Corepack。** 不予采纳:在每种受支持的安装形态中,只修改一份应用配置就必须足以启用该功能。固定并随应用分发 CLI命令行界面还能使准备政策可供评审并与宿主的包管理器版本无关。
**每次启动都重新解析分支或 tag。** 不予采纳:这会把启动变成网络刷新,在配置 diff 未变化时更改代码,并让回滚依赖远端状态。即使用户有意选择可变 ref显式修改 ref 仍能保持可审计性。
**禁用仓库生命周期脚本。** 不予采纳:常见插件仓库需要声明式 `prepare` 步骤来校验并打包插件子目录。信任边界是显式配置可执行来源,而不是营造一种不完整的假象,仿佛只有静态文件能够运行。
**引入 Cordis 仓库服务。** 不予采纳:缓存查找没有运行时贡献注册表,也不存在提供方变体。小型 helper 让后续宿主负责 Cordis 生命周期与 HMR热模块替换无需过早新增服务 seam。
## 后果
- 独立应用随附 pnpm 约 18.6 MB 的解压后运行时,不要求全局工具,也无需自行实现 Git 与包处理。
- 仓库作者可以使用常规包准备流程;恶意的已配置仓库或依赖可以在经过上述清理的子进程环境中,以用户的文件系统权限执行代码。
- 精确 specifier 使首次安装成功后的启动具有确定性;更改缓存代码必须修改配置或 ref。
- 安装失败不会留下已发布缓存项,可以再次重试。已发布缓存损坏时会明确报错,而不会在同一标识下静默重装。
- 缓存代次会持续占用磁盘,直到未来有明确的缓存管理政策将其移除。
## 测试
`packages/ui/app-boot/tests/repository-cache.spec.ts` 覆盖同进程 single-flight、跨实例缓存复用、精确 specifier 隔离、失败暂存清理与重试,以及边界校验。其真实本地 Git 用例会调用随附的 pnpm运行 fixture测试前置数据仓库的 `prepare` 脚本,并在不访问网络的情况下,从已安装缓存项中读取准备后的文件。

View File

@@ -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-07-30-static-repository-plugin-format.md
2026-07-30-static-repository-plugin-format.md: c9d755b925a6ea05eed71e75803397d2672df9f4
2026-07-30-static-repository-plugin-format.zh.md: 361de64d2e98b9fb4ac42963e4ae48e77fbc7016

View File

@@ -0,0 +1,49 @@
# Agent Note: Static repository Plugin format
Status: implemented
English | [中文](2026-07-30-static-repository-plugin-format.zh.md)
## Problem
A repository that already contains reusable skills or an MCP server declaration should be usable by standalone Harness applications without becoming a Harness SDK project or rewriting its existing layout. Popular repositories must be able to add one `.dsh-plugin` directory while keeping their current skills and `.mcp.json` elsewhere in the tree. At the same time, treating an arbitrary repository entry point as a Cordis Plugin would make every repository a new unrestricted runtime extension surface and would bypass the existing skill and MCP lifecycle owners.
The [package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) prepares an exact package source but intentionally knows nothing about DSH formats. This layer therefore needs a package-manager-compatible authoring format, a deterministic prepared artifact, and a Cordis composition that stays transactional under Loader disposal and replacement.
## Decision
`@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths.
The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, an `inject` list derived from it (`loader`, plus `skills` and/or `tools` per the declared capabilities, so the wrapper fiber gates on the services its children need), and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point.
Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself.
Each prepared skill set mounts `dsh-skill-local` with a unique `repository:<package-name>` provider name, only the copied custom roots, and watching disabled. `dsh-skill-local` therefore gains two general configuration fields: `providerName` and `includeDefaultRoots`. Their defaults preserve its existing single local provider; repository instances set a distinct name and exclude project/user roots so multiple instances neither collide nor duplicate host-local discovery.
Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Consequently an MCP connection failure keeps its established successful-plugin/no-tools behavior and is not reclassified as a repository preparation or Loader failure.
Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Hooks, commands, agents, apps, arbitrary Cordis code, marketplaces, and discovery are also unsupported. Repository subdirectory selection and GitHub source configuration belong to the [standalone app integration](../feature/2026-07-30-config-only-repository-plugins.md), not this format package.
## Alternatives considered
**Load a repository's own Cordis entry point.** Rejected because it makes the advertised static format an unrestricted code-loading API, requires repository authors to depend on Harness internals, and duplicates the ordinary SDK/plugin-dependency path.
**Teach generated wrappers to implement skills and MCP directly.** Rejected because copied runtime code would drift from `dsh-skill-local` and `dsh-mcp-client`, especially their provider invalidation, tool synchronization, failure, and teardown contracts.
**Import Harness packages from each generated wrapper.** Rejected because repository packages should not resolve or version the application's internal dependency graph. A Loader builtin supplies one app-owned implementation and keeps generated wrappers import-free.
**Watch prepared repository assets.** Rejected because an exact repository cache generation is immutable. Ref, subdirectory, or configuration changes select a new generation; a second watcher would create an unowned refresh identity.
**Treat MCP connect failures as Loader update failures.** Rejected because the existing MCP client deliberately contains connect failures and exposes no tools. Changing that semantic only for repository sources would create two failure contracts for the same server configuration.
## Consequences
- Existing skill/MCP repositories can add a small `.dsh-plugin/package.json` without relocating their assets or adopting an SDK project.
- Prepared output is deterministic static glue, while the configured repository and its dependency lifecycle remain trusted executable package-manager input rather than a sandbox.
- Multiple repository Plugins coexist through provider names and ordinary MCP server-name uniqueness; duplicate names fail through their existing registries and participate in Loader rollback.
- Cached source edits do not appear live. Another exact source/ref/path/config selection is required.
- Adding another contribution kind requires an explicit format and DSH-owned runtime consumer; it cannot arrive as repository JavaScript by accident.
## Testing
Focused tests prepare skills and MCP metadata, prove the emitted wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The keyless headless example loads a checked-in prepared wrapper through its real `cordis.yml` and snapshots the repository skill's logged model catalog row.

View File

@@ -0,0 +1,49 @@
# Agent Note静态 repository Plugin 格式
状态:已实现
[English](2026-07-30-static-repository-plugin-format.md) | 中文
## 问题
一个已经包含可复用 skills 或 MCP server 声明的仓库,应当能被独立 Harness 应用使用,而不必先变成 Harness SDK 项目,也不应被迫改写现有布局。常见仓库只需新增一个 `.dsh-plugin` 目录,同时仍可把原有 skills 与 `.mcp.json` 放在仓库其他位置。与此同时,如果把任意仓库入口都当作 Cordis Plugin就会让每个仓库成为新的无限制运行时扩展表面并绕过现有的 skill 与 MCP 生命周期所有者。
[Package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) 会准备一个精确 package source但有意不了解任何 DSH 格式。因此本层需要一种兼容 package manager 的创作格式、确定性的已准备产物,以及在 Loader dispose 和替换期间仍保持事务性的 Cordis 组合。
## 决策
`@deepseek-ai/dsh-repository-plugin` 负责一个受限的 `.dsh-plugin` package 格式且只允许两类贡献skill 根和一个通用 `.mcp.json`。Package metadata 使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径;两者至少需要一个。路径可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的 Plugin 可以拥有其 package 上方相邻的子树,却不能访问无关宿主路径。
`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs``.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、由 manifest 派生的 `inject` 列表(`loader`,加上按声明能力加入的 `skills``tools`,使包装 fiber 在其子插件所需服务上门控),以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。
加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。
每份已准备 skill 集合都会挂载 `dsh-skill-local`,使用唯一的 `repository:<package-name>` 提供方名称、仅包含复制后的自定义根,并禁用监视。因此 `dsh-skill-local` 新增两个通用配置字段:`providerName``includeDefaultRoots`。默认值保持原有单一本地提供方行为repository 实例设置不同名称并排除项目/用户根,使多个实例既不冲突,也不会重复宿主本地发现。
`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`stdio 定义只允许可选的 `type: "stdio"``command``args``env`HTTP 定义只允许 `type: "http"``url``headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transportstdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。因此 MCP 连接失败会继续沿用“Plugin 成功但不注册工具”的既有行为,不会被重新分类为 repository 准备或 Loader 失败。
未知 MCP 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容契约。Hooks、commands、agents、apps、任意 Cordis 代码、marketplace 和发现同样不受支持。Repository 子目录选择与 GitHub 源配置属于[独立应用集成](../feature/2026-07-30-config-only-repository-plugins.md),而不是本格式 package。
## 考虑过的替代方案
**加载仓库自己的 Cordis 入口。** 拒绝,因为这会把宣传为静态的格式变成无限制代码加载 API要求仓库作者依赖 Harness 内部实现,并重复普通 SDKPlugin dependency 路径。
**让生成包装模块直接实现 skills 和 MCP。** 拒绝,因为复制的运行时代码会与 `dsh-skill-local``dsh-mcp-client` 漂移,尤其是提供方失效、工具同步、失败和 teardown 契约。
**让每个生成包装模块 import Harness package。** 拒绝,因为 repository package 不应解析或锁定应用的内部依赖图。Loader builtin 提供一份由 app 所有的实现,并让生成包装模块保持无 import。
**监视已准备 repository 资源。** 拒绝,因为一个精确 repository cache generation 是不可变的。Ref、子目录或配置变化会选择新 generation第二套 watcher 会创造一套没有所有者的刷新身份。
**把 MCP 连接失败当作 Loader 更新失败。** 拒绝,因为现有 MCP client 有意收束连接失败并不暴露工具。只对 repository source 改变该语义,会让同一 server 配置拥有两套失败契约。
## 后果
- 现有 skillMCP 仓库可以新增一个很小的 `.dsh-plugin/package.json`,无需移动资源或采用 SDK 项目。
- 已准备输出是确定性的静态胶水;已配置仓库及其依赖生命周期仍是受信任的可执行 package-manager 输入,而非 sandbox。
- 多个 repository Plugin 通过提供方名称和普通 MCP server-name 唯一性共存;重复名称经现有 registry 失败,并参与 Loader 回滚。
- Cache 内的源码编辑不会实时出现;必须选择另一个精确 sourcerefpathconfig。
- 新增贡献类型必须提供显式格式和 DSH 自有运行时消费方;它不能意外以 repository JavaScript 形式进入。
## 测试
聚焦测试会准备 skills 与 MCP metadata证明生成包装模块不含 import拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill移除 Loader 条目并观察提供方清理。Keyless headless 示例通过真实 `cordis.yml` 加载一份签入的已准备包装模块,并快照 repository skill 写入日志的模型目录行。

View File

@@ -1,6 +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
2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40
2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md
2026-07-20-config-hot-reload-resilience.md: 0f15bb0aaacb6e06c416cbe35b44155279497eee
2026-07-20-config-hot-reload-resilience.zh.md: 8a185c1915b5247150d8bb1dd5c42d69bd4f2a35

View File

@@ -6,33 +6,36 @@ English | [中文](2026-07-20-config-hot-reload-resilience.zh.md)
## Problem
The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones.
An invalid `cordis.yml` edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, including a file or parent directory created after startup.
## Decision
Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers:
The vendored Cordis lifecycle and Loader plugins provide an awaited, compensating config transaction, logged as local modifications 6, 8, and 9 in [vendor/README.md](../../../../vendor/README.md).
- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down.
- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged".
- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay.
`Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang.
Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`.
Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation starts candidates concurrently, awaits every outcome, and restores changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree.
Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse.
HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` method watches one exact path from the nearest existing ancestor, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Both exact-path and ordinary config-file refreshes use that queue. A failure is normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed(filename, error)` event; rejecting observers are logged without stopping later refreshes. Creation, change, and removal are observed.
## Alternatives considered
**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include.
**Contain failures inside `Include.refresh()`.** Rejected because it prevents an HMR host from broadcasting the failure and still permits Loader reconciliation to hide partial application. Include owns candidate parsing and commit; HMR owns containment and observation.
**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place.
**Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition.
**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file).
**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Awaited application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions.
## Consequences
- A bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred.
- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base.
- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync.
- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree).
- A failed live refresh rejects internally, retains or restores the last-good tree when compensation succeeds, and broadcasts one typed failure without becoming an unhandled rejection.
- A rollback failure is visible and may leave an entry unavailable; the event and log do not claim otherwise.
- Fibers waiting on declared dependencies remain valid pending entries: lifecycle settlement means no current work failed, not that every dependency exists.
- Exact config watchers add filesystem resources only for registered paths and release them with their owning HMR fiber.
- The vendored Loader, Include, HMR, and core event typing diverge further from upstream; the complete divergence is maintained in the vendor manifest.
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include.
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections.

View File

@@ -6,33 +6,36 @@ Status: implemented
## Problem
各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树Code Mode、个人 overlay做任何热重载都会悄悄把打过补丁的配置项回退、并把插入的配置项移除
无效的 `cordis.yml` 编辑不得杀死运行中的 agent智能体但若一次看似有效的更新先部分替换 Loader 树后续配置项才失败仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求HMR热模块替换必须观察其模块根目录之外的一个确切文件包括启动后才创建的文件或父目录
## Decision
加固 vendor 的 `@cordisjs/plugin-include`在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方:
vendor Cordis 生命周期和 Loader 插件提供可等待、带补偿的配置事务,并在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 6、8、9 条。
- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出
- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。
- `refresh()``internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。
`Fiber.update()` 返回其 `internal/update` waterfall瀑布式事件的结果。配置校验保持同步而默认 continuation 返回重启 promise。因此Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber并在 fiber 已结算为失败时 reject等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起
启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`或「config file not found」存在但无效的文件现在会以真实的解析错误失败而不是被误标为文件缺失、或被 `initial` 静默覆盖
Loader 会先导入变化后的模块名,再 dispose资源释放活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect并恢复先前的插件或配置。组内对账会并发启动各候选项等待每项结果并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留
Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。
HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 `Error`、记入日志,并通过并行事件 `hmr/config-update-failed(filename, error)` 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。
## Alternatives considered
**在 HMR 监听回调里捕获,而不是在 `refresh()` ** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷
**在 `Include.refresh()` 内收容失败** 否决,因为这会使 HMR 宿主无法广播失败,却仍允许 Loader 对账掩盖部分应用。Include 负责候选内容的解析与提交HMR 负责收容和观察
**在 `installFailLoud` 里过滤配置文件相关的 rejection** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃
**每次编辑配置都重启进程** 否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话
**用 PTY e2e 证明 TUI 能在错误编辑后存活** 否决其作为主要门禁PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件
**承诺不可见的原子替换** 否决,因为任意插件 effect 无法制作快照。等待应用完成并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换
## Consequences
- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at <file>`agent 继续运行在上一份完好的配置树上下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓
- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置
- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用
- 已知缺口不在本次范围内HMR 监听器只处理 chokidar 的 `change` 事件因此通过重命名替换文件的编辑方式BSD `sed -i``git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI未修改的代码树上即已如此
- 实时刷新失败会在内部 reject补偿成功时会保留或恢复上一份完好的树并广播一次类型化失败而不会成为未处理的 rejection
- 回滚失败可见,并可能使一个配置项不可用;事件和日志不会误称其已恢复
- 等待已声明依赖的 fiber 仍是有效的 pending 配置项:生命周期完成只表示当前工作均未失败,而不表示每项依赖都存在
- 确切配置 watcher 只为已注册路径增加文件系统资源,并随其所属 HMR fiber 一起释放
- vendor 中的 Loader、Include、HMR 与核心事件类型定义进一步偏离上游;全部分叉均维护在 vendor manifest元数据清单中。
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection并保留之前的配置项配置随后的合法编辑正常生效overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败
`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除ACPAgent Client Protocol`pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md
2026-07-28-web-agent-runtime-context.md: 449c9d4ba2b144d02dee4b98ae80c86815aec5c1
2026-07-28-web-agent-runtime-context.zh.md: def1674be5f193739bfb214a24f34590ee075d5f

View File

@@ -0,0 +1,33 @@
# Agent Note: Web agents receive explicit runtime context
Status: implemented
English | [中文](2026-07-28-web-agent-runtime-context.zh.md)
## Problem
The shared CLI base configured an empty deployment persona, the Web overlay did not replace it, and the Web launcher added no source or interaction-surface section. A session header recorded its working directory for tools and persistence, but the model prompt did not state that directory or identify the DeepSeek Harness Web GUI. A request such as “change this page's theme” therefore made the agent search the selected project for an unspecified page, even when the user meant the GUI running the session.
## Decision
The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The launcher registers that setup before mounting the config tree; its `systemPrompt` injection therefore installs both sections before later prompt consumers such as the agent loop can activate and emit a request header. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other.
The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant.
## Verification
The focused startup-order test registers a later `systemPrompt` consumer and proves that it observes both launcher sections on its first activation. The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, registers the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order.
## Alternatives considered
**Send URL, DOM, or screenshots with every prompt.** The observed failure needed stable product orientation, while the current root URL does not identify a selected component and no visual capture exists in the message contract. Adding dynamic page state would require a separate logged model-input design and is not implied by this fix.
**Require the session Workspace to be the harness checkout.** Workspace cwd is the user's task target and may legitimately be an empty project or another repository. Conflating it with the application's source location would break that boundary and leave installed or externally launched sessions ambiguous.
**Put Web wording in the global harness identity.** `dsh-system-prompt` serves TUI, ACP, SDK, and custom deployments that do not run in a browser. The composing Web app owns this surface fact.
**Change the existing source-location section for every CLI surface.** The source section is shared with TUI and states only the checkout fact. Keeping Web orientation separate preserves that reusable contract and avoids telling headless or terminal agents that they are in a browser.
## Consequences
Web requests gain a short stable prompt prefix and may invalidate provider prefix caches once when this change is deployed. Agents can distinguish the GUI source checkout from the selected Workspace and resolve ordinary references to the current app without a clarification round trip. References to a specific visual state remain bounded by the explicit no-DOM/no-route/no-screenshot statement and may still require a path, description, or attachment.

View File

@@ -0,0 +1,33 @@
# Agent Note: Web agent 获得显式运行时上下文
Status: implemented
[English](2026-07-28-web-agent-runtime-context.md) | 中文
## 问题
CLI 共享 base 配置了空的部署 personaWeb overlay 没有替换它,而 Web 启动器既未添加源码提示词段,也未添加交互界面提示词段。会话 header 会记录工作目录,供工具与持久化使用,但模型提示词既不说明该目录,也不标识 DeepSeek Harness Web GUI。因此当用户提出「修改这个页面的主题」之类的请求时即使用户指的是承载当前会话的 GUIagent 也只能在所选项目中搜索一个未明确说明的页面。
## 决策
`apps/cli/config/web.cordis.yml` 这份 Web无头共享 overlay 提供一段简洁的编码 agent persona其中包含解析后的 `{{model}}` 与会话 `{{cwd}}``dsh web` 还会根据启动器模块的 URL 解析 harness checkout安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。启动器会在挂载配置树前注册这项设置;因此,它的 `systemPrompt` 注入会在 agent loop智能体循环等后续提示词消费方激活并发出 request header 之前安装这两个提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。
Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文使模型能够识别产品但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。
## 验证
聚焦启动顺序的测试会注册一个后续的 `systemPrompt` 消费方,并证明该消费方首次激活时就能观察到启动器的两个提示词段。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay注册与 `dsh web` 相同的启动器上下文,并通过 HTTPSSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。
## 考虑过的替代方案
**每次提示词都发送 URL、DOM 或截图。** 本次故障只需要稳定的产品定位;当前根 URL 无法标识所选组件,消息契约中也不存在视觉捕获内容。添加动态页面状态需要另行设计可记录的模型输入,不属于本次修复的隐含范围。
**要求会话 Workspace 必须是 harness checkout。** Workspace cwd 是用户任务的目标,可以合理地指向空项目或其他仓库。将其与应用源码位置混为一谈会破坏这一边界,并且仍无法消除已安装版本或外部启动会话中的歧义。
**把 Web 文案放入全局 harness 身份。** `dsh-system-prompt` 还服务于 TUI、ACP、SDK 和不在浏览器中运行的自定义部署。该界面事实应由组装 Web 应用负责。
**为所有 CLI 界面修改现有源码位置提示词段。** TUI 也复用源码位置提示词段,而该段只陈述 checkout 事实。单独保留 Web 界面定位可以维持这份可复用契约,避免错误地告诉无头或终端 agent 它们正处于浏览器中。
## 影响
Web 请求会增加一段较短且稳定的提示词前缀部署此变更时模型提供方的前缀缓存可能失效一次。agent 可以区分 GUI 源码 checkout 与所选 Workspace并且无需再经过一轮澄清即可解析对当前应用的一般指代。对特定视觉状态的指代仍受「无 DOM无路由无截图」这一显式边界约束必要时仍需用户提供路径、描述或附件。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
2026-07-28-web-gui-feedback-loop.md: 039d2aebeeef903d10838a46b48e5172f0195126
2026-07-28-web-gui-feedback-loop.zh.md: 34b6b26d4ce7c7e194e641536fffc503c013188b

View File

@@ -0,0 +1,39 @@
# Agent Note: Web GUI changes close the loop on the existing URL
Status: implemented
English | [中文](2026-07-28-web-gui-feedback-loop.zh.md)
## Problem
The Web agent could identify neither the GUI hosting its session nor the URL the user was viewing. The [runtime-context decision](2026-07-28-web-agent-runtime-context.md) supplies the first fact, but a GUI edit still had no executable acceptance target: source edits, artifact builds, a listening process, and the user's existing page were unrelated observations. Repository affordances made a wrong substitute look valid because `apps/web/package.json` exposed `vite` as its `dev` script and bare Vite returned HTTP 200 even though it could not inject `window.__DSH_BOOT__`.
The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md) owns the event-log timeline and why the original checks accepted the wrong page, process, and port.
## Decision
`dsh web` publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address.
The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked.
The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged.
No server restart or replacement is required merely because static artifacts changed. The host reads `index.html` and static assets on each request, while client bundles are also served from their current files with `no-cache`; a refresh of the existing URL is therefore the acceptance path after the relevant shell and plugin bundles are rebuilt. Starting a separate server proves only that a separate server works. If the user explicitly requests another long-running server, the existing managed background-task contract owns its lifecycle and completion notices; shell `&` is not an alternative lifecycle.
## Verification
The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement.
## Alternatives considered
**Extend only the system prompt.** Rejected because it would leave the target unavailable to tools, preserve the misleading bare-Vite path, and fail to prove how an existing process observes rebuilt artifacts.
**Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail.
**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition.
**Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive.
## Consequences
Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one.

View File

@@ -0,0 +1,39 @@
# Agent Note: Web GUI 改动在现有 URL 上闭环
Status: implemented
[English](2026-07-28-web-gui-feedback-loop.md) | 中文
## 问题
Web agent智能体既无法识别承载当前会话的 GUI也不知道用户正在查看哪个 URL。[运行时上下文决策](2026-07-28-web-agent-runtime-context.md)提供前一项事实,但 GUI 编辑仍然没有可执行的验收目标:源码编辑、产物构建、监听中的进程与用户已打开的页面只是互不关联的观察结果。仓库提供的入口让错误的替代方案显得合理,因为 `apps/web/package.json``vite` 暴露为 `dev` 脚本,而裸 Vite 即使无法注入 `window.__DSH_BOOT__`,仍会返回 HTTP 200。
[事故复盘](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md)集中记录事件日志时间线,并解释原有检查为何会接受错误的页面、进程和端口。
## 决策
`dsh web` 发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI并给出 URL`DSH_WEB_URL``DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界也不声称局域网别名等于浏览器中的实际地址。
按模式区分的提示词让 agent 而非用户负责隐藏的启动契约。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明`dsh web --dev` 只会启用 HMR热模块替换接收端客户端插件要自动重新加载还需要在同一检出中运行 `pnpm run dev:web` 监听进程agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。
静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器则现有受管后台任务契约负责其生命周期和完成通知shell `&` 不能替代这套生命周期机制。
## 验证
无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL``$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发契约。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
## 考虑过的替代方案
**仅扩展系统提示词。** 不予采纳,因为这样会让工具仍然无法获得目标,保留具有误导性的裸 Vite 路径,并且无法证明现有进程如何观察重新构建的产物。
**删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。
**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合。
**每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。
## 影响
Web 提示词会增加一个动态 URL 和模式段落因此模型提供方的前缀复用会随绑定端口和模式变化。Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱开发者应改用完整宿主或构建模式。作为交换GUI 工作有了一个可由机制观察的唯一目标agent 可以向用户说明实际承载其会话的进程究竟如何更新不受支持的启动路径也会在出现白屏前失败。URL模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。

View File

@@ -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-07-20-dsh-cli-personal-config.md
2026-07-20-dsh-cli-personal-config.md: 259c3865a9edcbc77949a9fe401af9a77e1e32c4
2026-07-20-dsh-cli-personal-config.zh.md: 8f7c15c3c683cc855c6e3b704bfde8f87d4009d2
2026-07-20-dsh-cli-personal-config.md: 1fa8cda2b34b58cc7a28b722872520b68a9b7009
2026-07-20-dsh-cli-personal-config.zh.md: e70b8914cf005e0a2e54ba2b29d3b7def84b00db

View File

@@ -12,21 +12,21 @@ A developer's own preferences — which provider and model the TUI uses, persona
Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443):
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the app with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. `pnpm run demo:tui` runs the same entry.
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The official dsh surfaces consume its two optional files; the demo bins boot their committed trees verbatim:
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim:
- `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`.
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op.
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice.
- A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip).
The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied.
The TUI and Web register the exact personal path through Cordis HMR after boot. Every add, change, or removal transactionally recomposes the full patch list through the launcher's own composition closure, so the fresh personal patches land in the same layer position they booted in. Invalid YAML or a rejected Loader candidate leaves the last good tree active and broadcasts `hmr/config-update-failed(filename, Error)`; the headless surface reads the file once at startup. The Include also re-applies its patches on committed config-file refreshes (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)).
## Alternatives considered
**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain.
**A separate `bin/dsh` wrapper owning the `dsh` name.** Rejected because `apps/cli` is the single product CLI for default TUI, headless, and Web dispatch. Two competing entrypoints would collide in `$PATH` and product identity.
**A pi-style typed settings file (`defaultProvider`/`defaultModel`/`providers`).** Rejected by the user in favor of patch semantics: the personal file is a cordis overlay over the shipped default config, not a second config vocabulary to own and translate.
@@ -38,12 +38,12 @@ Hot-reload interplay: the include re-applies its `patches` on every config re-re
## Consequences
- `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip.
- `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip.
- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics.
- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred.
- `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`.
- When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch).
- Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher.
## Testing
`packages/ui/app-boot/tests/personal-config.spec.ts` pins `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the default directory resolving from `$DSH_HOME`, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.dsh` overlay would change the booted model — the isolation, not luck.
`packages/ui/app-boot/tests/personal-config.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real dsh bin with no overlay, a personal environment and UI patch, a config-only cached repository skill, and invalid personal YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures.

View File

@@ -12,21 +12,21 @@ Status: implemented
两个耦合的部分,与 `dsh web` PR#443)提出的 `apps/` 装配层对齐:
**`dsh` CLI`apps/cli`npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web``-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树`pnpm run demo:tui` 运行同一入口。
**`dsh` CLI`apps/cli`npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout并使用 tsx 的 ESM hook 运行应用;该契约由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护`pnpm run demo:tui` 运行同一入口。
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的官方界面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
- `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include``PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config``insert` 追加配置项,未匹配的 id 静默不执行任何操作。
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include``PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config``insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。
- 文件缺失即无 overlay文件存在但不可读、不可解析或非数组则在启动时抛出配置错误响亮失败绝不静默跳过
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
与热重载的交互include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效
TUI 和 Web 启动后通过 Cordis HMR热模块替换注册确切的个人配置路径。每次新增、变更或移除都会以事务方式通过启动器自己的组合闭包重新组合完整 patch 列表,因此新的个人 patch 落在启动时相同的层次位置。YAML 无效或 Loader 候选被拒时,最后一个可用树保持活动状态,并广播 `hmr/config-update-failed(filename, Error)`无头界面只在启动时读取该文件。Include 在已提交配置文件刷新时也会重新应用其 patch见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md))。
## Alternatives considered
**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web``-p`)的 `dsh` CLI并且默认位空缺。两个相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上
**另设一个 `bin/dsh` 包装脚本并由其占用 `dsh` 名称** 否决,因为 `apps/cli` 是统一的产品 CLI负责分发默认 TUI、无头和 Web 界面。两个相竞争的入口会在 `$PATH` 和产品身份上冲突。
**pi 风格的类型化设置文件(`defaultProvider`/`defaultModel`/`providers`)。** 用户否决,选择补丁语义:个人文件是叠加在随仓库提供的默认配置之上的 cordis overlay而不是需要另行拥有和翻译的第二套配置词汇。
@@ -38,12 +38,12 @@ PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录
## Consequences
- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。
- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`,无需修改 checkout即可应用个人提供方模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。
- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)。
- 个人补丁只在被启动文件自身的树里解析 id因此嵌套 include 的 overlayCode Mode不会被个性化这些叶子的实际运行等价性暂缓。
- `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`
- PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)
- 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher
## Testing
`packages/ui/app-boot/tests/personal-config.spec.ts` 固定 `!!js` 的保留与经真实启动树的端到端插值、insert 配置项、默认目录从 `$DSH_HOME` 解析、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气
`packages/ui/app-boot/tests/personal-config.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 启动真实 dsh bin,覆盖无 overlay、个人环境与 UI patch、纯配置的缓存 repository skill以及无效个人 YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md
2026-07-30-config-only-repository-plugins.md: 2057125fc78596dd4e5eb153f77b828f83d9ceff
2026-07-30-config-only-repository-plugins.zh.md: 6e741b46be716e21a11c2f508fb5e6d0505c76d0

View File

@@ -0,0 +1,50 @@
# Agent Note: Config-only repository Plugins for standalone dsh
Status: implemented
English | [中文](2026-07-30-config-only-repository-plugins.zh.md)
## Problem
A standalone `dsh` user has no developer-owned SDK project whose `package.json`, lockfile, and `cordis.yml` can carry an external Plugin dependency. Requiring an install command or another state file would make “use this repository” a multi-step workflow, while loading arbitrary repository code would bypass the restricted [static repository Plugin format](../architecture/2026-07-30-static-repository-plugin-format.md). Long-running TUI and Web processes also need a failed edit to preserve their usable Plugin generation and tell observers why the candidate was rejected.
## Decision
The shipped TUI and Web/headless `cordis.yml` trees contain an empty `repository-plugins` entry. A user changes only `$DSH_HOME/config.yaml`, replacing that entry's config with a `repositories` list. Each item uses `github:owner/repository#<ref>` plus an optional `&path:/.../.dsh-plugin`; omission selects `/.dsh-plugin`. An explicit ref is mandatory, paths are absolute within the repository and end in `.dsh-plugin`, and duplicate normalized specifiers reject before installation. There is no marketplace, discovery index, HTTPS URL vocabulary, or implicit latest generation.
`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, runs its ordinary lifecycle including `prepare`, and atomically publishes the exact specifier. The DSH host imports only the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber, so skills and MCP retain the owners, failure contracts, and teardown defined by the format package.
## Live update and failure
`dsh-app-boot` mounts the root Include through one helper that retains its exact Loader `Entry`. The TUI and Web register `$DSH_HOME/config.yaml` through Cordis HMR; headless reads the same file at startup without retaining a watcher. A watcher update rebuilds the Include patch list as immutable app-owned patches followed by the newly parsed personal patches, so Web-generated port, session-root, trust, and frontend values survive every personal edit unless a later personal patch deliberately replaces that row.
Cordis serializes and coalesces exact-path changes. Include and Loader reconcile a candidate transactionally: success commits the new source list, while fetch, preparation, wrapper import, format, or child-Plugin failure rejects the candidate and retains or restores the last good tree. HMR normalizes the caught value to `Error`, logs it, and broadcasts the parallel `hmr/config-update-failed(filename, error)` event; observer failures cannot break refresh processing. MCP transport connection failure remains the existing MCP client's contained successful-Plugin/no-tools result and therefore is not reclassified as a config-update failure.
An identical specifier permanently reuses its cache generation. HMR watches configuration, not cached repository code; the user changes the ref, path, or source list to select another generation.
## Trust boundary
Configuring a repository authorizes package-manager lifecycle code from that repository and its dependencies to run with the user's filesystem authority. The pnpm child removes ambient environment variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, but this is credential-exposure reduction rather than a sandbox. The fixed runtime wrapper prevents repository-authored Cordis entry points from becoming part of the supported Plugin format; it does not make package preparation untrusted-safe.
## Alternatives considered
**Require an SDK project dependency.** Rejected for the standalone app path because there is no project manifest to edit. Developer-owned SDK projects keep their native package-manager workflow as a separate capability.
**Add a `dsh plugin install` command and installation database.** Rejected because the personal Loader overlay already owns machine-local composition. A second mutation interface and durable registry would duplicate config identity and rollback.
**Resolve repositories directly in the DSH package.** Rejected because Git transport, GitHub subpackage selection, lifecycle execution, and content storage belong to pnpm and the generic Loader cache, not a DSH-specific adapter.
**Watch cache contents or refresh the same ref automatically.** Rejected because one config value must identify one immutable prepared generation. Background remote resolution would change executable code without a config diff and make rollback depend on mutable remote state.
**Broadcast an `unknown` failure payload.** Rejected at the HMR boundary. JavaScript may throw any value internally, but the public event always receives a normalized `Error`, giving observers one stable contract while retaining the original value as its cause when needed.
## Consequences
- A repository that adds `.dsh-plugin/package.json` can reach standalone users through one personal-config edit without changing its existing skills or `.mcp.json` layout.
- Long-running apps can add, replace, or remove configured generations without restart; rejected candidates retain the last good runtime and produce one generic Cordis event.
- First use may require Git/network access and preparation time. Later starts reuse the exact prepared cache; old generations consume disk until a separate cache-management policy exists.
- Only skills and common MCP definitions are supported. Hooks, commands, agents, apps, arbitrary Cordis code, compatibility shims, OAuth-bearing MCP definitions, and marketplaces remain intentionally absent.
## Testing
Repository-package tests pin source normalization, default and nested `.dsh-plugin` paths, cache-root resolution, duplicate rejection, prepared-wrapper loading, and disposal. App-boot tests drive exact-path add, two failure classes, recovery, removal, failure events, and generated-patch preservation through the real HMR/Include/Loader path. A keyless PTY smoke boots the shipped `dsh` composition from personal config alone and invokes a skill from a seeded immutable cache generation.

View File

@@ -0,0 +1,50 @@
# Agent Note: 仅凭配置为独立 dsh 接入仓库插件
Status: implemented
[English](2026-07-30-config-only-repository-plugins.md) | 中文
## 问题
独立 `dsh` 用户没有开发者自有的 SDK 项目,无法由其 `package.json`、lockfile 和 `cordis.yml` 承载外部插件依赖。若要求运行安装命令或维护另一份状态文件,「使用这个仓库」就会变成多步骤流程;若加载任意仓库代码,又会绕过受限的[静态仓库插件格式](../architecture/2026-07-30-static-repository-plugin-format.md)。长时间运行的 TUI 和 Web 进程还必须在编辑失败时保留仍可使用的插件版本,并向观察者说明候选配置被拒绝的原因。
## 决策
已交付的 TUI 和 Web无头 `cordis.yml` 配置树包含一个空的 `repository-plugins` 配置项。用户只需修改 `$DSH_HOME/config.yaml`,用 `repositories` 列表替换该配置项的配置。每一项采用 `github:owner/repository#<ref>`,并可追加 `&path:/.../.dsh-plugin`;省略时选择 `/.dsh-plugin`。必须显式指定 ref路径是仓库内的绝对路径并以 `.dsh-plugin` 结尾重复的规范化说明符在安装前即被拒绝。不提供插件市场、发现索引、HTTPS URL 词汇或隐式的最新版本。
`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins``cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择配置的仓库子包package运行包括 `prepare` 在内的普通生命周期并原子发布该精确说明符。DSH 宿主只导入生成的 `dsh-plugin.mjs` 包装模块并将其挂载为子 fiber因此 skill技能与 MCP 仍沿用格式包定义的所有者、失败契约和清理行为。
## 实时更新与失败
`dsh-app-boot` 通过一个辅助函数挂载根 Include并保留其确切的 Loader `Entry`。TUI 和 Web 通过 Cordis HMR热模块替换注册 `$DSH_HOME/config.yaml`;无头界面在启动时读取同一文件,但不保留监视器。监视器更新会重新构建 Include 补丁列表先放置不可变的应用自有补丁再放置新解析的个人补丁。因此Web 生成的端口、会话根目录、信任和前端值会在每次个人编辑后保留,除非后续个人补丁有意替换相应配置项。
Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置成功时提交新源列表拉取、准备、包装模块导入、格式或子插件失败时拒绝候选配置并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播并行的 `hmr/config-update-failed(filename, error)` 事件观察者失败不会中断刷新处理。MCP 传输连接失败仍沿用现有 MCP 客户端所收束的「插件成功加载但无工具」结果,因此不会被重新分类为配置更新失败。
相同说明符会永久复用同一个缓存版本。HMR 监视配置,而非已缓存的仓库代码;用户必须改变 ref、路径或源列表才能选择另一个版本。
## 信任边界
配置仓库即授权该仓库及其依赖中的包管理器生命周期代码以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY``PASSWORD``SECRET``TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。固定的运行时包装模块会阻止仓库作者提供的 Cordis 入口成为受支持插件格式的一部分;它无法让包准备过程安全执行不受信任的代码。
## 考虑过的替代方案
**要求声明 SDK 项目依赖。** 独立应用路径没有可编辑的项目 manifest元数据清单因此否决。开发者自有的 SDK 项目仍可使用原生包管理器工作流,这是一项独立能力。
**新增 `dsh plugin install` 命令和安装数据库。** 否决,因为个人 Loader 覆盖层已经负责机器本地组合。第二个变更接口和持久注册表会重复配置身份与回滚机制。
**由 DSH 包直接解析仓库。** 否决,因为 Git 传输、GitHub 子包选择、生命周期执行和内容存储属于 pnpm 与通用 Loader 缓存,而非 DSH 专用适配器。
**监视缓存内容,或自动刷新相同 ref。** 否决,因为一个配置值必须标识一个不可变的已准备版本。后台远端解析会在没有配置差异的情况下改变可执行代码,并使回滚依赖可变的远端状态。
**广播 `unknown` 失败载荷。** 在 HMR 边界否决。JavaScript 内部可以抛出任意值,但公开事件始终接收规范化的 `Error`,从而为观察者提供稳定契约,并在需要时把原始值保留为错误原因。
## 后果
- 添加 `.dsh-plugin/package.json` 的仓库只需一次个人配置编辑即可供独立用户使用,无需改变现有 skill 或 `.mcp.json` 布局。
- 长时间运行的应用无需重启即可新增、替换或移除已配置版本;被拒绝的候选配置会保留最后一个可用运行时,并产生一个通用 Cordis 事件。
- 首次使用可能需要 Git网络访问和准备时间。后续启动会复用这份精确的已准备缓存在另行制定缓存管理政策之前旧版本会持续占用磁盘空间。
- 仅支持 skill 和通用 MCP 定义。钩子、命令、agent智能体、应用、任意 Cordis 代码、兼容 shim、带 OAuth 的 MCP 定义和插件市场均有意不提供。
## 测试
仓库包测试固定源规范化、默认和嵌套 `.dsh-plugin` 路径、缓存根解析、重复项拒绝、已准备包装模块加载及资源释放。App-boot 测试通过真实 HMRIncludeLoader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。

View File

@@ -1,6 +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
2026-06-11-vendor-cordis-as-source.md: ae6f5438c5817c61a549d9edb2041d538fbcebe6
2026-06-11-vendor-cordis-as-source.zh.md: 8d6f0e39d53e1c85eaaa50c4c4bf1d9ef648d953
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md
2026-06-11-vendor-cordis-as-source.md: ccc1289c8a0feadc08d80a3b6e8dc674c1b87bc4
2026-06-11-vendor-cordis-as-source.zh.md: 9abea504d677ab71c62d24e2e4d9dfde4315802c

View File

@@ -10,7 +10,7 @@ DeepSeek Harness SDK is built on the Cordis framework. Cordis core was at 4.0.0-
## Decision
Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logger-console) and the cordiverse foundation libraries (cosmokit, schemastery) into `vendor/` as source, flattened, keeping their original npm names so workspace resolution is transparent. Truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm.
Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logger-console) and the cordiverse foundation libraries (cosmokit, schemastery) into `vendor/` as source, flattened, keeping their original npm names so workspace resolution is transparent. `pnpm-workspace.yaml` sets `linkWorkspacePackages: true`, so matching upstream semver ranges resolve these pinned workspaces in both source and built-artifact execution. Truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm.
`vendor/README.md` is the manifest: upstream repo + commit SHA per package and an exhaustive local-modification log. A pre-commit guard (`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that don't update the manifest in the same commit.
@@ -22,6 +22,7 @@ Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logge
## Consequences
- The harness fully owns its framework layer: auditable, patchable, pinned — an RC upstream can't break us, and we can fix framework bugs in-tree.
- Built packages execute the same vendored Cordis generation as source tests; removing workspace linking would silently substitute npm copies behind unchanged package names.
- Upstream sync is manual (documented procedure in the manifest). The modification log keeps the diff surface known.
- Vendored packages keep upstream code style; lint/strictness gates exclude them (their tsconfigs relax our newer compiler flags locally).
- One local patch exists from day one: hmr's locale-YAML imports removed (the runtime YAML import hook isn't vendored).

View File

@@ -10,7 +10,7 @@ DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时Cordis
## 决策
将所需的 Cordis 包core、loader、include、group、timer、hmr、logger-console与 cordiverse 基础库cosmokit、schemastery以源码形式复制到 `vendor/`,扁平化放置,保留其原始 npm 包名以实现透明的 workspace 解析。真正的第三方依赖js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取。
将所需的 Cordis 包core、loader、include、group、timer、hmr、logger-console与 cordiverse 基础库cosmokit、schemastery以源码形式复制到 `vendor/`,扁平化放置,保留其原始 npm 包名以实现透明的 workspace 解析。`pnpm-workspace.yaml` 设置 `linkWorkspacePackages: true`,所以只要上游 semver 范围匹配,无论以源码执行还是以构建产物执行,依赖都会解析到这些固定版本的 workspace。真正的第三方依赖js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取。
`vendor/README.md` 是 manifest元数据清单记录每个包package的上游仓库 + commit SHA以及一份详尽的本地修改日志。pre-commit 守卫(`scripts/check-vendor-manifest.sh`)会拒绝未在同一次提交中更新 manifest 的 vendor 源码变更。
@@ -22,6 +22,7 @@ DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时Cordis
## 后果
- harness 完全持有其框架层:可审计、可打补丁、版本锁定。上游 RC 无法影响我们,框架 bug 可以在仓库内直接修复。
- 构建后的包与源码测试执行的是同一版收录的 Cordis移除 workspace 链接后,构建后的包会在包名不变的情况下静默改用 npm 副本。
- 上游同步是手动操作(流程记录在 manifest 中)。修改日志使 diff 范围始终可知。
- 收录的包保留上游代码风格lint 与严格性门禁将其排除(它们的 tsconfig 在本地放宽了我们较新的编译器选项)。
- 从第一天起就有一个本地补丁:移除了 hmr 的 locale-YAML 导入(运行时 YAML 导入钩子未被收录)。

View File

@@ -1,6 +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
2026-07-26-incremental-pr-base-retargeting.md: e2097ac4c32a926c8c0271df19dbc9796d0ed19d
2026-07-26-incremental-pr-base-retargeting.zh.md: a6c94b66732b6c037fee1b0726b31ecb6f3b48c5
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md
2026-07-26-incremental-pr-base-retargeting.md: b2e644d99877b4214b5a6edb2775b3962e6b7da2
2026-07-26-incremental-pr-base-retargeting.zh.md: 5014fef644f9907c4d16a9a2a3767d6f55633688

View File

@@ -10,9 +10,9 @@ A PR base can advance while its current tip is being merged into the PR branch.
## Decision
Each observed base tip gets its own merge checkpoint. If the base advances during the work, finish and validate the merge already in progress, commit it, and push it when the task authorizes a push. Only then fetch and merge the newer base in a separate merge commit. Never abandon, amend, rebase, or otherwise rewrite the earlier work.
When merge-forward is chosen, each observed base tip gets its own merge checkpoint. If the base advances during the work, finish and validate the merge already in progress, commit it, and push it when the task authorizes a push. Only then fetch and merge the newer base in a separate merge commit. Do not abandon or rewrite a checkpoint within that merge-forward sequence.
The root [AGENTS.md](../../../../AGENTS.md) states the standing order. The [stacked-PR landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) applies it while retargeting dependent PRs, and the [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns merging fixes down a stack.
The [native-stack and optional-rebase decision](2026-08-02-native-github-stacks-and-optional-rebases.md) also permits a lease-protected rebase for standalone or stacked PRs, including after review. This note owns the merge-forward path only. The [stacked-PR landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) selects either history under the root [AGENTS.md](../../../../AGENTS.md), and the [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns propagating fixes through dependent layers.
## Alternatives considered

View File

@@ -10,9 +10,9 @@ Status: implemented
## 决策
每次观察到的 base 分支顶端提交都保留为独立的合并检查点。如果处理期间 base 分支继续前移,先完成并验证正在进行的合并,再将其提交;任务授权推送时,还要完成推送。完成这些步骤后,才能拉取较新的 base并通过单独的合并提交将其合入。绝不放弃先前工作,也不通过 amend、rebase 或其他方式重写它
选择 merge-forward 时,每次观察到的 base 分支顶端提交都保留为独立的合并检查点。如果处理期间 base 分支继续前移,先完成并验证正在进行的合并,再将其提交;任务授权推送时,还要完成推送。完成这些步骤后,才能拉取较新的 base并通过单独的合并提交将其合入。在这条 merge-forward 序列中,不得放弃或重写任何检查点
根 [AGENTS.md](../../../../AGENTS.md) 规定了这项常设指令。[堆叠 PR 落地 skill技能](../../../skills/dsh-merging-stacked-prs/SKILL.md)在调整依赖 PR 的 base 时执行这一规则[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)则负责说明如何将修复沿堆叠向下合并
[原生堆叠与可选 rebase 决策](2026-08-02-native-github-stacks-and-optional-rebases.md)也允许独立或堆叠 PR 使用受 lease 保护的 rebase评审后同样如此。本文只负责 merge-forward 路径。[堆叠 PR 落地 skill技能](../../../skills/dsh-merging-stacked-prs/SKILL.md)根据根 [AGENTS.md](../../../../AGENTS.md) 选择其中一种历史更新方式[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)则负责说明如何在依赖层之间传播修复
## 曾考虑的替代方案

View File

@@ -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/process/2026-08-02-native-github-stacks-and-optional-rebases.md
2026-08-02-native-github-stacks-and-optional-rebases.md: a349ed18a27ab006384310e4318f057dbf8873b1
2026-08-02-native-github-stacks-and-optional-rebases.zh.md: 0205eb475bfe951f8382d61bf19df988027afb13

View File

@@ -0,0 +1,43 @@
# Agent Note: Native GitHub stacks and optional PR rebases
Status: implemented
English | [中文](2026-08-02-native-github-stacks-and-optional-rebases.zh.md)
## Problem
A dependent PR chain represented only by base branches has no official stack identity. Landing it requires manually merging one PR at a time, preserving intermediate branches, retargeting every child, and reconstructing whether the chain survived. GitHub's native stacked-PR feature instead carries the order, applies trunk rules and CI to every layer, and owns bottom-up merges and retargeting.
A blanket prohibition on rewriting reviewed branches also excludes the native `gh stack` synchronization workflow, whose cascading rebase updates each active layer and publishes it with lease protection. Applying that prohibition only outside stacks would give standalone and stacked PRs inconsistent history choices.
## Decision
Every same-repository chain of two or more dependent PRs uses GitHub's official stack object before landing. Live `PullRequest.stack` and `stackEntry.position` fields are authoritative. An unstacked chain whose PRs have one author is linked automatically in bottom-to-top order with `gh stack link`; mixed or unavailable authors require user confirmation. Missing native support and cross-fork chains hard-stop. Existing membership in conflicting stacks or an official order that disagrees with the branch topology requires user direction before any stack is dissolved or rebuilt.
"Land the stack" merges the complete official stack through `gh stack merge <stack-number> --yes --merge`. A partial landing requires an explicit boundary PR and merges the bottom prefix through that PR. The workflow never falls back to per-PR `gh pr merge` and manual retargeting. A direct native merge is all-or-nothing; a merge queue may process the selected PRs in separate groups, so every selected PR must independently reach `MERGED` before the landing is complete.
Merge-forward and rebase are both allowed refresh histories for standalone and officially stacked PRs, including after review. A remote history rewrite uses an exact lease or the lease-protected `gh stack` push path and aborts if the remote moved; raw `--force` is forbidden. The [incremental base-retargeting decision](2026-07-26-incremental-pr-base-retargeting.md) remains the owner of the merge-forward option.
Relevant checks normally run before publication. `gh stack sync` is the explicit exception because it fetches, cascade-rebases, and pushes as one operation: every rewritten layer is validated immediately afterward, and no affected PR merges until that evidence passes. After any rewritten push, current heads, unresolved review threads, approvals, mergeability, and checks are re-audited because earlier commit OIDs and inline anchors may be outdated.
## Verification
The [stack landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) verifies native support, same-repository branches, live authors, official membership and order, merge range, and final merged state. The [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) keeps fixes on their introducing layer and covers both propagation histories. The [pre-push workflow](../../../skills/dsh-pre-push-checks/SKILL.md) owns lease protection and immediate post-sync evidence.
## Alternatives considered
**Keep branch chains as the only stack representation.** This preserves the manual procedure but gives GitHub no stack object through which to show order, enforce trunk rules across every layer, or merge a range atomically.
**Adopt native stacks while forbidding their rebase commands after review.** This keeps commit OIDs stable but disables the official synchronization path when a stack is under active review and leaves standalone PRs under a different policy.
**Require rebase for every PR refresh.** A linear history is useful, but merge checkpoints remain a valid choice when preserving completed conflict resolution and its recovery point matters more than compact history.
**Automatically dissolve conflicting stacks.** This would make local branch inference override shared GitHub metadata and could disturb PRs or authors outside the requested chain; merged and queued entries cannot always be removed.
## Consequences
- Reviewers and automation receive GitHub's stack map, stack-wide rules, CI, and native merge state.
- A same-author legacy chain becomes official without an extra prompt, while mixed ownership and conflicting metadata retain a human decision boundary.
- Rebases can invalidate commit hashes, approvals, or comment anchors after review, so every rewritten push carries a live review and check audit.
- `gh stack sync` can briefly publish code whose local evidence is pending; the affected PRs remain blocked from merging until immediate post-sync validation passes.
- Merge-forward remains available and preserves completed checkpoints, at the cost of additional merge commits.

View File

@@ -0,0 +1,43 @@
# Agent Note: GitHub 原生堆叠与可选 PR rebase
Status: implemented
[English](2026-08-02-native-github-stacks-and-optional-rebases.md) | 中文
## 问题
仅以 base 分支表示的依赖 PRPull Request链没有官方的堆叠身份。要让它落地就必须逐个手动合并 PR、保留中间分支、调整每个子 PR 的 base并重新查证这条链是否仍然完整。GitHub 原生的堆叠 PR 功能则会承载顺序,对每一层应用 trunk 规则和 CI并负责自底向上的合并与 base 调整。
一概禁止改写已评审分支,也会排除原生的 `gh stack` 同步工作流:该工作流通过级联 rebase 更新每个活跃层,并在 lease 保护下发布。如果只在堆叠之外实施这项禁令,就会让独立 PR 和堆叠 PR 面临不一致的历史选择。
## 决策
同一仓库内由两个或更多个相互依赖的 PR 组成的每条链,在落地前都必须使用 GitHub 的官方 stack 对象。以实时 `PullRequest.stack``stackEntry.position` 字段为权威依据。对于尚未形成官方堆叠且所有 PR 作者相同的链,系统使用 `gh stack link` 按自底向上的顺序自动关联;作者不一或作者信息不可用时,必须取得用户确认。缺少原生支持或跨 fork 的链会使流程硬性停止。如果现有成员属于相互冲突的堆叠,或者官方顺序与分支拓扑不一致,则在解散或重建任何堆叠之前都必须取得用户指示。
「落地堆叠」通过 `gh stack merge <stack-number> --yes --merge` 合并整个官方堆叠。部分落地需要明确指定边界 PR并合并从底部到该 PR 的前缀。工作流绝不回退到逐个执行 `gh pr merge` 和手动调整 base。原生直接合并要么全部成功要么全部不合并合并队列可能分组处理所选 PR因此只有每个所选 PR 都分别达到 `MERGED`,落地才算完成。
merge-forward 和 rebase 都可以作为独立 PR 与官方堆叠 PR 的历史刷新方式,包括评审后。改写远端历史时,必须使用精确 lease 或受 lease 保护的 `gh stack` 推送路径;如果远端已经前移,操作必须中止。禁止直接使用 `--force`。[增量更新 base 的决策](2026-07-26-incremental-pr-base-retargeting.md)仍负责 merge-forward 选项。
相关检查通常在发布前运行。`gh stack sync` 是明确的例外,因为它在一次操作中完成获取、级联 rebase 和推送:随后立即验证每个已改写的层;这些验证通过前,不得合并任何受影响的 PR。每次改写推送后都要重新审计当前 head、未解决的评审线程、批准状态、可合并性和检查结果因为先前的 commit OID 和内联锚点可能已经过时。
## 验证
[堆叠落地 skill技能](../../../skills/dsh-merging-stacked-prs/SKILL.md)验证原生支持、同仓库分支、实时作者信息、官方成员关系与顺序、合并范围以及最终合并状态。[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)让修复留在引入问题的层,并涵盖两种用于传播修复的历史策略。[推送前工作流](../../../skills/dsh-pre-push-checks/SKILL.md)负责 lease 保护和同步后立即验证所得的证据。
## 曾考虑的替代方案
**仅以分支链表示堆叠。** 这种做法保留手动流程,但 GitHub 没有 stack 对象可用于展示顺序、对每一层执行 trunk 规则或以原子操作合并整个范围。
**采用原生堆叠,但禁止在评审后使用其 rebase 命令。** 这会保持 commit OID 稳定,但也会在堆叠正在接受评审时禁用官方同步路径,并让独立 PR 遵循不同的政策。
**要求每次刷新 PR 都使用 rebase。** 线性历史很有价值,但当保存已经完成的冲突解决及其恢复点比紧凑历史更重要时,合并检查点仍然是有效选择。
**自动解散相互冲突的堆叠。** 这会让本地分支推断凌驾于共享的 GitHub 元数据之上,并可能干扰所请求链之外的 PR 或作者;已经合并或进入队列的条目不一定都能移除。
## 后果
- 评审者和自动化会获得 GitHub 的堆叠图、覆盖整个堆叠的规则、CI 和原生合并状态。
- 同一作者的遗留链无需额外询问即可成为官方堆叠;链由多名作者共同拥有或元数据发生冲突时,仍保留人工决策边界。
- 评审后rebase 可能使 commit hash、批准状态或评论锚点失效因此每次改写推送后都要对实时评审状态和检查结果进行审计。
- `gh stack sync` 可能短暂发布本地验证仍待完成的代码;受影响的 PR 在同步后立即验证通过前仍禁止合并。
- merge-forward 仍然可用,并以增加合并提交为代价保留已完成的检查点。

View File

@@ -1,6 +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
2026-07-17-sdk-follow-up-capabilities.md: 0f3ada6bdbb4ce933d14602cf59be9a51640e61c
2026-07-17-sdk-follow-up-capabilities.zh.md: d0d0b3e6bcdf192e64f003dc9f6e90cc2bdb060b
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md
2026-07-17-sdk-follow-up-capabilities.md: 88d5d2f9bd1ce01c20177bcaee5bbe6b434bb978
2026-07-17-sdk-follow-up-capabilities.zh.md: 998b7ec3cfddafe40537908fb61aa6d7e6f90418

View File

@@ -47,6 +47,8 @@ The repository ships a thin `SKILL.md` that teaches an agent to construct the st
The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern.
This proposal concerns dependencies of developer-owned SDK projects. Standalone app repository caching, its bundled-pnpm policy, and its explicit preparation trust boundary are owned by the [package-manager-native repository cache](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md).
## Launcher telemetry
### Consent and collection

View File

@@ -47,6 +47,8 @@ Create 和 config 使用相同的功能计划形状。create 通过上述命令
包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。
本提案只涉及开发者自有 SDK 工程的依赖。独立应用的仓库缓存、随应用捆绑 pnpm 的政策和显式的准备流程信任边界,均由[包管理器原生仓库缓存](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md)负责。
## Launcher 遥测
### Consent 与采集

View File

@@ -1,53 +1,127 @@
---
name: dsh-merging-stacked-prs
description: Use when landing a stack of dependent GitHub PRs (A ← B ← C, where each bases on the one below) onto master — merging more than one PR in a chain, merging a PR whose base is another open PR's branch, or whenever a request mentions "stacked PRs", "PR stack", "dependent PRs", "base branch", or merging several related PRs in sequence. Critical because deleting a base branch mid-chain auto-closes the open PR that bases on it — get the order wrong and you silently close unmerged work.
description: Use when landing a stack of dependent GitHub PRs (A ← B ← C, where each bases on the one below) onto master, merging a PR whose base is another open PR's branch, or whenever a request mentions "stacked PRs", "PR stack", "dependent PRs", or merging several related PRs in sequence. Requires every same-repository dependency chain to use GitHub's official stacked-PR feature before landing so GitHub owns stack-wide rules, CI, ordering, retargeting, and merge state.
---
# Merging a stacked PR chain
# Landing an official GitHub PR stack
This skill is the landing procedure for a dependent PR stack. The standing orders it rests on — merge commits only (`gh pr merge --merge`), never rewrite a pushed branch — live in the root [AGENTS.md](../../../AGENTS.md) § Conventions; the discipline for handling review comments across a stack before it lands is the [responding-to-pr-review-on-a-stack](../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) cookbook guide.
Land dependent PRs through GitHub's native stack object and `gh stack merge`. Do not reproduce stack semantics by merging and retargeting individual PRs with `gh pr merge` and `gh pr edit`. The root [AGENTS.md](../../../AGENTS.md) owns the allowed merge-forward and rebase histories; the [stack review guide](../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns review-fix propagation.
## The hazard this prevents
## Require native stack support
On GitHub, **deleting a PR's base branch auto-closes that PR.** In a stack `A ← B ← C` (B bases on A, C bases on B), branch A is the base of PR B, and branch B is the base of PR C. So if you merge A with `--delete-branch`, GitHub closes PR B before it's merged — silently destroying the chain. The whole procedure below exists to avoid that: **merge one at a time, retarget each dependent as you go, and delete nothing until every PR has landed.**
Run `gh stack --version` before changing GitHub state. Hard-stop if the official extension or server-side stack feature is unavailable; do not fall back to the legacy manual landing procedure. GitHub stacks require every head branch to live in the same repository, so hard-stop on a cross-fork chain.
## The procedure
Use a clean dedicated worktree. Fetch current PR metadata and exact head OIDs rather than trusting branch names or an earlier report:
Given `A ← B ← C` landing on `master`:
```sh
gh pr view <pr> --json number,author,baseRefName,baseRefOid,headRefName,headRefOid,isCrossRepository,state,isDraft,reviewDecision,mergeStateStatus,statusCheckRollup
```
1. **Merge PR A into master, keeping its branch.** `gh pr merge A --merge` — no `--delete-branch`. Branch A must survive because PR B still bases on it. Before touching the next link, confirm the merge actually landed: with required checks pending or a merge queue, `gh pr merge` may only enable auto-merge and return early, so wait until `gh pr view A --json state` reports `MERGED`. This applies after every merge in the stack.
Query `PullRequest.stack` and `stackEntry.position` for at least one PR in each apparent chain; this official GitHub object, not base-branch inference alone, is the stack-membership authority. Paginate `entries` when `size` exceeds the returned page:
2. **Retarget PR B, refresh it, then merge it — keeping its branch.**
- `gh pr edit B --base master` (now that A is in master, B's base becomes master).
- Merge the new master *into* branch B: check out B, `git fetch origin`, `git merge origin/master` — merge `origin/master`, not local `master`, because `gh pr merge` updated only GitHub and the local branch is stale — resolve any conflicts here, and push. This makes B current and surfaces conflicts in the working branch where they can be tested — not as a surprise at the GitHub merge.
- If `origin/master` moves during that work, finish and push the in-progress merge, then fetch and merge the newer tip in a separate commit. Never abandon or rewrite the earlier work ([rationale](../../notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)).
- `gh pr merge B --merge` — still no `--delete-branch` (PR C bases on branch B).
```sh
gh api graphql -F owner=<owner> -F name=<repo> -F number=<pr> -f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
number
author { login }
baseRefName
headRefName
stackEntry { position }
stack {
number
baseRefName
size
entries(first: 100) {
nodes {
position
pullRequest { number author { login } baseRefName headRefName state isDraft }
}
}
}
}
}
}'
```
3. **Retarget PR C, refresh it, then merge it — keeping its branch.** Same steps: `gh pr edit C --base master`, fetch and merge `origin/master` into branch C, resolve conflicts there and push, then `gh pr merge C --merge` without `--delete-branch`.
Establish the expected bottom-to-top order from the live PR bases: the bottom targets the trunk, and each higher PR targets the head branch immediately below it.
4. **Only after every PR (A, B, C) is merged, delete the branches** — local and remote, for all of A, B, C.
## Link missing stack members
## Why "merge new master into the dependent before merging it"
First compare any existing stack entries with the expected chain. One existing stack may contain an order-preserving subset of the requested chain; multiple stack numbers, an unexpected entry, or a conflicting order requires user direction before any mutation.
Each retarget step merges the freshly-updated master back into the dependent branch *before* merging the PR. This keeps each PR's diff clean (it only shows that PR's own changes, not the parent's) and forces conflicts to surface in the working branch, where you can build and test the resolution — instead of letting GitHub attempt a blind merge that may conflict or quietly mis-resolve.
When any dependent PR is not yet in that official stack:
## Verify before deleting anything
1. Compare every `author.login` exactly.
2. If all authors match, link the chain automatically in bottom-to-top order:
Before deleting a branch, ask GitHub directly whether any open PR still bases on it:
```sh
gh stack link --base <trunk> <bottom-pr> <next-pr> ... <top-pr>
```
3. If authors differ or any author is unavailable, ask the user whether to link before changing GitHub state.
4. Re-query GraphQL and require one stack number, the expected trunk, the complete PR set, and the expected positions and base chain.
Never dissolve, reorder, or rebuild an existing stack automatically; `gh stack link` is additive and merged or queued entries cannot be unstacked.
## Refresh only when needed
Do not rewrite branches merely because a refresh mechanism exists. When the live merge state or repository rules require an updated trunk, choose either allowed history:
- **Native cascading rebase:** check out the remote stack with `gh stack checkout <pr-or-stack>` when it is not tracked locally, then run `gh stack sync`. The command may rebase and lease-protected force-push every active layer before local validation. Immediately inspect the rewritten scope, run the relevant checks for every affected layer, and do not merge or claim readiness until they pass. If sync detects a rebase conflict, use `gh stack rebase`, resolve and validate it, then publish with `gh stack push`. If checkout or sync reports divergent local and remote stack compositions, cancel and ask rather than deleting or recreating the remote stack automatically.
- **Incremental merge-forward:** merge the trunk into the bottom affected branch, then propagate each updated parent into its child in bottom-to-top order and push normally. If the base advances during an in-progress merge, preserve that checkpoint before merging the newer tip as specified by the [incremental-retargeting note](../../notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md).
Any history rewrite is allowed after review, but it invalidates commit-OID assumptions. Re-fetch exact heads and re-audit unresolved review threads, approvals, mergeability, and checks after the push. Never use raw `--force` or overwrite a concurrently advanced remote head.
## Preflight the merge range
Re-query the official stack immediately before merging. Require every selected PR to be open, non-draft, in the expected order, and compliant with the repository's review and check requirements. Treat each PR's state independently; a ready top layer does not prove its dependencies are ready.
"Land the stack" selects the whole stack. A partial landing requires an explicit boundary PR and includes every layer from the bottom through that boundary.
## Merge through the stack API
Merge the whole stack by its official stack number:
```sh
gh stack merge <stack-number> --yes --merge
```
For an explicitly requested partial landing, merge through the boundary PR:
```sh
gh stack merge <boundary-pr> --yes --merge
```
Do not pass `--delete-branch`, manually retarget dependents, or issue per-PR merge commands. GitHub merges the selected range bottom-up and retargets/rebases any remaining upper layers. A direct stack merge is all-or-nothing; when the trunk uses a merge queue, GitHub queues the selected range together but may land it in separate groups.
Do not bypass merge requirements. If the native merge reports a blocker, inspect and resolve that blocker through the owning PR or stop and report it; never fall back to `gh pr merge`.
## Verify the landed state
Wait for every selected PR to report `MERGED`; a queued request is not a completed landing:
```sh
gh pr view <pr> --json number,state,mergedAt,mergeCommit,baseRefName,headRefName
```
For a partial landing, re-query the official stack and verify that every remaining PR is still linked in the expected order and targets the stack trunk or the layer below it. Re-check current heads, review state, and CI because GitHub may have rebased the remaining layers.
Delete branches only in a separate final pass after the corresponding PRs report `MERGED`. Before deleting each branch, require GitHub to report no open PR still using it as a base:
```sh
gh pr list --state open --base <branch> --json number --jq length
```
Anything other than `0` means open PRs still base on `<branch>` and deleting it would auto-close them — do not delete it. The `--base` filter is applied server-side, so zero-versus-non-zero is exact no matter how many PRs are open; the printed number itself saturates at `gh`'s `--limit` (default 30), which never matters here because only `0` clears a delete. Default to merging without `--delete-branch` throughout, and do the deletions as a separate final pass once every branch you're about to delete reports `0`.
Anything other than `0` blocks deletion.
## Longer chains
## Checklist
The pattern extends to any depth. For `A ← B ← C ← D ← …`, walk the stack from the bottom up: merge the lowest, then for each next link retarget to master, fetch and merge `origin/master` into it, merge the PR — always without deleting — and only sweep up all the branches at the very end. The invariant never changes: **a branch may be deleted only when no open PR bases on it.**
## Quick checklist
- [ ] Merge bottom PR first, `--merge`, no `--delete-branch`; wait until `gh pr view <n> --json state` shows `MERGED`.
- [ ] For each dependent: `gh pr edit <n> --base master` → fetch and merge `origin/master` into the branch (resolve conflicts there, push) → `gh pr merge <n> --merge`, no `--delete-branch`; again wait for `MERGED`.
- [ ] Before each branch delete: `gh pr list --state open --base <branch> --json number --jq length` prints `0`.
- [ ] Delete all branches (local + remote) only as a final pass.
- [ ] Native `gh stack` support is available; every PR branch is in the same repository.
- [ ] Live PR bases and exact heads establish one bottom-to-top dependency chain.
- [ ] GraphQL reports one official stack with the expected trunk, entries, and order; an eligible same-author unstacked chain was linked automatically.
- [ ] Any rewritten layers passed relevant validation, and review threads, approvals, mergeability, and checks were re-audited afterward.
- [ ] The whole stack, or an explicitly bounded prefix, was submitted through `gh stack merge --yes --merge`.
- [ ] Every selected PR reports `MERGED`; any remaining upper layers still form the expected official stack.
- [ ] Branch deletion happened only after merged-state and zero-dependent verification.

View File

@@ -1,11 +1,11 @@
---
name: dsh-pre-push-checks
description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite.
description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch, and immediately after gh stack sync publishes rewritten branches, to select the smallest tests and checks that cover the outgoing or just-published diff without reflexively running the full repository suite.
---
# DSH Pre-Push Checks
Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix.
Use this skill to run relevant local evidence once before a `deepseek-harness` push. The sole ordering exception is `gh stack sync`, which may publish a cascading rebase before the rewritten layers can be validated; validate them immediately afterward and do not merge until the evidence passes. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix.
## Inspect the outgoing change
@@ -63,9 +63,26 @@ pnpm exec vitest related packages/<group>/<package>/src/<changed>.ts \
Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate.
## Protect history-rewriting pushes
Rebase is allowed for standalone and stacked PR branches, including after review. Before a standalone history rewrite, fetch the current remote branch and record its exact OID; publish with `--force-with-lease=<branch>:<observed-oid>` so a concurrent update aborts the push. `gh stack push` and `gh stack sync` supply lease protection for their managed branches. Raw `--force` is never allowed.
After any rewritten push, fetch the live heads again and re-audit unresolved review threads, approvals, mergeability, and checks. Commit hashes and inline-comment anchors from before the rewrite are not current evidence.
### Post-sync validation
`gh stack sync` fetches, cascade-rebases, and pushes as one operation, so it cannot place local validation between rewrite and publication. Before running it, require a clean worktree and record the official stack order and exact remote heads. After it returns:
1. Re-query every branch head and the official GitHub stack order.
2. Inspect the changed scope of every rewritten layer against its live PR base.
3. Run the relevant evidence selected by this skill for each affected layer.
4. Keep every PR unmerged and report validation as pending until all selected checks pass.
If post-sync evidence fails, leave the lease-protected published heads in place, repair the failure, validate the repair, and publish the correction. Do not claim the sync made the stack ready merely because the command succeeded.
## Handle failures
If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs.
If a relevant check fails before an ordinary push, stop and fix or explain the blocker. Do not push and hope CI differs. For the post-sync exception, block the merge and follow the repair procedure above.
If a failure looks environment-specific, prove it:
@@ -76,9 +93,11 @@ If a failure looks environment-specific, prove it:
## Push procedure
For ordinary and standalone rebase pushes:
1. Run the selected relevant checks once.
2. Commit normally and inspect any files changed by the pre-commit fixer before continuing.
3. Push normally so the incremental typecheck hook runs.
3. Push normally, or use the exact lease for an authorized rewritten branch, so the incremental typecheck hook runs.
4. Verify the remote ref matches local `HEAD`.
```sh
@@ -92,3 +111,5 @@ gh pr checks
```
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment.
For `gh stack sync`, use the post-sync validation sequence instead of pretending the ordinary order was possible.

View File

@@ -79,7 +79,7 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the
### Run relevant checks locally
Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run.
Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass.
- Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior.
- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
@@ -116,7 +116,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
- **Use incremental merge commits.** Split independent changes. Pushed history may be rewritten before review; afterward prefer new commits. Fix the introducing PR before merging down-stack. If the base advances mid-merge, finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)).
- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
- **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible.
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.

View File

@@ -66,6 +66,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
| [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`react`](https://github.com/facebook/react) | MIT |
| [`react-dom`](https://github.com/facebook/react) | MIT |
| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 774bb08d2e923e15db23b3980eff7759b45fc146
README.zh.md: de096659679df3d73f9aa76c9563b31c8ecb3cb6
README.md: 76d9ed65398322cb9244a31661ee59b60c23f793
README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b

View File

@@ -11,7 +11,7 @@ The TUI surface:
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only.
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
@@ -21,10 +21,22 @@ The TUI surface:
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one.
All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command:
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
```
The repository must contain a prepared `.dsh-plugin` package; the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration) documents authoring, nested Plugin paths, the immutable cache, trust boundary, and failure semantics. A failed live edit keeps the last good tree and emits Cordis's `hmr/config-update-failed` event.
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).

View File

@@ -11,7 +11,7 @@ TUI 界面:
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。
-`DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有overlay 不会写入会话事件或模型上下文。
- 注册裸 `/compact`agent 空闲时即使未达到自动压力也会摘要有效的较早历史该命令拒绝参数并只在独立替换标记对持久化后报告成功。压缩compaction期间提交的提示词保留其队列身份并在该检查点之后启动注入的上下文仍保持可见。
@@ -21,10 +21,22 @@ TUI 界面:
`dsh --dump-config``dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include``applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、CLI 标志补丁是每次调用的事实位于配置树之外不会出现。dump 标志会拒绝仅用于启动的标志(`-p``--resume``--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle因此刷新现有 URL 即可更新该 GUI无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR热模块替换接收端但要实现无刷新更新还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundleshell 和普通包package的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。
三个界面都会使用 `$DSH_HOME/config.yaml`TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件:
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
```
仓库必须包含已准备的 `.dsh-plugin` 包;[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)说明创作方式、嵌套插件路径、不可变缓存、信任边界和失败语义。实时编辑失败时,最后一个可用树保持运行,并发出 Cordis 的 HMR热模块替换事件 `hmr/config-update-failed`
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。

View File

@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_tui_timer
plugin_tui_hmr["hmr<br/>@cordisjs/plugin-hmr"]
cfg --> plugin_tui_hmr
plugin_tui_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"]
cfg --> plugin_tui_repository_plugins
plugin_tui_llm["llm<br/>@deepseek-ai/dsh-llm"]
cfg --> plugin_tui_llm
plugin_tui_session["session<br/>@deepseek-ai/dsh-session"]
@@ -144,6 +146,7 @@ flowchart LR
| --- | --- |
| `timer` | `@cordisjs/plugin-timer` |
| `hmr` | `@cordisjs/plugin-hmr` |
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
| `llm` | `@deepseek-ai/dsh-llm` |
| `session` | `@deepseek-ai/dsh-session` |
| `session-title` | `@deepseek-ai/dsh-session-title` |

View File

@@ -22,6 +22,13 @@
config:
root: ['.']
# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub
# repository Plugin generations. The app registers the DSH-owned runtime even
# when the list is empty so a later personal-config edit can load
# transactionally; one-shot headless runs consume the startup value only.
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
- id: llm
name: '@deepseek-ai/dsh-llm'

View File

@@ -10,6 +10,11 @@
# ── surface-specific values the base deliberately omits ─────────────────────
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.
- id: hmr
disabled: true

View File

@@ -77,6 +77,7 @@
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",

View File

@@ -1,6 +1,6 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* for the Web/headless surface.
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and surface overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
@@ -16,7 +16,13 @@ import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
import {
boot,
installFailLoud,
loadOverlayPatches,
loadPersonalPatches,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -140,8 +146,10 @@ export interface AppCLIEntryOptions {
* `$DSH_HOME/config.yaml` overlay is applied instead.
*/
extraOverlayPath?: string
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
dev: boolean
/** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */
watchPersonalConfig: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
@@ -155,6 +163,8 @@ export interface AppCLIEntryOptions {
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context) => Promise<void> | void
}
/**
@@ -180,8 +190,8 @@ export class AppCLIEntry {
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: patch composition → Loader include boot (dev row
* before await) → fail-loud triple.
* Run the boot chain: patch composition → Loader installation → surface
* preparation → config-tree boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
@@ -233,11 +243,12 @@ export class AppCLIEntry {
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const generated = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
this.patches = generated
// Telemetry opt-out: a row can only be turned off at the patch layer
// (config cannot disable an entry), and the switch must hold BEFORE the
@@ -246,22 +257,36 @@ export class AppCLIEntry {
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
/** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
/** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
private async bootTree(): Promise<void> {
// One include of the shared base with every overlay as a sibling patch
// list: patches never cross an include boundary, so nesting them would
// silently stop reaching base rows. The surface overlay applies first, then
// this entry's profile-json and CLI-flag patches, which therefore win.
const patches = [
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
...loadOverlayPatches('dsh', this.options.overlayPath),
...this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
...overlay,
...this.patches,
]
// An explicit --config overlay REPLACES the personal overlay, so there is
// then no personal layer to keep live — the watcher is personal-only.
const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined
const patches = compose(
this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
)
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
await this.options.prepare?.(ctx)
// Config-only HMR for the personal overlay: module reload stays off for
// this surface (web.cordis.yml disables the shared `hmr` row until its
// reload lifecycle is tested), so this row watches no module roots.
if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
if (watchPersonal) {
await watchPersonalPatches(this.ctx, { binName: 'dsh', compose })
}
}
/** Install the diagnostic for plugin rejections that happen after settled boot. */

View File

@@ -285,7 +285,7 @@ Examples:
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')

View File

@@ -78,6 +78,7 @@ export async function runHeadless(task: string): Promise<void> {
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
dev: false,
watchPersonalConfig: false,
port: 0,
})
const { ctx, port } = await entry.run()

View File

@@ -28,8 +28,10 @@ import {
loadOverlayPatches,
loadPersonalPatches,
resolveConfigPath,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { SessionId } from '@deepseek-ai/dsh-session'
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
@@ -201,15 +203,16 @@ export async function runTui(
// presence is checked against the tree actually booting, so a
// --config-replace tree is judged on its own rows, not the shipped base's.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
const patches = [
const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [
...replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
? personalPatches
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
],
...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const patches = composePatches(loadPersonalPatches(NAME) ?? [])
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
@@ -246,6 +249,14 @@ export async function runTui(
}
},
)
// The shipped tree includes HMR and keeps personal config live. An explicit
// --config tree replaces the personal overlay (so there is nothing to keep
// live), and a --config-replace or HMR-less tree remains a valid composition
// that still receives the startup overlay but deliberately has no hidden
// watcher.
if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) {
await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches })
}
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
if (showFirstRunWelcome) {

View File

@@ -7,23 +7,87 @@
*/
import { fileURLToPath } from 'node:url'
import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type { Context } from 'cordis'
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import { AppCLIEntry } from './app-cli-entry.ts'
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const DSH_WEB_URL = 'DSH_WEB_URL' as const
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
type WebMode = 'production' | 'development'
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
const updateContract = mode === 'development'
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
+ 'The browser provides no implicit DOM, route, or screenshot context. '
+ updateContract
+ 'Starting another server does not update this GUI. '
+ 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. '
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.'
}
/** Resolve the canonical loopback URL from the active Web server. */
function localWebUrl(ctx: Context): string {
const port = ctx.get('httpServer')?.port
if (port === undefined) throw new Error('dsh web: httpServer service missing while resolving Web runtime')
return `http://${LOOPBACK_HOST}:${String(port)}`
}
/**
* Register the launcher-owned prompt and shell runtime context before the
* shared config tree mounts. The earlier injections install the prompt
* sections and managed Bash contributor when their owning services activate;
* dynamic values read the bound server only when consumed.
* @param ctx - Web root context with Loader installed but no config tree mounted.
* @param sourceRoot - absolute checkout root resolved from the launcher module.
* @param mode - whether this process mounted the client-plugin HMR receiver.
*/
export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, sourceRoot)
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), mode),
})
})
ctx.inject(['bashEnv'], (runtimeCtx) => {
runtimeCtx.bashEnv.register({
name: 'web-runtime',
variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
},
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: mode }),
})
})
}
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the shipped Web overlay value stands.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
* @param config - an overlay of loader patches applied over the shipped web
@@ -38,17 +102,21 @@ export async function runWeb(
trustedHosts: string[] | undefined,
config?: string,
): Promise<void> {
const mode: WebMode = dev ? 'development' : 'production'
const entry = new AppCLIEntry({
configPath: BASE_CONFIG,
overlayPath: WEB_OVERLAY,
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
dev,
prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) },
watchPersonalConfig: true,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
let exiting = false
const shutdown = (code: number): void => {
@@ -65,6 +133,5 @@ export async function runWeb(
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
console.log(`dsh web: ${resolvedLocalWebUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
}

View File

@@ -1,4 +1,5 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { createHash } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -6,6 +7,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
@@ -66,6 +68,38 @@ function seedWorkspace(
}
}
/**
* Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin`
* package and return the generated wrapper text, so the smoke's cache-seeded
* wrapper can never drift from the generator's template.
*/
async function generatePreparedWrapper(pluginName: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-'))
try {
const plugin = join(root, '.dsh-plugin')
await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true })
await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [
'---',
'name: config-only-repository',
'description: Generator input; the seeded cache copy owns the visible text.',
'---',
'',
'Repository instructions.',
'',
].join('\n'))
await mkdir(plugin, { recursive: true })
await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
name: pluginName,
version: '0.0.0',
dsh: { skills: ['../skills'] },
}, undefined, 2)}\n`)
await prepareDshPlugin(plugin)
return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
} finally {
await rm(root, { recursive: true, force: true })
}
}
/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
async function seedResumeSession(cwd: string): Promise<void> {
const sessionCwd = realpathSync.native(cwd)
@@ -640,6 +674,54 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('loads a cached repository Plugin from personal config alone', async () => {
const source = 'github:fixture/repository#fixed-ref'
const specifier = `${source}&path:/.dsh-plugin`
const key = createHash('sha256').update(specifier).digest('hex')
const packageRoot = `cache/repository-plugins/${key}/node_modules/repository`
// Produced by the real generator (prepareDshPlugin over an equivalent
// .dsh-plugin package) rather than hand-written, so a wrapper-template
// change cannot leave this smoke exercising a stale shape. The cache
// LAYOUT below (sha256 key, marker, node_modules/repository) remains a
// deliberate external pin of the durable on-disk format.
const wrapper = await generatePreparedWrapper('config-only-fixture')
const output = await smoke({
label: 'dsh personal repository Plugin',
tempDirPrefix: 'dsh-personal-repository-plugin-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
personal: {
'config.yaml': [
'- id: repository-plugins',
" name: '@deepseek-ai/dsh-repository-plugin'",
' config:',
' repositories:',
` - '${source}'`,
'',
].join('\n'),
[`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`,
[`${packageRoot}/dsh-plugin.mjs`]: wrapper,
[`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [
'---',
'name: config-only-repository',
'description: CONFIG_ONLY_REPOSITORY_SKILL',
'---',
'',
'Repository instructions.',
'',
].join('\n'),
},
}),
actions: [
{ waitFor: 'main-session-', send: '/skill:config-only' },
{ waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' },
],
})
expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
const output = await smoke({
label: 'dsh invalid personal config',

View File

@@ -0,0 +1,32 @@
import { sep } from 'node:path'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { prepareWebRuntimeContext } from '../src/web.ts'
describe('prepareWebRuntimeContext', () => {
it('installs both sections before a later systemPrompt consumer activates', async () => {
const ctx = new Context()
const sourceRoot = `${sep}opt${sep}harness-src`
let observedSections: { name: string; text: string }[] | undefined
try {
prepareWebRuntimeContext(ctx, sourceRoot, 'production')
ctx.provide('httpServer', { port: 3080 } as Context['httpServer'])
const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => {
const assembly = await promptCtx.systemPrompt.assemble()
observedSections = assembly.sections
})
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
await consumer
expect(observedSections?.map(section => section.name)).toContain(HARNESS_SOURCE_SECTION)
expect(observedSections?.find(section => section.name === 'app:web-surface')?.text)
.toContain('http://127.0.0.1:3080')
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../packages/ui/app-boot"
},
{
"path": "../../packages/bash/tool-bash"
},
{
"path": "../../packages/ui/tui"
},

View File

@@ -0,0 +1,132 @@
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { REPO_ROOT } from './support.ts'
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
return {
argv,
cwd,
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
graceMs: 5_000,
...env === undefined ? {} : { env },
}
}
function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
return new Promise((resolveReady, reject) => {
let output = ''
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.stdout?.off('data', onData)
child.stderr?.off('data', onData)
}
const resolveOnce = (value: string): void => {
if (settled) return
settled = true
cleanup()
resolveReady(value)
}
const rejectOnce = (error: Error): void => {
if (settled) return
settled = true
cleanup()
reject(error)
}
const onData = (chunk: Buffer): void => {
output += chunk.toString()
const match = pattern.exec(output)
if (match === null) return
resolveOnce(match[1] ?? match[0])
}
const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
child.stdout?.on('data', onData)
child.stderr?.on('data', onData)
void child.done.then((outcome) => {
rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
}, (error: unknown) => {
rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
})
})
}
async function stopTree(child: SubprocessHandle): Promise<void> {
child.terminate()
const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
await child.done
}
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
const oldText = "Let's start building"
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
const subprocessCtx = new Context()
let subprocessFiber: Fiber | undefined
let watcher: SubprocessHandle | undefined
let host: SubprocessHandle | undefined
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
const failures: unknown[] = []
try {
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
world,
{
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
DSH_HOME: join(world, '.dsh'),
},
))
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
browser = await chromium.launch()
const page = await browser.newPage()
const pageErrors: string[] = []
page.on('pageerror', error => pageErrors.push(String(error)))
await page.goto(baseUrl, { waitUntil: 'load' })
await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
const pageIdentity = await page.evaluate(() => {
const identity = crypto.randomUUID()
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
return identity
})
await writeFile(sourcePath, updatedSource)
await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
.toBe(pageIdentity)
expect(pageErrors).toEqual([])
} catch (error) {
failures.push(error)
} finally {
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
await browser?.close().catch((error: unknown) => failures.push(error))
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
}, 120_000)

View File

@@ -9,20 +9,23 @@
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// The scenario's one drive prompt. Record sends it; replay asserts the
@@ -35,6 +38,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let settledSessionId: SessionId | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
@@ -69,11 +73,44 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
settledSessionId = sessionId
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 200_000)
it('records the Web surface, source checkout, and session cwd in the request header', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
const system = agent.session.requestHeader()?.system
if (system === undefined) throw new Error('the settled Web request has no system prompt')
const prefix = system.split('\n\n').slice(0, 4).join('\n\n')
.split(REPO_ROOT).join('{{sourceRoot}}')
.split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}')
.split(scaffold.baseUrl).join('{{webUrl}}')
await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE)
})
it('exposes the assembled Web URL to the real bash tool', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
const result = await scaffold.ctx.tools.execute({
signal: AbortSignal.timeout(5_000),
callId: CallId('web-url-probe'),
name: 'bash',
arguments: {
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
description: 'Print current Web runtime',
},
agent,
})
expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe(`${scaffold.baseUrl}\nproduction\n`)
})
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
// Browser settled-poll after host completion (host strictly precedes render).
@@ -129,6 +166,6 @@ describe('web e2e: fresh round trip through the real assembly', () => {
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md'])
})
})

View File

@@ -53,6 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
@@ -300,6 +301,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// The shipped CLI deliberately has no dependency on this opt-in package.
// Keep the Loader row real without broadening the product installation.
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },

View File

@@ -26,6 +26,8 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
let out = ''
@@ -184,7 +186,7 @@ describe('dsh web keyless CLI smoke', () => {
}
})
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
@@ -220,7 +222,7 @@ describe('dsh web keyless CLI smoke', () => {
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
{
cwd: workspace,
env: {
@@ -252,6 +254,10 @@ describe('dsh web keyless CLI smoke', () => {
message.role === 'user' && message.content?.includes('<available_skills>'))).toBe(false)
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
.replace('{{webUrl}}', baseUrl)
expect(systemMessage?.content).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(`
{
"content": "<system-reminder>

View File

@@ -0,0 +1,7 @@
You are an AI agent powered by the DeepSeek Harness SDK.
The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.

View File

@@ -0,0 +1 @@
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.

View File

@@ -0,0 +1,9 @@
import { appendFileSync } from 'node:fs'
import { Server } from 'node:net'
const marker = process.env.DSH_LISTEN_PROBE_MARKER
const listen = Server.prototype.listen
Server.prototype.listen = function (...args) {
if (marker !== undefined) appendFileSync(marker, 'listen\n')
return listen.apply(this, args)
}

View File

@@ -0,0 +1,63 @@
/** Bare Vite must fail before it can present a bootless shell as a working GUI. */
import { fileURLToPath, pathToFileURL } from 'node:url'
import { join } from 'node:path'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { createServer } from 'node:net'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
const WEB_ROOT = fileURLToPath(new URL('..', import.meta.url))
/** Reserve an available loopback port, then release it for the child invocation. */
async function freePort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('port probe returned no address')
await new Promise<void>((resolve, reject) => server.close((error) => {
if (error === undefined) resolve()
else reject(error)
}))
return address.port
}
describe('Web development entry', () => {
it('rejects the package dev alias with the full-host correction', async () => {
const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false })
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain('apps/web is not a standalone application')
expect(result.stderr).toContain('dsh web')
})
it('rejects the standalone Vite server with the full-host correction', async () => {
const probeRoot = mkdtempSync(join(tmpdir(), 'dsh-vite-listen-probe-'))
const marker = join(probeRoot, 'listen-called')
const port = await freePort()
try {
const probeModule = fileURLToPath(new URL('./support/listen-probe.mjs', import.meta.url))
const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], {
cwd: WEB_ROOT,
reject: false,
timeout: 10_000,
env: {
...process.env,
DSH_LISTEN_PROBE_MARKER: marker,
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import ${pathToFileURL(probeModule).href}`.trim(),
},
})
expect(result.timedOut).toBe(false)
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain('apps/web is not a standalone application')
expect(result.stderr).toContain('dsh web')
expect(result.stderr).toContain('window.__DSH_BOOT__')
expect(existsSync(marker), 'Vite called Server.listen before rejecting standalone serve mode').toBe(false)
} finally {
rmSync(probeRoot, { recursive: true, force: true })
}
})
})

View File

@@ -38,6 +38,7 @@
"tests/onboarding-deepseek-config.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/code-mode-round.e2e.ts",

View File

@@ -1,11 +1,25 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
+ 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). '
+ 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.'
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
function rejectStandaloneServe(): Plugin {
return {
name: 'dsh-reject-standalone-web-serve',
config(_config, env) {
if (env.command === 'serve') throw new Error(STANDALONE_ERROR)
},
}
}
export default defineConfig({
plugins: [react()],
plugins: [rejectStandaloneServe(), react()],
resolve: {
// Workspace packages resolve to SOURCE: package.json exports point at lib
// for Node/type consumers, but the browser bundle must compile src directly

View File

@@ -867,7 +867,7 @@ export interface StreamableHttpConfig {
}
```
Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/src/index.ts)
Source: [`packages/mcp/mcp-client/src/index.ts:96`](../packages/mcp/mcp-client/src/index.ts)
## `@deepseek-ai/dsh-permission`
@@ -995,6 +995,22 @@ export interface Config {
Source: [`packages/guard/repeat-tool-guard/src/index.ts:28`](../packages/guard/repeat-tool-guard/src/index.ts)
## `@deepseek-ai/dsh-repository-plugin`
Requires: `loader`
```ts config-catalog
/** Repository Plugin runtime and source-list configuration. */
export interface Config {
/** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */
repositories?: string[]
/** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */
cacheDir?: string
}
```
Source: [`packages/cordis/repository-plugin/src/index.ts:42`](../packages/cordis/repository-plugin/src/index.ts)
## `@deepseek-ai/dsh-sandbox-local`
```ts config-catalog
@@ -1312,6 +1328,10 @@ Requires: `skills`
```ts config-catalog
/** Local filesystem skill provider configuration. */
export interface Config {
/** Unique provider name. Defaults to `local`. */
providerName?: string
/** Whether project and user roots are included around custom roots. */
includeDefaultRoots?: boolean
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
@@ -1330,7 +1350,7 @@ export interface Config {
watchMaxProjects?: number
/** Whether watched symbolic links follow their target files. */
watchFollowSymlinks?: boolean
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */
bundledSkillDir?: string
}
```

View File

@@ -1,6 +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
responding-to-pr-review-on-a-stack.md: 3fb7eb943eeb8d703303be3f6a844870cc26fd47
responding-to-pr-review-on-a-stack.zh.md: d96323b853c093265931904c20996df335f82926
# pnpm run verify-translation-pairing --write docs/cookbook/responding-to-pr-review-on-a-stack.md
responding-to-pr-review-on-a-stack.md: 6bb7be3daf8613666f02f21e96bf7b1ac8cb3606
responding-to-pr-review-on-a-stack.zh.md: 94c2bd2cdd03b9eeca1e180b09bb71471db91c0a

View File

@@ -2,25 +2,31 @@
English | [中文](responding-to-pr-review-on-a-stack.zh.md)
Review comments may target several PRs in a dependent stack (`A ← B ← C …`). This guide explains how to resolve them without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch.
Review comments may target several PRs in a dependent stack (`A ← B ← C …`). Keep that chain linked through GitHub's official stacked-PR feature. This guide owns review-fix placement and propagation; the [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill owns linkage checks and landing.
## Ground rules
1. **One worktree per PR branch.** Each PR's fixes happen in that PR's own worktree; parallel fixes never share a checkout.
2. **Bring a child up to date by merging the parent down** (`git merge <parent-branch>` into the child, a new merge commit). Never rebase/amend/force-push a pushed branch: rewriting diverges it from what the parent PR and GitHub recorded, breaks the stacked-merge graph, and erases the review-fix history.
3. **A fix lands on the PR that INTRODUCED the issue, then flows down.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and merge `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer.
4. **Each review fix is a separate commit, never an amend.** The "fix review findings" commit documents what the review caught. Amending is fine only for your own not-yet-pushed, not-yet-reviewed work.
2. **GitHub's stack object is authoritative.** Base branches establish the expected dependency order, while `PullRequest.stack` and `stackEntry.position` prove that GitHub recognizes it. Do not treat a matching branch chain as an official stack without checking those fields.
3. **A fix lands on the PR that INTRODUCED the issue, then flows up-stack.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and propagate `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer.
4. **Each review fix remains a distinct commit.** A later rebase may change its OID, but do not amend a reviewed fix out of the branch history. Amend only your own not-yet-pushed, not-yet-reviewed work.
5. **Choose merge-forward or rebase deliberately.** Both histories are allowed after review. A rewritten push must be lease-protected and must abort rather than overwrite a concurrently advanced remote head; raw `--force` is forbidden.
## Resolve comments through the stack
1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still mis-diagnose the cause.
2. Map each accepted finding to its originating PR, fix it there, then merge down the chain in order.
3. Delegated fixes are trust-but-verify: a sub-agent's report describes intent, not necessarily what landed. Re-run the gates yourself on the actual tree, and for a regression guard, prove it FAILS on the unfixed code (introduce the regression, watch red, revert) — a guard that passes both ways guards nothing. A sub-agent that reframes a problem as already-handled is a signal to dig in personally.
4. Reply in the review thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the commit that carries it.
5. Before merging the stack, check dependents: deleting a PR's base branch auto-closes the dependent PR — check each branch with `gh pr list --state open --base <branch> --json number --jq length` (non-zero = open dependents), and merge without `--delete-branch` where a child still bases on the branch. The full landing procedure is the [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill.
1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still misdiagnose the cause.
2. Map each accepted finding to its originating PR and fix it there.
3. Propagate the fixed layer through every affected child in order:
- **Merge-forward:** merge the fixed parent branch into its child, validate the child, and continue upward. Preserve each in-progress checkpoint under the [incremental-retargeting decision](../../.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md).
- **Native cascading rebase:** use `gh stack rebase`, validate the rewritten layers, then publish with `gh stack push`; or use `gh stack sync`, which may publish first and therefore requires immediate post-sync validation under [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md).
4. Treat delegated fixes as trust-but-verify: a sub-agent's report describes intent, not necessarily what landed. Re-run the gates yourself on the actual tree, and for a regression guard, prove it FAILS on the unfixed code (introduce the regression, watch red, revert) — a guard that passes both ways guards nothing. A sub-agent that reframes a problem as already handled is a signal to dig in personally.
5. Reply in the review thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the current commit or head that carries it.
6. After any rewritten push, re-read unresolved threads, approvals, mergeability, and checks. A force-pushed commit OID or outdated inline anchor is not current evidence that the finding remains resolved.
7. Land only through the official stack procedure. If the PRs are not yet linked, the landing skill automatically links a same-author chain, asks before linking mixed authors, and hard-stops when native stack support is unavailable.
## Verify
- Every fixed PR shows a new commit (no force-push icon in the PR timeline).
- Each child PR's diff against its parent still shows only its own changes.
- The gates pass on every PR in the stack, not just the top.
- Every fixed PR's current diff contains the intended correction at the layer that introduced the issue.
- GraphQL reports one official stack in the expected order, and each child diff against its parent shows only that child's changes.
- Unresolved threads, approvals, mergeability, and checks were re-audited after every rewritten push.
- The relevant gates pass on every affected PR in the stack, not just the top.

View File

@@ -2,25 +2,31 @@
[English](responding-to-pr-review-on-a-stack.md) | 中文
评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PRPull Request本指南说明如何在不破坏堆叠的前提下解决这些意见。它依赖的两个不变式是根 [AGENTS.md](../../AGENTS.md) § Conventions 中的常设指令:只用 merge commit以及永远不改写已推送的分支
评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PRPull Request请通过 GitHub 官方的堆叠 PR 功能保持这条链的关联。本指南负责评审修复的归属与传播;[dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill技能负责检查关联关系和落地
## 基本规则
1. **每个 PR 分支一个 worktree。** 每个 PR 的修复在该 PR 自己的 worktree 中进行;并行修复绝不共享同一个 checkout。
2. **通过将父分支向下合并来更新子分支**(在子分支中执行 `git merge <parent-branch>`,产生一个新的 merge commit。绝不对已推送的分支做 rebase/amend/force-push改写会使分支与父 PR 及 GitHub 记录的内容产生分歧,破坏堆叠合并图,并抹去评审修复历史
3. **修复落在引入问题的那个 PR 上,然后向下流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 合并`C`——即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。
4. **每评审修复是一个独立 commit,绝不 amend。** "修复评审发现"的 commit 记录了评审捕获的内容。只有你自己尚未推送尚未评审的工作才可以 amend。
2. **GitHub 的 stack 对象是权威依据。** base 分支确定预期的依赖顺序,`PullRequest.stack``stackEntry.position` 则证明 GitHub 已识别该堆叠。未经检查这些字段,不得仅凭分支链吻合就将其视为官方堆叠
3. **修复落在引入问题的那个 PR 上,然后沿堆叠向上流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 的变更传播`C`即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。
4. **每评审修复都保留为独立 commit** 后续 rebase 可能改变其 OID但不得通过 amend 把已经评审的修复从分支历史中抹去。只有你自己尚未推送尚未评审的工作才可以 amend。
5. **明确选择 merge-forward 或 rebase。** 评审后允许采用这两种历史更新方式。改写历史的推送必须受 lease 保护;如果远端 head 在此期间前移,操作必须中止,不得将其覆盖。禁止直接使用 `--force`
## 沿堆叠解决评审意见
1. 在行动之前先就事论事地审视每条评论:对照代码验证其论断——评审者指出了正确的症状,但仍可能误诊原因。
2. 将每个被接受的发现映射到其发起 PR在那里修复,然后按顺序沿链向下合并
3. 委派的修复需要信任但验证:子 agent智能体的报告描述的是意图不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁对于回归守卫要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。
4. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及承载修复的 commit
5. 合并堆叠之前,检查依赖方:删除一个 PR 的 base 分支会自动关闭依赖它的 PR。用 `gh pr list --state open --base <branch> --json number --jq length` 检查每个分支(非零 = 有打开的依赖方),当子 PR 仍以该分支为 base 时,合并时不带 `--delete-branch`。完整的落地流程见 [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill技能
2. 将每个被接受的发现映射到其发起 PR在那里修复。
3. 将修复后的层按顺序传播到每个受影响的子 PR
- **Merge-forward** 将修复后的父分支合并到其子分支,验证子分支,然后继续沿堆叠向上传播。依照[增量更新 base 的决策](../../.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md),保留每个正在处理的检查点
- **原生级联 rebase** 使用 `gh stack rebase`,验证所有已改写的层,然后通过 `gh stack push` 发布;也可以使用 `gh stack sync`,该命令可能先发布,因此必须按照 [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) 在同步后立即验证
4. 委派的修复需要信任但验证:子 agent智能体的报告描述的是意图不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁对于回归守卫要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。
5. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及当前承载修复的 commit 或 head。
6. 每次改写推送后,都要重新读取未解决线程、批准状态、可合并性和检查结果。经 force-push 改写的 commit OID 或已过时的内联锚点,都不足以证明该发现当前仍处于已解决状态。
7. 仅可通过官方堆叠流程落地。如果这些 PR 尚未关联,落地 skill 会自动关联作者相同的链;如果作者不同,则先询问用户;如果原生堆叠支持不可用,则硬性停止流程。
## 验证
- 每个已修复 PR 显示一个新 commitPR 时间线中没有 force-push 图标)
- 每个子 PR 相对父 PR 的 diff 仍然只包含自身的变更。
- 门禁在堆叠中的每个 PR 上都通过,而不仅仅是顶部。
- 每个已修复 PR 的当前 diff 都在引入问题的那一层包含预期修正
- GraphQL 报告的官方堆叠只有一个且顺序符合预期;每个子 PR 相对父 PR 的 diff 只显示该子 PR 自身的变更。
- 每次改写推送后,均重新审计了未解决线程、批准状态、可合并性和检查结果。
- 相关门禁在堆叠中的每个受影响 PR 上都通过,而不仅仅是顶部。

View File

@@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config.
*
* @param config — the new raw config; validated before anything restarts.
* @param noSave — hint for persistence hooks not to write the change back.
* @returns nothing; the restart runs behind the `internal/update` waterfall.
* @throws {ValidationError} when the new config fails validation.
* @returns the update waterfall result; the default restart returns a promise.
* @throws when validation, an update listener, or the restarted plugin fails.
*/
update(config: any, noSave = false)
```
@@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o
- `config` — the new raw config; validated before anything restarts.
- `noSave` — hint for persistence hooks not to write the change back.
**Returns** nothing; the restart runs behind the `internal/update` waterfall.
**Returns** the update waterfall result; the default restart returns a promise.
[Source](../../../vendor/cordis/src/fiber.ts#L734)

View File

@@ -1183,7 +1183,8 @@ The framework events every plugin also sees, beyond the harness vocabulary above
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts))
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts))
- `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts))
- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts))
- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:22`](../../vendor/hmr/src/index.ts))
- `hmr/config-update-failed` — A watched config-file refresh failed. ([`vendor/hmr/src/index.ts:29`](../../vendor/hmr/src/index.ts))
- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts))
- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts))
- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts))

View File

@@ -94,6 +94,7 @@ flowchart TD
pkg_plan_mode["plan-mode"]
end
subgraph group_cordis["packages/cordis"]
pkg_repository_plugin["repository-plugin"]
pkg_tool_cordis["tool-cordis"]
end
subgraph group_hooks["packages/hooks"]
@@ -904,6 +905,10 @@ flowchart TD
pkg_tool_subagent --> pkg_subagent
pkg_tool_subagent --> pkg_tasks
pkg_tool_subagent --> pkg_tools
pkg_repository_plugin --> pkg_invariants
pkg_repository_plugin --> pkg_mcp_client
pkg_repository_plugin --> pkg_paths
pkg_repository_plugin --> pkg_skill_local
pkg_hooks_claude --> pkg_agent
pkg_hooks_claude --> pkg_hook_protocol
pkg_hooks_claude --> pkg_invariants
@@ -1208,6 +1213,7 @@ flowchart TD
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |

View File

@@ -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 docs/postmortem/0003-web-agent-gui-feedback-loop.md
0003-web-agent-gui-feedback-loop.md: 13d13a607babfe7f5ddfdb6773c94f973bbef0db
0003-web-agent-gui-feedback-loop.zh.md: f80530605ef9274638c6848bf5a256f76057915c

View File

@@ -0,0 +1,53 @@
# Post-mortem 0003: Web agent validated a replacement server instead of its current GUI
English | [中文](0003-web-agent-gui-feedback-loop.zh.md)
Status: resolved
## Executive summary
A Web agent changed the GUI source but did not know which URL and process hosted its session. It delegated acceptance to the user, then treated a bare Vite HTTP 200 as success despite a missing `window.__DSH_BOOT__` white screen, and finally validated a replacement `dsh web` server on another port while the original page had already picked up rebuilt artifacts. The fix makes the current URL and runtime mode model-visible and shell-queryable, rejects standalone Vite before listen, and verifies production refresh and development HMR against external state.
## Summary
The session ran inside the DeepSeek Harness Web GUI at port 3081 while its selected Workspace was an empty `test/` directory. The model request named neither the GUI nor its source checkout, URL, process, or update mode. Repository affordances exposed `apps/web` with a Vite development script, while the full browser composition lived behind `dsh web`.
The resulting actions were individually plausible but did not share one acceptance target. A source edit, a successful build, an HTTP 200, an injected boot manifest, and the user's existing page were treated as interchangeable facts.
The evidence source is the persisted event log for `session-3eb796c2-5159-4686-affe-df8719f6f987`, whose header records cwd `/Users/tn.shen/Documents/deepseek-harness-gui-master/test`. Its initial request header is sequence 6; the user-facing handoff, bare-Vite launch, replacement-host launch, boot-manifest probe, and first 3081 process probe are sequences 30939, 31865, 34309, 34441, and 34681 respectively. The timeline below follows those events rather than reconstructing intent from the later report.
## Impact
The user had to identify three consecutive mistakes: acceptance was delegated back to them; the proposed preview was a blank page; and the reported successful URL was not the page they were using. An unmanaged replacement server also outlived the turn until the user challenged it.
No change in this investigation restarted or modified the read-only 3081 and 3082 trial services.
## Timeline
- In turn 2, after editing the theme, the agent's sequence-30939 message told the user to run `pnpm run demo:tui` or open an unspecified Web application. It ran no assembled Web acceptance.
- In turn 3, the agent read `apps/web/package.json`, launched bare Vite on port 5173 at sequence 31865, observed HTTP 200, and declared success. The browser instead threw `client-modules: window.__DSH_BOOT__ is missing or not an object` and rendered a white page.
- In turn 4, the agent found the full `dsh web` path, rebuilt the shell, launched an unmanaged process on port 3334 at sequence 34309, and checked only that this replacement returned 200 with a boot manifest at sequence 34441. It never probed port 3081.
- In turn 5, the user reported at sequence 34556 that 3081 already showed the new theme. Only then, at sequence 34681, did the agent inspect the existing process and remove the redundant server.
## Root cause
The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly represented the user's selected Workspace, but the model mistook that project boundary for the application boundary. No durable contract related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance.
The wrong startup path looked legitimate because bare Vite returned HTTP 200. `window.__DSH_BOOT__` is injected only by the full host, so transport readiness did not imply application readiness. The first regression test repeated this mistake in another form: a timeout killed Vite and satisfied a nonzero-exit assertion. Live reproduction exposed that false positive.
Background process semantics were also bypassed with shell `&`, so task identity, completion notices, collection, and cleanup did not apply. Verifying port 3334 therefore proved only that a second service worked.
## Guardrails added
- The Web launcher publishes the canonical loopback URL and actual production/development mode in the logged `app:web-surface` prompt section and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE` environment.
- Production guidance requires rebuilding artifacts and verifying the existing URL after refresh. Development guidance explains that `dsh web --dev` mounts only the HMR receiver; `pnpm run dev:web` in the same checkout must also rebuild client-plugin bundles, while shell and plain-package changes still require refresh.
- `apps/web` standalone Vite serve mode rejects during configuration. Its subprocess test proves natural exit and instruments `Server.listen()` so a transient bind cannot pass unnoticed.
- Layered real-path tests cover the CLI request, exact production/development prompts, shell runtime facts, same-port static replacement, source watcher rebuild, host stat polling, and browser HMR under an unchanged page identity.
- PR evidence preserves screenshots from the original 3081 session and a real-model before/after GUI run; external browser, HTTP, process, and session-log observations carry acceptance.
## Lessons
- The agent must know hidden runtime prerequisites before it can guide the user; startup mode is application context, not tribal knowledge.
- HTTP readiness, build success, and a boot manifest are different facts. Acceptance names the exact origin and externally observes the requested change there.
- A replacement service cannot prove that an existing page changed. Long-running processes use managed task lifecycles when they are actually requested.
- A regression test must be able to fail for the reported mechanism. Process timeout is not equivalent to fail-fast, and post-exit port availability does not prove the port was never bound.

View File

@@ -0,0 +1,53 @@
# 事故复盘postmortem 0003Web agent智能体验收了替代服务器而非其当前 GUI
[English](0003-web-agent-gui-feedback-loop.md) | 中文
Status: resolved
## 摘要
Web agent 修改了 GUI 源码,却不知道由哪个 URL 和进程承载当前会话。它把验收交还给用户,随后在 `window.__DSH_BOOT__` 缺失导致白屏的情况下,仍把裸 Vite 返回的 HTTP 200 当作成功;最后,原页面其实已经加载了重建产物,它却去验收另一个端口上的替代 `dsh web` 服务器。修复让当前 URL 和运行模式对模型可见且可由 shell 查询,在独立 Vite 开始监听前拒绝启动,并依据外部状态验收生产模式刷新与开发模式 HMR热模块替换
## 概述
该会话运行在端口 3081 的 DeepSeek Harness Web GUI 中,而用户选择的 Workspace 是空的 `test/` 目录。模型请求既未指明该 GUI也未提供它的源码检出目录、URL、进程或更新模式。仓库在 `apps/web` 中提供了 Vite 开发脚本,完整的浏览器组合则由 `dsh web` 提供。
由此产生的各个动作单看都合理却没有指向同一个验收目标。源码修改、成功构建、HTTP 200、注入的启动 manifest元数据清单和用户原本打开的页面被当成了可以相互替代的事实。
证据源是 `session-3eb796c2-5159-4686-affe-df8719f6f987` 的持久化事件日志,其头部记录的 cwd 为 `/Users/tn.shen/Documents/deepseek-harness-gui-master/test`。初始请求头位于序列 6面向用户的交接、裸 Vite 启动、替代宿主启动、启动 manifest 探测,以及首次探测 3081 进程,分别位于序列 30939、31865、34309、34441 和 34681。下方时间线以这些事件为依据而不是根据后续报告反推意图。
## 影响
用户不得不连续指出三个错误agent 把验收交还给用户;建议预览的页面一片空白;报告成功的 URL 并不是用户正在使用的页面。一个不受管理的替代服务器还持续运行到下一轮,直到用户提出质疑。
本次调查没有重启或修改只读的 3081 和 3082 试验服务。
## 时间线
- 在第 2 轮中agent 修改主题后,在序列 30939 的消息中让用户运行 `pnpm run demo:tui` 或打开一个未明确指定的 Web 应用。它没有对组装后的 Web 应用执行任何验收。
- 在第 3 轮中agent 读取 `apps/web/package.json`,在序列 31865 于端口 5173 上启动裸 Vite观察到 HTTP 200 后便宣布成功。浏览器却抛出 `client-modules: window.__DSH_BOOT__ is missing or not an object`,并显示白屏。
- 在第 4 轮中agent 找到了完整的 `dsh web` 启动路径,重新构建 shell在序列 34309 于端口 3334 上启动一个不受管理的进程,并且只在序列 34441 检查了这个替代服务是否返回 200 和启动 manifest。它从未探测端口 3081。
- 在第 5 轮中,用户在序列 34556 报告 3081 已经显示新主题。直到序列 34681agent 才检查既有进程并移除冗余服务器。
## 根因
Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确表示了用户选择的 Workspace但模型误把这个项目边界当成了应用边界。系统也没有持久契约将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。
裸 Vite 返回 HTTP 200使错误的启动路径看似合理。`window.__DSH_BOOT__` 只由完整宿主注入,因此传输层就绪不代表应用已就绪。首个回归测试以另一种方式重复了同样的错误:超时机制终止 Vite 后,非零退出断言仍会通过。真实复现暴露了这一误报。
agent 还通过 shell `&` 绕过了后台进程语义,因此任务身份、完成通知、结果收集和清理机制均未生效。验证端口 3334 只能证明第二个服务可以工作。
## 已添加的防护措施
- Web 启动器在记录到日志的 `app:web-surface` 提示词区段,以及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 环境变量中,发布规范环回 URL 和实际的生产/开发模式。
- 生产模式指南要求重新构建产物,并在刷新后验证既有 URL。开发模式指南说明`dsh web --dev` 只挂载 HMR 接收端;同一源码检出目录中的 `pnpm run dev:web` 还必须重新构建客户端插件 bundle而 Web shell 和普通包的改动仍然需要刷新页面。
- `apps/web` 的独立 Vite 服务模式会在配置阶段拒绝启动。其子进程测试验证进程自然退出,并插桩 `Server.listen()`,确保短暂绑定端口也不会漏检。
- 分层的真实路径测试覆盖 CLI命令行界面请求、精确的生产开发模式提示词、shell 运行时事实、同端口静态产物替换、源码 watcher 重建、宿主 stat 轮询,以及页面 identity 不变的浏览器 HMR。
- PRPull Request证据保留了原始 3081 会话的截图,以及真实模型驱动的 GUI 修改前后对比验收以外部浏览器、HTTP、进程和会话日志的观测结果为准。
## 教训
- agent 必须先知道隐藏的运行时前置条件,才能指导用户;启动模式属于应用上下文,不应依赖团队口口相传。
- HTTP 就绪、构建成功和启动 manifest 是不同的事实。验收必须明确指定确切的 origin并从外部观察所请求的改动是否在该 origin 生效。
- 替代服务无法证明既有页面已经改变。确实收到启动长时间运行进程的请求时,应使用受管任务生命周期。
- 回归测试必须能够针对所报告的机制失败。进程超时不等同于快速失败,进程退出后端口可用也不能证明该端口从未被绑定。

View File

@@ -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/postmortem/README.md
README.md: df0e2fcb8540aeed005153dbecc451d781ca5ff1
README.zh.md: e364ef30e342f8484a40f35e3970dea0f2f86ef3
README.md: 4858f8841e92a895f2d1a840b59b42758e83d952
README.zh.md: 127eb19422f1eb6738c1d246791064096f4382f9

View File

@@ -14,3 +14,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus
|---|---|
| [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` |
| [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object |
| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent validated a replacement server instead of the GUI hosting its session |

View File

@@ -14,3 +14,4 @@
|---|---|
| [0001](0001-acp-default-export-drops-inject.md) | ACPAgent Client Protocol服务器在连接时崩溃`export default` 丢失了插件的 `inject` |
| [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 |
| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent 验证了替代服务器,而非承载其会话的 GUI |

View File

@@ -1,6 +1,9 @@
- id: cli-mock-llm
name: './cli-mock-llm.ts'
- id: repository-plugin-fixture
name: './repository-plugin/load.mjs'
- id: base
name: '@cordisjs/plugin-include'
config:
@@ -16,4 +19,8 @@
model: cli-mock
persistenceRoot: './.sessions'
workspaceContext: false
dshHome: './.dsh-home'
skills:
local:
agentsHome: './.agents-home'
persona: 'Keyless headless-agent smoke.'

View File

@@ -0,0 +1,6 @@
---
name: repository-fixture
description: Repository fixture skill.
---
Static instructions from a prepared repository plugin.

View File

@@ -0,0 +1,9 @@
// Generated by dsh-plugin-prepare. Do not edit.
const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]}
export const name = "headless-repository-fixture"
export const inject = ["loader","skills"]
export async function apply(ctx) {
const runtime = ctx.loader.builtins["dsh-repository-plugin"]
if (runtime === undefined) throw new Error("missing Cordis builtin dsh-repository-plugin")
await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })
}

View File

@@ -0,0 +1,13 @@
/**
* Keyless fixture owner that mounts the runtime before its prepared wrapper.
* Cordis starts sibling Loader entries concurrently, so row order is not a dependency edge.
*/
import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin'
import * as PreparedPlugin from './dsh-plugin.mjs'
export const name = 'headless-repository-fixture-loader'
export async function apply(ctx) {
await ctx.plugin(RepositoryPlugin)
await ctx.plugin(PreparedPlugin)
}

View File

@@ -1,10 +1,12 @@
import { readFile, readdir } from 'node:fs/promises'
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { zstdDecompress } from 'node:zlib'
import { promisify } from 'node:util'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
@@ -36,6 +38,17 @@ describe('headless-agent keyless smoke', () => {
const result = lines.at(-1)
expect(stderr).toBe('')
expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true)
const catalogMessage = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'dsh-tool-skill')
const catalog = catalogMessage?.type === 'user/message'
? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n')
: ''
expect(catalog.split('\n').find(line => line.includes('repository-fixture'))).toMatchInlineSnapshot(
`
"- \`repository-fixture\`: Repository fixture skill."
`,
)
const toolResult = events.find(event => event.type === 'tool/result')
expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP')
expect(result).toMatchObject({
@@ -48,4 +61,28 @@ describe('headless-agent keyless smoke', () => {
expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP')
expect(persistedHeader).toMatchObject({ type: 'session' })
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => {
// The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the
// claim true — a wrapper-template change fails here until the fixture is
// regenerated, so the assembled smoke can never exercise a stale shape.
const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url))
const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-'))
try {
const plugin = join(root, '.dsh-plugin')
await mkdir(plugin, { recursive: true })
await cp(join(fixture, 'dsh-plugin-assets/skills/0'), join(root, 'skills'), { recursive: true })
await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
name: 'headless-repository-fixture',
version: '0.0.0',
dsh: { skills: ['../skills'] },
}, undefined, 2)}\n`)
await prepareDshPlugin(plugin)
const generated = await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
const checkedIn = await readFile(join(fixture, PREPARED_ENTRY_FILENAME), 'utf8')
expect(checkedIn).toBe(generated)
} finally {
await rm(root, { recursive: true, force: true })
}
})
})

View File

@@ -1,3 +1,3 @@
dsh-cli-demo: dsh-cli-demo: 1 entry did not activate
./activation-error.mjs: Error: startup activation snapshot failure
dsh-cli-demo: dsh-cli-demo: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure
Error: startup activation snapshot failure
at activation-error-fixture

View File

@@ -184,6 +184,8 @@ describe('jsonrpc-agent keyless smoke', () => {
expect(exitCode, stderr).toBe(1)
expect(stdout).toBe('')
expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc')
expect(stderr).toContain('plugin tree failed to load')
expect(stderr).toContain('failed to apply loader entry jsonrpc (@deepseek-ai/dsh-jsonrpc)')
expect(stderr).toContain('sometimes')
}, 30_000)
})

View File

@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-pty": "workspace:*",
"@deepseek-ai/dsh-pty-local": "workspace:*",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
"@deepseek-ai/dsh-repository-plugin": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:*",

View File

@@ -53,8 +53,7 @@
"**/*.ts"
],
"ignoreDependencies": [
"@deepseek-ai/.+",
"@cordisjs/.+"
"@deepseek-ai/.+"
]
},
"packages/util/home": {
@@ -631,8 +630,7 @@
"tests/**/*.ts"
],
"ignoreDependencies": [
"@deepseek-ai/.+",
"@cordisjs/.+"
"@deepseek-ai/.+"
]
},
"packages/client/modules": {

View File

@@ -77,6 +77,7 @@
"verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts",
"verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
"verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
"verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
"verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts",
"gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
@@ -100,7 +101,7 @@
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts",
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links",
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
"demo:tui": "node --import tsx/esm apps/cli/src/bin.ts",

View File

@@ -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/README.md
README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b
README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462
README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863
README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01

View File

@@ -33,7 +33,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |

View File

@@ -33,7 +33,7 @@
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 产品:稳定表面 |
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin以及受限 repository Plugin 加载 | 产品:稳定表面 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定表面 |
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |

View File

@@ -354,6 +354,9 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
} as const
export function apply(ctx: Context, config: Config = {}): void {
// FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell
// environment plugin; replacing this tool with persistent Bash must not
// remove the managed DSH_* contributor seam.
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
name: 'session-persistence',

View File

@@ -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/cordis/README.md
README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365
README.zh.md: 3ee1ddb1db28352cd05b3e79e88228035bc39ac4
README.md: 485a6ce7858a77507c07b76138127faa411b354b
README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066

View File

@@ -1,9 +1,10 @@
# packages/cordis — the self-referential runtime toolset
# packages/cordis — Cordis runtime integration
English | [中文](README.zh.md)
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
Plugins that integrate Harness-owned formats with the Cordis runtime: the self-referential model toolset and the restricted repository Plugin runtime.
| Package | Role | ctx key |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` |
| [`repository-plugin/`](repository-plugin/README.md) | Prepare and mount static repository skills plus common `.mcp.json` servers through DSH-owned child Plugins | registers a Loader builtin |

View File

@@ -1,9 +1,10 @@
# packages/cordis自指运行时工具集
# packages/cordisCordis 运行时集成
[English](README.md) | 中文
这些面向模型的工具作用于 agent智能体自身所在的实时 Cordis 运行时,可检查已加载的插件和服务接口、挂载模型编写的插件,并将其 dispose资源释放。设计说明见[工具集 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)
这些 Plugin 把 Harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository Plugin 运行时
| 包package | 角色 | ctx 键 |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` |
| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin |

View File

@@ -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 packages/cordis/repository-plugin/README.md
README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7
README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797

View File

@@ -0,0 +1,102 @@
# @deepseek-ai/dsh-repository-plugin
English | [中文](README.zh.md)
Restricted repository Plugin format for DeepSeek Harness. A repository author declares static skill roots and an optional common `.mcp.json` in `.dsh-plugin/package.json`; the prepare helper copies those assets and emits a fixed import-free Cordis wrapper. The runtime wrapper can only delegate to this DSH-owned package, which composes [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [static repository Plugin format Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md).
## Authoring format
Place an ordinary package in the repository's `.dsh-plugin` directory:
```json
{
"name": "humanize-dsh-plugin",
"version": "0.0.0",
"private": true,
"scripts": {
"prepare": "dsh-plugin-prepare"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
},
"dsh": {
"skills": ["../skills"],
"mcpServers": "../.mcp.json"
}
}
```
`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
## Standalone app configuration
The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`):
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
```
Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root.
The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
## Preparation
`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point.
The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source.
## Runtime composition
Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. The runtime validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped (a `files`/`.npmignore` mistake, a damaged cache entry) fails the plugin load instead of silently mounting a skill-less plugin. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown.
## Common MCP format
The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`.
Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle; a network or child-process connection failure retains that client's established log-and-no-tools behavior.
## Export shape
Namespace Plugin: named exports `name` / `inject` / `apply`, preparation constants, and `prepareDshPlugin`; no default export. The package also exposes the `dsh-plugin-prepare` executable and an invariant companion.
## Model Experience
### Repository skills
#### What the model sees
Indirectly through `dsh-tool-skill`: prepared, model-invocable skills join its logged catalog and selected instruction-body surface under their declared names and descriptions. The exact consumer schema is in the generated [`skill` tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill).
#### Token effect
Conditional and data-dependent: each visible repository skill adds one capped catalog row; loading one adds its full current instruction body and resource-base guidance to retained tool history.
#### KV Cache effect
A stable prepared Plugin set is prefix-stable. Adding, removing, or replacing a repository Plugin can append the consumer's replacement catalog and affect later request prefixes.
### Repository MCP tools
#### What the model sees
Indirectly through `dsh-mcp-client`: every connected server contributes its server-qualified tool schemas, and calls retain that client's canonical MCP results and rendering.
#### Token effect
Conditional on successful connection and the remote tool list; schemas recur on requests in the active tool view, while calls and results remain in history until compaction.
#### KV Cache effect
Stable connected tool lists are prefix-stable. Plugin lifecycle or MCP tool-list changes can change later tool-schema prefixes from the first affected definition.
## Known Limitations and Deferred Work
- **Skills and MCP only** — commands, hooks, agents, apps, arbitrary Cordis code, marketplaces, and compatibility shims are intentionally outside this format.
- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here.
- **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation.

View File

@@ -0,0 +1,102 @@
# @deepseek-ai/dsh-repository-plugin
[English](README.md) | 中文
这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill 根和可选的通用 `.mcp.json`prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository Plugin 格式 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。
## 创作格式
在仓库的 `.dsh-plugin` 目录中放置一个普通 package
```json
{
"name": "humanize-dsh-plugin",
"version": "0.0.0",
"private": true,
"scripts": {
"prepare": "dsh-plugin-prepare"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
},
"dsh": {
"skills": ["../skills"],
"mcpServers": "../.mcp.json"
}
}
```
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。
## 独立应用配置
已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
```
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`
TUI 和 Web 通过 Cordis HMR热模块替换监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation拉取、准备、导入或插件应用失败时最后一个可用树保持运行并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
## 准备阶段
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest元数据清单以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript运行时也不会导入仓库的其他入口。
外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。
## 运行时组合
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files``.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
## 通用 MCP 格式
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"``command``args``env`HTTP 条目只接受 `type: "http"``url``headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transportstdio 条目以已准备的 package 目录作为 `cwd`
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。
## 导出形状
Namespace Plugin具名导出 `name``inject``apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。
## 模型体验
### Repository skills
#### 模型看到什么
通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。
#### Token 影响
有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基准指引加入保留的工具历史。
#### KV Cache 影响
稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。
### Repository MCP 工具
#### 模型看到什么
通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema调用会保留该 client 的规范 MCP 结果和渲染。
#### Token 影响
取决于连接成功和远端工具列表schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。
#### KV Cache 影响
稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。
## 已知限制与延后工作
- **仅支持 skills 与 MCP**commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
- **生成资源是不可变运行时输入**repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-repository-plugin",
"description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-plugin-prepare": "./lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-mcp-client": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env node
/** Command-line entry that prepares the current `.dsh-plugin` package. @module */
import { prepareDshPlugin } from './format.ts'
try {
await prepareDshPlugin()
} catch (error) {
process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
}

View File

@@ -0,0 +1,193 @@
/**
* Static repository-plugin preparation and prepared-manifest validation.
* @module
*/
import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { z } from 'zod'
import { parseMcpDocument } from './mcp.ts'
/** Fixed module filename loaded from an installed prepared plugin package. */
export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
/** Fixed directory containing copied static plugin assets. */
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
/** Loader builtin used by every generated import-free wrapper. */
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
const sourceMetadataSchema = z.object({
skills: z.array(z.string().min(1)).default([]),
mcpServers: z.string().min(1).optional(),
}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, {
message: 'declare at least one skill root or mcpServers file',
})
const sourcePackageSchema = z.looseObject({
name: z.string().min(1),
dsh: sourceMetadataSchema,
})
const preparedManifestSchema = z.object({
name: z.string().min(1),
skills: z.array(z.string().min(1)),
mcpServers: z.string().min(1).optional(),
}).strict()
const preparedConfigSchema = z.object({
// Wrappers pass import.meta.url, which is always file: for an installed
// package; any other scheme would only fail later inside fileURLToPath with
// an uncontextualized TypeError, so reject it at this validation boundary.
baseUrl: z.url({ protocol: /^file$/ }),
manifest: preparedManifestSchema,
}).strict()
/** Static manifest embedded in the generated wrapper. */
export interface PreparedPluginManifest {
name: string
skills: string[]
mcpServers?: string
}
/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */
export interface PreparedPluginConfig {
baseUrl: string
manifest: PreparedPluginManifest
}
function formatZodError(label: string, error: z.ZodError): Error {
return new Error(`${label}:\n${z.prettifyError(error)}`)
}
/**
* Validate the config passed by an installed prepared wrapper.
* @param value - wrapper-provided value crossing the file/module boundary.
* @returns a detached typed config.
*/
export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig {
const result = preparedConfigSchema.safeParse(value)
if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error)
return {
baseUrl: result.data.baseUrl,
manifest: {
name: result.data.manifest.name,
skills: result.data.manifest.skills,
...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers },
},
}
}
/**
* Whether `candidate` resolves outside `root` — the containment check shared
* by prepare-time asset copying and runtime prepared-path resolution.
* @param root - directory that must contain the candidate.
* @param candidate - absolute path to test.
* @returns true when the candidate escapes the root.
*/
export function isOutside(root: string, candidate: string): boolean {
const path = relative(root, candidate)
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
}
async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise<string> {
if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`)
let path: string
try {
path = await realpath(resolve(pluginDirectory, configured))
} catch (cause) {
throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause })
}
if (isOutside(sourceRoot, path)) {
throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`)
}
const info = await stat(path)
if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) {
throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`)
}
return path
}
function wrapperSource(manifest: PreparedPluginManifest): string {
// The manifest is static, so the wrapper's service dependencies are too:
// declaring them gates the wrapper fiber until the composition provides
// them, which means the runtime's SkillLocal/McpClient children activate
// within the wrapper's own load epoch and their failures (duplicate
// provider names, damaged packages) reject the wrapper's Loader
// transaction instead of leaving a silently PENDING or FAILED child.
const inject = [
'loader',
...manifest.skills.length > 0 ? ['skills'] : [],
...manifest.mcpServers === undefined ? [] : ['tools'],
]
return [
'// Generated by dsh-plugin-prepare. Do not edit.',
`const manifest = ${JSON.stringify(manifest)}`,
`export const name = ${JSON.stringify(manifest.name)}`,
`export const inject = ${JSON.stringify(inject)}`,
'export async function apply(ctx) {',
` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`,
` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`,
' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })',
'}',
'',
].join('\n')
}
/**
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
* Outputs are staged and committed by rename, but the final publish (remove
* old outputs, rename assets, rename entry) is not one atomic step: a crash
* mid-publish can leave assets without an entry or neither. Rerunning prepare
* repairs the package; partial outputs are never importable as a plugin.
* @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
* @returns the generated static manifest.
*/
export async function prepareDshPlugin(directory: string = process.cwd()): Promise<PreparedPluginManifest> {
const pluginDirectory = await realpath(resolve(directory))
let packageValue: unknown
try {
packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown
} catch (cause) {
throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause })
}
const parsed = sourcePackageSchema.safeParse(packageValue)
if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error)
const sourceRoot = await realpath(dirname(pluginDirectory))
const skillSources: string[] = []
for (const configured of parsed.data.dsh.skills) {
const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory')
if (!isOutside(source, pluginDirectory)) {
throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`)
}
skillSources.push(source)
}
let mcpSource: string | undefined
if (parsed.data.dsh.mcpServers !== undefined) {
mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file')
parseMcpDocument(await readFile(mcpSource, 'utf8'))
}
const manifest: PreparedPluginManifest = {
name: parsed.data.name,
skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`),
...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` },
}
const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-'))
try {
const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY)
await mkdir(join(stagedAssets, 'skills'), { recursive: true })
await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), {
recursive: true,
force: false,
errorOnExist: true,
})))
if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json'))
await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest))
await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true })
await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true })
await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY))
await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME))
} finally {
await rm(staging, { recursive: true, force: true })
}
return manifest
}

View File

@@ -0,0 +1,145 @@
/**
* Restricted repository-plugin runtime for static skills and common MCP definitions.
* @module @deepseek-ai/dsh-repository-plugin
*/
import { readFile, stat } from 'node:fs/promises'
import { dirname, isAbsolute, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type {} from '@cordisjs/plugin-loader'
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
import { z } from 'zod'
import {
REPOSITORY_PLUGIN_BUILTIN,
isOutside,
parsePreparedPluginConfig,
type PreparedPluginConfig,
} from './format.ts'
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
import {
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
} from './source.ts'
export {
PREPARED_ASSET_DIRECTORY,
PREPARED_ENTRY_FILENAME,
REPOSITORY_PLUGIN_BUILTIN,
prepareDshPlugin,
type PreparedPluginManifest,
} from './format.ts'
/** Cordis plugin name used by Loader diagnostics. */
export const name = 'repository-plugin'
/** Loader service required to register the fixed prepared-wrapper builtin. */
export const inject = ['loader']
/** Repository Plugin runtime and source-list configuration. */
export interface Config {
/** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */
repositories?: string[]
/** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */
cacheDir?: string
}
export const Config = z.object({
repositories: z.array(z.string().min(1)).default([]),
cacheDir: z.string().min(1).optional(),
}).strict().default({ repositories: [] })
function preparedPath(baseUrl: string, configured: string): string {
if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`)
const directory = dirname(fileURLToPath(baseUrl))
const path = resolve(directory, configured)
if (isOutside(directory, path)) {
throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`)
}
return path
}
async function preparedDirectory(baseUrl: string, configured: string): Promise<string> {
const path = preparedPath(baseUrl, configured)
// A manifest-declared skill root missing from the installed package (files/
// .npmignore dropping generated outputs, a damaged cache entry) must fail
// the plugin load: the skill provider treats an absent root as legitimately
// empty, which would silently mount a skill-less plugin.
let info
try {
info = await stat(path)
} catch (cause) {
throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause })
}
if (!info.isDirectory()) {
throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`)
}
return path
}
async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise<void> {
const config = parsePreparedPluginConfig(value)
const directory = dirname(fileURLToPath(config.baseUrl))
const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path)))
const mcpConfigs = config.manifest.mcpServers === undefined
? []
: resolveMcpServers(
parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')),
process.env,
directory,
// Schemastery call signatures collapse the parameter to `never` under
// NodeNext; ResolvedMcpServer is shaped for the Config union by design.
).map(input => McpClient.Config(input as never))
await ctx.effect(async function* () {
if (skillDirectories.length > 0) {
const skills = ctx.plugin(SkillLocal, {
providerName: `repository:${config.manifest.name}`,
includeDefaultRoots: false,
customSkillDirs: skillDirectories,
watch: false,
})
await skills
yield skills.dispose
}
for (const mcpConfig of mcpConfigs) {
const mcp = ctx.plugin(McpClient, mcpConfig)
await mcp
yield mcp.dispose
}
}, `repository-plugin(${config.manifest.name})`)
}
const preparedRuntime = {
name: 'repository-plugin-runtime',
apply: applyPrepared,
}
/**
* Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers.
* @param ctx - plugin context carrying the Loader service.
*/
export async function apply(ctx: Context, config: Config = {}): Promise<void> {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
}
const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier)
if (new Set(repositories).size !== repositories.length) {
throw new Error('repository sources must resolve to unique exact specifiers')
}
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir))
await ctx.effect(async function* () {
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
yield () => {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
}
}
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`.
* @module @deepseek-ai/dsh-repository-plugin/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin'
/** Cordis companion plugin name. */
export const name = 'repository-plugin-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the package owns no service state; Loader fibers and the existing skill
* and MCP owners expose the authoritative lifecycle relationships for its composed children.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

Some files were not shown because too many files have changed in this diff Show More