diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml new file mode 100644 index 0000000000..6c888c7cad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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 diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md new file mode 100644 index 0000000000..f8a6706065 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md new file mode 100644 index 0000000000..b1fea3d655 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md @@ -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` 脚本,并在不访问网络的情况下,从已安装缓存项中读取准备后的文件。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml new file mode 100644 index 0000000000..6319b99fb2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md new file mode 100644 index 0000000000..c9d755b925 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -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:` 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. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md new file mode 100644 index 0000000000..361de64d2e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -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:` 提供方名称、仅包含复制后的自定义根,并禁用监视。因此 `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 transport,stdio 使用已准备 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 内部实现,并重复普通 SDK/Plugin 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 配置拥有两套失败契约。 + +## 后果 + +- 现有 skill/MCP 仓库可以新增一个很小的 `.dsh-plugin/package.json`,无需移动资源或采用 SDK 项目。 +- 已准备输出是确定性的静态胶水;已配置仓库及其依赖生命周期仍是受信任的可执行 package-manager 输入,而非 sandbox。 +- 多个 repository Plugin 通过提供方名称和普通 MCP server-name 唯一性共存;重复名称经现有 registry 失败,并参与 Loader 回滚。 +- Cache 内的源码编辑不会实时出现;必须选择另一个精确 source/ref/path/config。 +- 新增贡献类型必须提供显式格式和 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 写入日志的模型目录行。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml index b16ef70d7c..6f4a6d363d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md index 1a8e29c603..0f15bb0aaa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -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 ` 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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md index 6c7a421bfa..8a185c1915 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -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 `,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 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 9e8573a79d..e4e9dfb93a 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 259c3865a9..1fa8cda2b3 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 8f7c15c3c6..e70b8914cf 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -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 的 overlay(Code 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。 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index ce1ef3af95..b11dc50cd6 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md -2026-07-24-model-facing-session-query-tools.md: 82fb70349a94916af2e99b83fcbdac765aae3dd0 -2026-07-24-model-facing-session-query-tools.zh.md: 3ffc142b2a27c612bb8a3238823f536871e5ea17 +2026-07-24-model-facing-session-query-tools.md: 863f557f11f89ff8dfc121b7da0b653852528394 +2026-07-24-model-facing-session-query-tools.zh.md: d8deaba15f111537a16deafe73ed6dd708ea044a diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 82fb70349a..863f557f11 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -36,7 +36,7 @@ Session-level results include the latest folded title when available. Each tool ## Host composition -The consumer is an opt-in plugin. The shipped TUI, Web, and headless compositions mount both `ctx.sessionQuery` and `@deepseek-ai/dsh-tool-session-query` through their shared base, so their default model requests include the query prompt and five schemas; the automation-only ACP composition mounts neither. These compositions also supply the generic timeout and spill policies. The dedicated ACP snapshot fixture mounts the consumer and both policies explicitly, with private local spill storage. Generic tool presentation requires no session-query-specific client plugin. +The consumer is an opt-in plugin. Shipped host compositions do not mount it: the shipped TUI, Web, and headless surfaces keep the `ctx.sessionQuery` index (the SQLite service behind `/resume` and the Web content search) but not the model-facing consumer, so their default requests carry neither the query prompt nor the five schemas; the automation-only ACP composition also mounts neither ([session-search-not-shipped-default](2026-08-02-session-search-not-shipped-default.md)). These compositions also supply the generic timeout and spill policies. The dedicated ACP snapshot fixture mounts the consumer and both policies explicitly, with private local spill storage. Generic tool presentation requires no session-query-specific client plugin. ## Alternatives considered @@ -48,7 +48,7 @@ The consumer is an opt-in plugin. The shipped TUI, Web, and headless composition ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration and the TUI/Web composition tests prove that the model-facing consumer is present on the TUI, Web, and headless surfaces, while assembled ACP request-header snapshots prove that the automation surface omits it by default. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration and the TUI/Web composition tests prove that the model-facing consumer is absent from the TUI, Web, and headless surfaces, while assembled ACP request-header snapshots prove that the automation surface omits it by default. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 3ffc142b2a..d8deaba15f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 宿主组合 -该消费方是一个需显式启用的插件。已交付的 TUI、Web 与无头组合通过共享 base 同时挂载 `ctx.sessionQuery` 和 `@deepseek-ai/dsh-tool-session-query`,因此其默认模型请求包含查询提示词与五个 schema;仅用于自动化的 ACP 组合两者均不挂载。这些组合还提供通用的超时与 spill 策略。专用的 ACP 快照 fixture(测试前置数据)显式挂载该消费方与这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 +该消费方是一个需显式启用的插件。已交付的宿主组合不挂载它:已交付的 TUI、Web 与无头界面保留 `ctx.sessionQuery` 索引(即 `/resume` 与 Web 内容搜索背后的 SQLite 服务),但不挂载面向模型的消费方,因此其默认请求既不携带查询提示词,也不携带五个 schema;仅用于自动化的 ACP 组合也两者均不挂载([session-search-not-shipped-default](2026-08-02-session-search-not-shipped-default.md))。这些组合还提供通用的超时与 spill 策略。专用的 ACP 快照 fixture(测试前置数据)显式挂载该消费方与这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置与 TUI/Web 组合测试证明面向模型的消费方存在于 TUI、Web 与无头界面,而组装后的 ACP 请求头快照证明自动化界面默认不包含它。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置与 TUI/Web 组合测试证明面向模型的消费方不存在于 TUI、Web 与无头界面,而组装后的 ACP 请求头快照证明自动化界面默认不包含它。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml new file mode 100644 index 0000000000..1491968f15 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md new file mode 100644 index 0000000000..2057125fc7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md @@ -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#` 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. diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md new file mode 100644 index 0000000000..6e741b46be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md @@ -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#`,并可追加 `&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 测试通过真实 HMR/Include/Loader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 3bcf323e4a..4502aa230f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 67ca2759f6d9de3063c80e091d12bd51e2449b58 -2026-07-31-even-out-shipped-tool-rosters.zh.md: 92627eff2bc2f055762b95118e4abe940da03a91 +2026-07-31-even-out-shipped-tool-rosters.md: e325f4614f8d7305ce2c6199a25afd56b51fad61 +2026-07-31-even-out-shipped-tool-rosters.zh.md: a9b49c454d78387583aa7dd9e25f5d5c850a15ae diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 67ca2759f6..e325f4614f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,11 +12,11 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces now assemble the same roster: twenty-seven tools on every host — the twenty-five shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty-two tools on every host — the twenty shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. -**This roster decision adds only.** No tool row is removed from either surface, and a catalog comparison finds additions and nothing else. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). +**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. One of those additions, `tool-session-query`, was subsequently removed by the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md). The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). ### What stays unmounted, and why @@ -62,8 +62,8 @@ Beyond the committed tests, both surfaces were driven against a real key from th ## Consequences -The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert all twenty-seven names exactly on both sides, so a later change that alters only one surface fails a check instead of shipping quietly. +The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty unconditional names exactly and pin `glob` and `grep` as fixed members on both sides, so a later change that alters only one surface fails a check instead of shipping quietly; the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) is exactly such a later change, and both tests moved with it. -`apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. +`apps/cli` gained five workspace dependencies: four the shipped tree mounted, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. Four remain — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) removed `@deepseek-ai/dsh-tool-session-query` along with its row. Execution policy stays independent of the roster. The [shared workspace-write decision](2026-07-31-workspace-write-surface-default.md) owns both surfaces' sandboxed executors and default permission; changing that policy does not add or remove a tool. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index 92627eff2b..a9b49c454d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 现在组装同一份清单:每台宿主上都有二十七个工具——二十五个共享行加上 `glob` 和 `grep`,它们成为固定成员是因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十二个工具——二十个共享行加上 `glob` 和 `grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 -**本次工具清单决策只做加法。** 两个 surface 均未移除任何工具行,目录对比只会发现新增,别无其他。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 +**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。这些新增中的一项 `tool-session-query` 随后被[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)移除。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 ### 什么保持不挂,以及为什么 @@ -46,7 +46,7 @@ Status: implemented [`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) 在构建产物 lane 中覆盖 Web surface,断言它的工具目录、它的访问默认值未被触碰,以及 `workspace-write` 的可写根包含临时目录——一个会让沙箱测试说谎的陷阱,当工作区落在 `/tmp` 下时([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts))。 -`glob` 与 `grep` 被作为全有或全无的一对断言,而不是固定成员:`dsh-tool-fs-search` 在加载时探测 `command -v rg`,没有 ripgrep 就两个工具都不注册,这是宿主依赖。 +`glob` 与 `grep` 被作为固定成员断言,而不是一对宿主依赖:`dsh-tool-fs-search` spawn 打包的 ripgrep 二进制并无条件注册两个工具,因此这一对始终在场。 除入库测试外,两个 surface 都以 plain Node 从构建产物 `apps/cli/lib/bin.js` 出发、用真实密钥驱动过。每一个已挂载的工具都执行成功,包括 `ralph` 与 `web_search`;模型从未触达 `cordis_*` 或 `mcp_*`,被要求做 LSP 跳转时退化到 `grep`,被要求开持久终端时用了后台 `bash` 任务。 @@ -62,8 +62,8 @@ Status: implemented ## 后果 -同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言两侧全部二十七个名称,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去。 +同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十个无条件提供的名称,并把 `glob` 与 `grep` 作为固定成员钉在两侧,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去;[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)正是这样一次后来的改动,两个测试也随之移动。 -`apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 +`apps/cli` 增加了五个 workspace 依赖:四个是交付树当时挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。四个保留了下来——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)把 `@deepseek-ai/dsh-tool-session-query` 连同它的行一起移除了。 执行策略独立于工具清单。[共享 workspace-write 决策](2026-07-31-workspace-write-surface-default.md)拥有两个 surface 的沙箱执行器与默认权限;更改该策略不会增加或移除工具。 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml new file mode 100644 index 0000000000..4a9a16de25 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md +2026-08-02-session-search-not-shipped-default.md: ba7299712c0ba3db5e807e928f6f5d98ac917187 +2026-08-02-session-search-not-shipped-default.zh.md: 1678ebfb5514003eabe0221e460c619bab1aa444 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md new file mode 100644 index 0000000000..ba7299712c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md @@ -0,0 +1,25 @@ +# Agent Note: Session search tools are not a shipped default + +Status: implemented + +English | [中文](2026-08-02-session-search-not-shipped-default.zh.md) + +## Problem + +The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made `tool-session-query` a default row of the shared [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), so the shipped TUI and Web surfaces put the five session-search tools (`session_search`, `session_event_search`, `session_trace`, `session_event_trace`, `session_event_read`) in front of the model. That contradicted the [model-facing session-query-tools decision](2026-07-24-model-facing-session-query-tools.md), whose opt-in stance the package README recorded as "shipped host compositions do not mount it by default". The default also shipped a prompt section teaching a prior-work search workflow that no user had asked for. + +## Decision + +The shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `base.cordis.yml`, the now-dangling `disabled` patch in the opt-in [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile goes with it, and the workspace dependency drops from `apps/cli/package.json`. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. + +The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed. + +## Alternatives considered + +- **Remove the `session-query-sqlite` index too** — rejected because `/resume` and the Web content-search box consume `ctx.sessionQuery` directly; those are host features, not model tools, and dropping the provider would break them. +- **Keep the row but disable it in each overlay** — rejected because a disabled base row still ships the dependency and invites a one-line re-enable; the recorded opt-in stance wants the consumer absent from shipped surfaces, with the ACP example as the mount reference. +- **Mount it on the TUI only** — rejected because the shared base is one row set for every surface; a surface-specific mount would reintroduce the roster split the shipped-roster decision removed. + +## Consequences + +Both surfaces return to the same twenty unconditional tools (plus `glob`/`grep` under ripgrep), and the five session-search schemas and their prompt section leave the default request. The shipped-composition tests on both surfaces pin the smaller catalog, so re-adding session search as a default touches the same tests. Users who want session search mount the consumer from a personal overlay or the ACP example, adding the dependency where they do. diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md new file mode 100644 index 0000000000..1678ebfb55 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 会话搜索工具不是交付默认项 + +Status: implemented + +[English](2026-08-02-session-search-not-shipped-default.md) | 中文 + +## 问题 + +[交付清单决策](2026-07-31-even-out-shipped-tool-rosters.md)把 `tool-session-query` 设为共享 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) 的默认行,于是交付的 TUI 与 Web surface 把这五个会话搜索工具(`session_search`、`session_event_search`、`session_trace`、`session_event_trace`、`session_event_read`)呈现给了模型。这与[面向模型的会话查询工具决策](2026-07-24-model-facing-session-query-tools.md)相抵触,该决策持需显式启用的立场,包 README 将其记录为「shipped host compositions do not mount it by default」。这份默认还交付了一个提示词段,向模型讲授一套既往工作搜索工作流,而没有任何用户要求过。 + +## 决策 + +交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `base.cordis.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP 示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 + +`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 + +## 曾考虑的替代方案 + +- **把 `session-query-sqlite` 索引也一并移除**——否决,因为 `/resume` 和 Web 内容搜索框直接消费 `ctx.sessionQuery`;它们是宿主功能,不是模型工具,移除提供方会破坏它们。 +- **保留该行,但在每个 overlay 中禁用它**——否决,因为一条被禁用的 base 行仍会交付依赖,而且一行就能轻易重新启用;已记录的 opt-in 立场要求消费方不出现在交付的 surface 上,以 ACP 示例作为挂载参考。 +- **只在 TUI 上挂载**——否决,因为共享 base 是所有 surface 共用的一组行;surface 专属挂载会重新引入交付清单决策所消除的清单分裂。 + +## 后果 + +两个 surface 都回到同样的二十个无条件工具(ripgrep 可用时再加上 `glob`/`grep`),五个会话搜索 schema 及其提示词段也一并退出默认请求。两个 surface 上的交付组合测试都固定这份更小的目录,因此把会话搜索重新作为默认加回会触及同样的测试。想要会话搜索的用户从个人 overlay 或 ACP 示例挂载该消费方,并在挂载处添加依赖。 diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml index 0e0c6693a2..93cf9ec401 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md index ae6f5438c5..ccc1289c8a 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md @@ -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). diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md index 8d6f0e39d5..9abea504d6 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md @@ -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 导入钩子未被收录)。 diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml index 2e9a5d6402..8bb5febd69 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md index e2097ac4c3..b2e644d998 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md @@ -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 diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md index a6c94b6673..5014fef644 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md @@ -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)则负责说明如何在依赖层之间传播修复。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml new file mode 100644 index 0000000000..96754a36f3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md new file mode 100644 index 0000000000..a349ed18a2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md @@ -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 --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. diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md new file mode 100644 index 0000000000..0205eb475b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md @@ -0,0 +1,43 @@ +# Agent Note: GitHub 原生堆叠与可选 PR rebase + +Status: implemented + +[English](2026-08-02-native-github-stacks-and-optional-rebases.md) | 中文 + +## 问题 + +仅以 base 分支表示的依赖 PR(Pull 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 --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 仍然可用,并以增加合并提交为代价保留已完成的检查点。 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml index 8b70484312..fab3ecc2b1 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml @@ -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 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md index 0f3ada6bdb..88d5d2f9bd 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -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 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md index d0d0b3e6bc..998b7ec3cf 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -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 与采集 diff --git a/.agents/skills/dsh-merging-stacked-prs/SKILL.md b/.agents/skills/dsh-merging-stacked-prs/SKILL.md index 50ceda2233..4bcb568955 100644 --- a/.agents/skills/dsh-merging-stacked-prs/SKILL.md +++ b/.agents/skills/dsh-merging-stacked-prs/SKILL.md @@ -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 --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= -F name= -F number= -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 ... +``` + +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 ` 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 --yes --merge +``` + +For an explicitly requested partial landing, merge through the boundary PR: + +```sh +gh stack merge --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 --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 --json number --jq length ``` -Anything other than `0` means open PRs still base on `` 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 --json state` shows `MERGED`. -- [ ] For each dependent: `gh pr edit --base master` → fetch and merge `origin/master` into the branch (resolve conflicts there, push) → `gh pr merge --merge`, no `--delete-branch`; again wait for `MERGED`. -- [ ] Before each branch delete: `gh pr list --state open --base --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. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index fe5de961a9..dd04cf9b33 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -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///src/.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=:` 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. diff --git a/AGENTS.md b/AGENTS.md index 9667432285..b7128ff89d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f4152cb8a0..03fd24e5b7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -67,6 +67,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 | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cfee5f6662..96a4588f2c 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 6b67cbfccd21c6f1c32b5bc9417ab71309898b24 -README.zh.md: 71e242398fe56616c2b146562a6c7ce74fb0f6e1 +README.md: 76d9ed65398322cb9244a31661ee59b60c23f793 +README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b diff --git a/apps/cli/README.md b/apps/cli/README.md index 6b67cbfccd..76d9ed6539 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -11,7 +11,7 @@ The TUI surface: - resumes a persisted session with `dsh --resume ` 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. @@ -25,6 +25,18 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then 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#' +``` + +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). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 71e242398f..16a7a4ec52 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -11,7 +11,7 @@ TUI 界面: - 使用 `dsh --resume ` 恢复已持久化会话。当 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)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 @@ -25,6 +25,18 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 共享组合把新建 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#' +``` + +仓库必须包含已准备的 `.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)。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cb6b98a3b0..c4deb098c4 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_timer plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] cfg --> plugin_tui_hmr + plugin_tui_repository_plugins["repository-plugins
@deepseek-ai/dsh-repository-plugin"] + cfg --> plugin_tui_repository_plugins plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_tui_llm plugin_tui_session["session
@deepseek-ai/dsh-session"] @@ -116,8 +118,6 @@ flowchart LR cfg --> plugin_tui_tool_goal plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] cfg --> plugin_tui_tool_ralph - plugin_tui_tool_session_query["tool-session-query
@deepseek-ai/dsh-tool-session-query"] - cfg --> plugin_tui_tool_session_query plugin_tui_tool_str_replace_editor["tool-str-replace-editor
@deepseek-ai/dsh-tool-str-replace-editor"] cfg --> plugin_tui_tool_str_replace_editor plugin_tui_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] @@ -144,6 +144,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` | @@ -196,7 +197,6 @@ flowchart LR | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `tool-goal` | `@deepseek-ai/dsh-tool-goal` | | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` | | `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` | | `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index e88cbdb39d..df7ed94258 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -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' @@ -309,12 +316,6 @@ subagentProvider: spawn maxRounds: 64 -- id: tool-session-query - name: '@deepseek-ai/dsh-tool-session-query' - config: - maxSearchResults: 100 - searchTimeoutMs: 30000 - - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index ef03b23fca..d025aef7f4 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -45,9 +45,6 @@ - id: tool-ralph disabled: true -- id: tool-session-query - disabled: true - - id: tool-str-replace-editor disabled: true diff --git a/apps/cli/package.json b/apps/cli/package.json index 2269ceaffb..58368f04d9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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:^", @@ -116,7 +117,6 @@ "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", - "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 7b182b4073..eaa1902eff 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -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 /** @@ -235,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, ...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 @@ -254,17 +263,30 @@ export class AppCLIEntry { // 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. */ diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 3ec2792e8e..e41bc03c6c 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -78,6 +78,7 @@ export async function runHeadless(task: string): Promise { 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() diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 93903b2fe6..32b767cb2c 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -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) { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 815a53a11d..4fbfba4d8d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -109,6 +109,7 @@ export async function runWeb( ...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 }, diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index 01b38b9074..6a374072fe 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -35,11 +35,6 @@ const EXPECTED_TUI_TOOLS = [ 'get_goal', 'ralph', 'read', - 'session_event_read', - 'session_event_search', - 'session_event_trace', - 'session_search', - 'session_trace', 'skill', 'str_replace_editor', 'subagent', diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 77e65e7d26..f61d37360b 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -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 { + 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 { 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', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 4774ca7cc8..b2ccd4a803 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -30,11 +30,6 @@ const EXPECTED_TOOLS = [ 'get_goal', 'ralph', 'read', - 'session_event_read', - 'session_event_search', - 'session_event_trace', - 'session_search', - 'session_trace', 'skill', 'str_replace_editor', 'subagent', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c568634985..e1c0d59e8c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 } ``` diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml index d75b8cad3e..86a72161b2 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml @@ -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 diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.md b/docs/cookbook/responding-to-pr-review-on-a-stack.md index 3fb7eb943e..6bb7be3daf 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.md @@ -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 ` 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 --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. diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md index d96323b853..94c2bd2cdd 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md @@ -2,25 +2,31 @@ [English](responding-to-pr-review-on-a-stack.md) | 中文 -评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。本指南说明如何在不破坏堆叠的前提下解决这些意见。它依赖的两个不变式是根 [AGENTS.md](../../AGENTS.md) § Conventions 中的常设指令:只用 merge commit,以及永远不改写已推送的分支。 +评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull 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 `,产生一个新的 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 --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 显示一个新 commit(PR 时间线中没有 force-push 图标)。 -- 每个子 PR 相对其父 PR 的 diff 仍然只包含自身的变更。 -- 门禁在堆叠中的每个 PR 上都通过,而不仅仅是顶部。 +- 每个已修复 PR 的当前 diff 都在引入问题的那一层包含预期修正。 +- GraphQL 报告的官方堆叠只有一个且顺序符合预期;每个子 PR 相对于父 PR 的 diff 只显示该子 PR 自身的变更。 +- 每次改写推送后,均重新审计了未解决线程、批准状态、可合并性和检查结果。 +- 相关门禁在堆叠中的每个受影响 PR 上都通过,而不仅仅是顶部。 diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md index 3cca4e8b86..35a991f789 100644 --- a/docs/cordis-catalog/core/fiber.md +++ b/docs/cordis-catalog/core/fiber.md @@ -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) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a7ff21e2f4..1f9386fe39 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 87789c9edb..c05d6c8e8f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 91941c108a..72e71ec775 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -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.' diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md new file mode 100644 index 0000000000..e24104e79f --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md @@ -0,0 +1,6 @@ +--- +name: repository-fixture +description: Repository fixture skill. +--- + +Static instructions from a prepared repository plugin. diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs new file mode 100644 index 0000000000..5515aa82a2 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -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 }) +} diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs new file mode 100644 index 0000000000..90f759876a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs @@ -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) +} diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 4cd06aed78..4e18e177c3 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -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 }) + } + }) }) diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt index 5896d03464..cd688cd471 100644 --- a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -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 diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cee30b4328..a713b27163 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -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) }) diff --git a/examples/package.json b/examples/package.json index b7e7b5d736..97e7918361 100644 --- a/examples/package.json +++ b/examples/package.json @@ -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:*", diff --git a/knip.json b/knip.json index 30b1821cd3..7cabe21ced 100644 --- a/knip.json +++ b/knip.json @@ -53,8 +53,7 @@ "**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/util/home": { @@ -628,8 +627,7 @@ "tests/**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/client/modules": { diff --git a/package.json b/package.json index a5f76a0612..c1013c23c6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 369277ba3f..5491ce5432 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b -README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462 +README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863 +README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01 diff --git a/packages/README.md b/packages/README.md index 0c729f7811..c8984bfa65 100644 --- a/packages/README.md +++ b/packages/README.md @@ -33,7 +33,7 @@ Packages live at `packages///`; 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 | diff --git a/packages/README.zh.md b/packages/README.zh.md index 660a24eeea..2a59a63d22 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -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 Code/Codex 协议格式库 | 产品:稳定表面 | | [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 | diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index aaa96435d3..29f9e303de 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/cordis/README.md -README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365 -README.zh.md: 3ee1ddb1db28352cd05b3e79e88228035bc39ac4 +README.md: 485a6ce7858a77507c07b76138127faa411b354b +README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index a47b9ba207..485a6ce785 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -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 | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index 3ee1ddb1db..38bfcd9fcb 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -1,9 +1,10 @@ -# packages/cordis:自指运行时工具集 +# packages/cordis:Cordis 运行时集成 [English](README.md) | 中文 -这些面向模型的工具作用于 agent(智能体)自身所在的实时 Cordis 运行时,可检查已加载的插件和服务接口、挂载模型编写的插件,并将其 dispose(资源释放)。设计说明见[工具集 Agent Note(agent 决策记录)](../../.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 | diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml new file mode 100644 index 0000000000..8cd641781f --- /dev/null +++ b/packages/cordis/repository-plugin/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/cordis/repository-plugin/README.md +README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7 +README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md new file mode 100644 index 0000000000..0ba1ce86d9 --- /dev/null +++ b/packages/cordis/repository-plugin/README.md @@ -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#' + - 'github:owner/repository#&path:/plugins/one/.dsh-plugin' +``` + +Each source must use `github:owner/repository#`. 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. diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md new file mode 100644 index 0000000000..2d9544166e --- /dev/null +++ b/packages/cordis/repository-plugin/README.zh.md @@ -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 Note(agent 决策记录)](../../../.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#' + - 'github:owner/repository#&path:/plugins/one/.dsh-plugin' +``` + +每个源都必须采用 `github:owner/repository#`。省略 `&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` transport;stdio 条目以已准备的 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。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json new file mode 100644 index 0000000000..07bfa924c2 --- /dev/null +++ b/packages/cordis/repository-plugin/package.json @@ -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" + } +} diff --git a/packages/cordis/repository-plugin/src/bin.ts b/packages/cordis/repository-plugin/src/bin.ts new file mode 100644 index 0000000000..a1787ff090 --- /dev/null +++ b/packages/cordis/repository-plugin/src/bin.ts @@ -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 +} diff --git a/packages/cordis/repository-plugin/src/format.ts b/packages/cordis/repository-plugin/src/format.ts new file mode 100644 index 0000000000..9d66321087 --- /dev/null +++ b/packages/cordis/repository-plugin/src/format.ts @@ -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 { + 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 { + 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 +} diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts new file mode 100644 index 0000000000..73eb2dc473 --- /dev/null +++ b/packages/cordis/repository-plugin/src/index.ts @@ -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 { + 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 { + 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 { + 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') +} diff --git a/packages/cordis/repository-plugin/src/invariant.ts b/packages/cordis/repository-plugin/src/invariant.ts new file mode 100644 index 0000000000..410e8bf69e --- /dev/null +++ b/packages/cordis/repository-plugin/src/invariant.ts @@ -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 */ diff --git a/packages/cordis/repository-plugin/src/mcp.ts b/packages/cordis/repository-plugin/src/mcp.ts new file mode 100644 index 0000000000..bce8179121 --- /dev/null +++ b/packages/cordis/repository-plugin/src/mcp.ts @@ -0,0 +1,152 @@ +/** + * Parser for the common `.mcp.json` file consumed by prepared repository plugins. + * @module + */ + +import { z } from 'zod' + +/** + * Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it: + * the prepare bin must stay a zod-only module graph (no tools seam, no MCP + * SDK). Exported so `repository-plugin.spec.ts` pins equality with the + * client's exported pattern — prepare-time validation cannot drift from the + * registry that enforces uniqueness. + */ +export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ +const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g + +const stringMap = z.record(z.string(), z.string()) +const stdioServerSchema = z.object({ + type: z.literal('stdio').optional(), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringMap.optional(), +}).strict() +const httpServerSchema = z.object({ + type: z.literal('http'), + url: z.string().min(1), + headers: stringMap.optional(), +}).strict() +const documentSchema = z.object({ + mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])), +}).strict() + +/** One supported server entry from the common `.mcp.json` format. */ +export type McpServerDefinition = z.infer | z.infer + +/** Parsed common MCP document before process-environment expansion. */ +export interface McpDocument { + mcpServers: Record +} + +/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */ +export type ResolvedMcpServer = + | { + transport: 'stdio' + serverName: string + command: string + args: string[] + env: Record + cwd: string + } + | { + transport: 'streamable-http' + serverName: string + url: string + headers: Record + } + +function assertTemplate(value: string, location: string): void { + for (const match of value.matchAll(PLACEHOLDER_PATTERN)) { + const name = match[1] as string + if (!ENVIRONMENT_NAME_PATTERN.test(name)) { + throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`) + } + } + if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) { + throw new Error(`${location} contains an unterminated environment placeholder`) + } +} + +function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void { + if ('command' in definition) { + visit(definition.command, `mcpServers.${serverName}.command`) + definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) }) + Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) }) + return + } + visit(definition.url, `mcpServers.${serverName}.url`) + Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) }) +} + +/** + * Parse and validate one common `.mcp.json` document without resolving environment values. + * @param content - UTF-8 JSON document. + * @returns the supported stdio and Streamable HTTP server definitions. + */ +export function parseMcpDocument(content: string): McpDocument { + let value: unknown + try { + value = JSON.parse(content) as unknown + } catch (cause) { + throw new Error('invalid .mcp.json: expected JSON', { cause }) + } + const result = documentSchema.safeParse(value) + if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`) + for (const [serverName, definition] of Object.entries(result.data.mcpServers)) { + if (!SERVER_NAME_PATTERN.test(serverName)) { + throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match ${SERVER_NAME_PATTERN.source}`) + } + visitStrings(serverName, definition, assertTemplate) + } + return result.data +} + +function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string { + return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => { + const replacement = environment[name] + if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`) + return replacement + }) +} + +function expandMap(values: Record | undefined, environment: NodeJS.ProcessEnv, location: string): Record { + return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [ + name, + expand(value, environment, `${location}.${name}`), + ])) +} + +/** + * Resolve supported MCP definitions to inputs for the existing MCP client. + * @param document - validated common MCP document. + * @param environment - process environment used for exact `${NAME}` expansion. + * @param cwd - prepared plugin directory used for stdio child processes. + * @returns one existing-client config input per declared server. + */ +export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] { + return Object.entries(document.mcpServers).map(([serverName, definition]) => { + if ('command' in definition) { + return { + transport: 'stdio', + serverName, + command: expand(definition.command, environment, `mcpServers.${serverName}.command`), + args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)), + env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`), + cwd, + } + } + const url = expand(definition.url, environment, `mcpServers.${serverName}.url`) + const protocol = new URL(url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error(`mcpServers.${serverName}.url must use http or https`) + } + return { + transport: 'streamable-http', + serverName, + url, + headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`), + } + }) +} diff --git a/packages/cordis/repository-plugin/src/source.ts b/packages/cordis/repository-plugin/src/source.ts new file mode 100644 index 0000000000..3ccddc6c52 --- /dev/null +++ b/packages/cordis/repository-plugin/src/source.ts @@ -0,0 +1,94 @@ +/** + * GitHub repository source validation and prepared-wrapper loading. + * @module + */ + +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Context, Fiber, FiberState, Plugin } from 'cordis' +import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { PREPARED_ENTRY_FILENAME } from './format.ts' + +// Value mirror: Cordis's const enum has no runtime object to import. Keep +// aligned with `packages/cordis/tool-cordis/src/fiber-state.ts`. +const FIBER_ACTIVE = 2 as FiberState.ACTIVE + +/** Directory under the Harness home containing immutable repository generations. */ +export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' + +// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config +// parser, with the syntax the error message promises — instead of inside the +// cache's pnpm install ('misconfiguration fails loud at the earliest +// resolvable point'). +const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/ + +function validPluginPath(path: string): boolean { + const segments = path.split('/').slice(1) + return segments.length > 0 + && segments.at(-1) === '.dsh-plugin' + && segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..') +} + +/** + * Normalize one user-facing GitHub source to the exact pnpm dependency specifier. + * @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`. + * @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted. + * @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid. + */ +export function resolveRepositorySpecifier(configured: string): string { + const match = GITHUB_SOURCE_PATTERN.exec(configured) + if (match === null) { + throw new Error(`repository source must use github:owner/repo# with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`) + } + const path = match[4] + if (path !== undefined && !validPluginPath(path)) { + throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`) + } + return path === undefined ? `${configured}&path:/.dsh-plugin` : configured +} + +/** + * Resolve the persistent repository cache root. + * @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`. + * @returns an absolute cache directory. + */ +export function resolveRepositoryCacheDirectory(configured: string | undefined): string { + return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY)) +} + +/** + * Load one exact repository generation's generated wrapper as a child Cordis fiber. + * @param ctx - repository runtime context that owns the child. + * @param cache - package-manager-native immutable repository cache. + * @param specifier - normalized exact pnpm dependency specifier. + * @returns the settled prepared-wrapper fiber. + * @throws when installation, wrapper import, manifest validation, or child registration fails. + */ +export async function loadPreparedRepository( + ctx: Context, + cache: Pick, + specifier: string, +): Promise { + const directory = await cache.resolve(specifier) + const filename = join(directory, PREPARED_ENTRY_FILENAME) + try { + const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin + const fiber = ctx.plugin(plugin) + await fiber + // Awaiting a service-gated fiber returns while it is still PENDING (the + // generated wrapper injects `skills`/`tools` per its manifest). This + // runtime commits the repository configuration transactionally, so a + // composition that never provides a required service must reject the + // transaction here — not settle ACTIVE with a silently pending child. + if (fiber.state !== FIBER_ACTIVE) { + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + /* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */ + const detail = missing.join(', ') || 'unknown' + throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`) + } + return await fiber + } catch (cause) { + throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause }) + } +} diff --git a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts new file mode 100644 index 0000000000..5251ccdbc2 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { SERVER_NAME_PATTERN as CLIENT_SERVER_NAME_PATTERN } from '@deepseek-ai/dsh-mcp-client' +import { SERVER_NAME_PATTERN, parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' + +describe('repository plugin common .mcp.json support', () => { + it('validates server names with exactly the pattern the MCP client registry enforces', () => { + // mcp.ts restates the pattern to keep the prepare bin's module graph + // zod-only; this pin is the drift guard. + expect(SERVER_NAME_PATTERN.source).toBe(CLIENT_SERVER_NAME_PATTERN.source) + expect(SERVER_NAME_PATTERN.flags).toBe(CLIENT_SERVER_NAME_PATTERN.flags) + }) + + it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + + expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{ + transport: 'streamable-http', + serverName: 'expo', + url: 'https://mcp.expo.dev/mcp', + headers: {}, + }]) + }) + + it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + datajunction: { + command: 'dj-mcp', + args: ['--endpoint', '${DJ_API_URL}'], + env: { DJ_API_URL: '${DJ_API_URL}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{ + transport: 'stdio', + serverName: 'datajunction', + command: 'dj-mcp', + args: ['--endpoint', 'http://localhost:8000'], + env: { DJ_API_URL: 'http://localhost:8000' }, + cwd: '/plugin', + }]) + }) + + it('fails loud when a declared environment value is absent', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } }, + })) + + expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL') + }) + + it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + local: { type: 'stdio', command: 'local-mcp' }, + remote: { + type: 'http', + url: 'http://${MCP_HOST}/mcp', + headers: { Authorization: 'Bearer ${MCP_TOKEN}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([ + { + transport: 'stdio', + serverName: 'local', + command: 'local-mcp', + args: [], + env: {}, + cwd: '/plugin', + }, + { + transport: 'streamable-http', + serverName: 'remote', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer test-token' }, + }, + ]) + }) + + it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => { + expect(() => parseMcpDocument('{')).toThrow('expected JSON') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { 'bad name': { command: 'server' } }, + }))).toThrow('server name') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${BAD-NAME}' } }, + }))).toThrow('unsupported environment placeholder') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${UNFINISHED' } }, + }))).toThrow('unterminated environment placeholder') + const ftp = parseMcpDocument(JSON.stringify({ + mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } }, + })) + expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https') + }) + + it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => { + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + }))).toThrow('invalid .mcp.json') + }) +}) diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts new file mode 100644 index 0000000000..39840fd1f3 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -0,0 +1,457 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { RepositoryCache } from '@cordisjs/plugin-loader/repository' +import SkillService from '@deepseek-ai/dsh-skill' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' +import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' +import { parsePreparedPluginConfig } from '../src/format.ts' +import { + loadPreparedRepository, + resolveRepositoryCacheDirectory, + resolveRepositorySpecifier, +} from '../src/source.ts' + +const roots: string[] = [] + +async function temporaryDirectory(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`)) + roots.push(directory) + return directory +} + +async function writePlugin(root: string, name: string, dsh: Record): Promise { + const directory = join(root, '.dsh-plugin') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', dsh }, undefined, 2)}\n`) + return directory +} + +async function writeSkill(root: string, name: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`) +} + +afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('dsh-plugin-prepare', () => { + it('copies declared static assets and emits the fixed import-free wrapper', async () => { + const root = await temporaryDirectory('prepare') + await writeSkill(join(root, 'skills'), 'repository-fixture') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + const directory = await writePlugin(root, 'fixture-plugin', { + skills: ['../skills'], + mcpServers: '../.mcp.json', + }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ + name: 'fixture-plugin', + skills: ['dsh-plugin-assets/skills/0'], + mcpServers: 'dsh-plugin-assets/.mcp.json', + }) + const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') + expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) + // Import-free means no static AND no dynamic imports; `import.meta.url` + // (no whitespace, no call parenthesis) is the one allowed appearance. + expect(wrapper).not.toMatch(/\b(?:import|from)\s|\bimport\s*\(/) + await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) + .resolves.toContain('Static instructions.') + await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) + .resolves.toContain('mcp.expo.dev') + }) + + it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => { + const root = await temporaryDirectory('oauth') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + })) + const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json') + await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => { + const malformedRoot = await temporaryDirectory('malformed-package') + const malformed = join(malformedRoot, '.dsh-plugin') + await mkdir(malformed) + await writeFile(join(malformed, 'package.json'), '{') + await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata') + + const emptyRoot = await temporaryDirectory('empty-metadata') + const empty = await writePlugin(emptyRoot, 'empty', {}) + await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file') + + const missingRoot = await temporaryDirectory('missing-asset') + const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] }) + await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist') + + const absoluteRoot = await temporaryDirectory('absolute-asset') + const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] }) + await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative') + + const wrongTypeRoot = await temporaryDirectory('wrong-type') + await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text') + const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory') + + const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type') + await mkdir(join(wrongMcpRoot, 'not-a-file')) + const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file') + + const containingRoot = await temporaryDirectory('containing-root') + const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] }) + await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package') + + const escapedRoot = await temporaryDirectory('escaped-root') + const outside = await temporaryDirectory('outside-root') + await writeSkill(outside, 'outside-skill') + const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] }) + await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root') + }) + + it('validates prepared wrapper configs with and without MCP assets', () => { + expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin') + expect(parsePreparedPluginConfig({ + baseUrl: 'file:///plugin/dsh-plugin.mjs', + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + })).toEqual({ + baseUrl: 'file:///plugin/dsh-plugin.mjs', + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + }) + }) +}) + +describe('prepared repository plugin Loader composition', () => { + it('mounts and removes copied skills through the real Loader and skill-local provider', async () => { + const root = await temporaryDirectory('loader') + await writeSkill(join(root, 'skills'), 'loaded-from-repository') + const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() + + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({ + name: 'loaded-from-repository', + provider: 'repository:loader-fixture', + content: 'Static instructions.', + }) + + await ctx.loader.remove(id) + await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined() + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => { + const root = await temporaryDirectory('mcp-loader') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { offline: { command: join(root, 'missing-mcp-command') } }, + })) + const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RepositoryPlugin) + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) + await ctx.loader.remove(id) + await ctx.fiber.dispose() + }) + + it('rejects hostile prepared paths before mounting children', async () => { + const root = await temporaryDirectory('prepared-paths') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(RepositoryPlugin) + + for (const [filename, skillPath] of [ + ['absolute.mjs', resolve(root)], + ['escaped.mjs', '../outside'], + ] as const) { + const wrapper = join(root, filename) + await writeFile(wrapper, [ + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, + ` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`, + ' })', + '}', + '', + ].join('\n')) + await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path') + } + await ctx.fiber.dispose() + }) + + it('fails the plugin load when a declared skill root is missing or not a directory', async () => { + const root = await temporaryDirectory('missing-skill-root') + await writeFile(join(root, 'not-a-directory'), 'text') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + await ctx.plugin(RepositoryPlugin) + + for (const [filename, skillPath, message] of [ + ['missing.mjs', 'dsh-plugin-assets/skills/0', 'skill root is missing from the installed package'], + ['file.mjs', 'not-a-directory', 'skill root is not a directory'], + ] as const) { + const wrapper = join(root, filename) + await writeFile(wrapper, [ + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, + ` baseUrl: import.meta.url, manifest: { name: 'damaged', skills: [${JSON.stringify(skillPath)}] },`, + ' })', + '}', + '', + ].join('\n')) + await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow(message) + } + await ctx.fiber.dispose() + }) + + it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered') + + const replacement = { name: 'replacement', apply() {} } + ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement) + await ctx.fiber.dispose() + }) +}) + +describe('configured GitHub repository sources', () => { + it('defaults an omitted source list and rejects unknown configuration fields', () => { + expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] }) + expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false) + }) + + it('accepts an empty direct-apply config', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + await RepositoryPlugin.apply(ctx, {}) + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() + await ctx.fiber.dispose() + }) + + it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => { + expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0')) + .toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin') + expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')) + .toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin') + }) + + it('rejects absent refs and invalid plugin subpaths', () => { + for (const source of [ + 'github:owner/repository', + 'github:owner/repository#', + 'github:owner/repository#a#b', + 'https://github.com/owner/repository#ref', + 'github:owner/repository#ref&path:relative/.dsh-plugin', + ]) { + expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#') + } + for (const path of [ + '/plugins//.dsh-plugin', + '/plugins/../.dsh-plugin', + '/plugins/./.dsh-plugin', + '/plugins/not-a-plugin', + ]) { + expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`)) + .toThrow('path must be an absolute repository subpath') + } + }) + + it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => { + const root = await temporaryDirectory('cache-root') + vi.stubEnv('DSH_HOME', root) + expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins')) + expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit')) + }) + + it('loads a configured source through the immutable cache and removes its skill on teardown', async () => { + const root = await temporaryDirectory('configured-source') + await writeSkill(join(root, 'skills'), 'configured-repository-skill') + const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + const resolved: string[] = [] + const cacheDirectory = join(root, 'cache') + vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) { + expect(this.directory).toBe(cacheDirectory) + resolved.push(specifier) + return directory + }) + + const ctx = new Context() + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + const registrar = ctx.plugin(RepositoryPlugin, { + repositories: ['github:owner/repository#fixed-ref'], + cacheDir: cacheDirectory, + }) + await registrar + expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin']) + await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({ + provider: 'repository:configured-source-fixture', + }) + + await registrar.dispose() + await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined() + await ctx.fiber.dispose() + }) + + it('swaps generations on a live source-list update and rolls a failed candidate back', async () => { + // The headline flow: a personal-config edit reaches this plugin as a + // Loader entry.update, which restarts the row's fiber (old cleanup, then + // new apply — so the 'already registered' builtin guard must not fire). + const roots: Record = {} + for (const generation of ['one', 'two'] as const) { + const root = await temporaryDirectory(`live-${generation}`) + await writeSkill(join(root, 'skills'), `live-skill-${generation}`) + const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory + } + vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => { + const directory = roots[specifier] + if (directory === undefined) throw new Error(`unprepared generation ${specifier}`) + return directory + }) + + // Route the row through the Loader builtin table exactly as a config tree + // would; the module itself is the row's plugin. + const ctx2 = new Context() + await ctx2.plugin(Loader) + await ctx2.plugin(SkillService) + ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin + const entryId = await ctx2.loader.create({ + name: 'cordis:repository-plugins', + config: { repositories: ['github:owner/repository#one'] }, + }) + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' }) + + const entry = ctx2.loader.resolve(entryId) + await entry.update({ config: { repositories: ['github:owner/repository#two'] } }) + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined() + await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) + + // A failed candidate (unprepared source) rejects the update and the + // transactional Loader restores the previous generation. + await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } })) + .rejects.toThrow('unprepared generation') + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) + await ctx2.fiber.dispose() + }) + + it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + await expect(RepositoryPlugin.apply(ctx, { + repositories: [ + 'github:owner/repository#ref', + 'github:owner/repository#ref', + ], + })).rejects.toThrow('must resolve to unique exact specifiers') + + vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed')) + await expect(RepositoryPlugin.apply(ctx, { + repositories: ['github:owner/repository#other'], + })).rejects.toThrow('prepare failed') + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('rejects a wrapper left pending by a composition without its required services', async () => { + // A skills-declaring generation mounted where no skills service exists: + // the wrapper fiber stays PENDING, and the transaction must fail loud + // instead of committing an ACTIVE row over a silently inert child. + const root = await temporaryDirectory('pending-services') + await writeSkill(join(root, 'skills'), 'pending-service-skill') + const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + await ctx.plugin(Loader) + // Deliberately NO SkillService. + await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin')) + .rejects.toMatchObject({ + message: expect.stringContaining('failed to load prepared repository Plugin') as string, + cause: expect.objectContaining({ + message: expect.stringContaining('waiting for services: skills') as string, + }) as Error, + }) + await ctx.fiber.dispose() + }) + + it('labels a missing prepared wrapper with its exact source and path', async () => { + const root = await temporaryDirectory('missing-wrapper') + const ctx = new Context() + const specifier = 'github:owner/repository#missing&path:/.dsh-plugin' + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier)) + .rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`) + await ctx.fiber.dispose() + }) +}) + +describe('repository plugin invariant companion', () => { + it('registers its explained empty invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/cordis/repository-plugin/tsconfig.json b/packages/cordis/repository-plugin/tsconfig.json new file mode 100644 index 0000000000..67cb0dedf2 --- /dev/null +++ b/packages/cordis/repository-plugin/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../skill/skill-local" + }, + { + "path": "../../mcp/mcp-client" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/cordis/repository-plugin/tsdown.config.ts b/packages/cordis/repository-plugin/tsdown.config.ts new file mode 100644 index 0000000000..ac8e9a5fe0 --- /dev/null +++ b/packages/cordis/repository-plugin/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +/** Build the runtime, invariant, and prepare executable as self-contained entries. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 2ea83029cf..ee765aaeab 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -261,7 +261,9 @@ function validateSnapshotTransition( * @returns stable identity used to reconcile a deferred change with its log event. */ export function goalChangeRef(change: GoalChangeMeta): GoalRef { - return change.operation === 'clear' ? change.cleared : change.goal + return change.operation === 'clear' + ? change.cleared + : { id: change.goal.id, revision: change.goal.revision } } /** diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 7c4b4d9b28..82793618c4 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -11,7 +11,7 @@ import GoalService, { foldGoal, renderGoalChange, } from '@deepseek-ai/dsh-goal' -import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' +import type { GoalChangeMeta, GoalChanged, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' type DeferredInjection = UserMessage @@ -381,6 +381,25 @@ describe('GoalService mutations', () => { expect(next.id).not.toBe(goal.id) }) + it('emits bare compare-and-set refs in folded lastRef and goal/changed notifications', async () => { + const { ctx, agent, session } = await harness() + const seen: GoalChanged['ref'][] = [] + ctx.on('goal/changed', (_subject, change) => { seen.push(change.ref) }) + const created = ctx.goals.create(agent, { objective: 'bare refs', maxGoalRounds: 3 }) + const edited = ctx.goals.edit(agent, created, { objective: 'bare refs edited' }) + const blocked = ctx.goals.block(agent, edited, { code: 'bare-blocker', message: 'Bare refs.' }) + // GoalRef is exactly { id, revision }: every notification ref must be bare. + for (const ref of seen) { + expect(Object.keys(ref).sort()).toEqual(['id', 'revision']) + expect(ref).toEqual({ id: created.id, revision: ref.revision }) + } + expect(seen).toHaveLength(3) + // The durable fold's lastRef is the same bare ref, not a full snapshot. + const folded = foldGoal(session.events) + expect(folded.lastRef).toEqual({ id: blocked.id, revision: blocked.revision }) + expect(Object.keys(folded.lastRef as object).sort()).toEqual(['id', 'revision']) + }) + it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => { vi.useFakeTimers() vi.setSystemTime(100) diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 5766e36b98..3cf75b20dc 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise { // nothing is left to unmount or await then. const entry = ctx.loader.store[id] if (entry === undefined) return - const fiber = entry.fiber - ctx.loader.remove(id) - // remove() only starts the fiber's dispose; join it so the chooser's - // unload signals completion only after the backend quiesced. - await fiber?.dispose() + // remove() disposes the entry transactionally, so the chooser's unload + // signals completion only after the backend quiesced. + await ctx.loader.remove(id) } }, 'directory-picker-auto: backend entry') } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 59ca1c1992..9d0b8c7de8 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -167,7 +167,7 @@ describe('real Loader composition', () => { const { ctx, configPath } = await loadComposition('127.0.0.1') const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! - ctx.loader.remove(backendEntry.id) + await ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 0160db9f01..a79958e9d2 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: ace8c09e43dd8544a28d300f97b04610be78bc69 -README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a +README.md: c3c7b222683bc7731a6c21f2fffd325225099bab +README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index ace8c09e43..c3c7b22268 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -6,7 +6,7 @@ Plain HTTP route-registration plugin (default-exported `HttpServerService`, conf The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index b9948e3d38..99c0560eb7 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -6,7 +6,7 @@ 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 017fedba1a..c64208eb8e 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context, FiberState } from 'cordis' +import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import HttpServer from '../src/index.ts' @@ -142,25 +142,17 @@ describe('real Loader composition', () => { const firstRoot = root root = undefined // keep the first composition's files until the end - // loader.await() never rejects (allSettled); the bind failure surfaces as - // a FAILED fiber whose error escapes as a late rejection — the shape the - // boot's installFailLoud is contracted to catch. Capture it here the same - // way, and assert it really is the bind error. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) let second: Context | undefined try { - second = await loadComposition(takenPort) - const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') - expect(entry?.fiber?.state).toBe(FiberState.FAILED) - // The rejection escapes a tick after loader.await() settles; bounded poll. - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) + let failure: unknown + try { + await loadComposition(takenPort) + } catch (error) { + failure = error } - expect(rejections.map(String).join('\n')).toContain('EADDRINUSE') + second = context + expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/) } finally { - process.off('unhandledRejection', onUnhandled) await second?.fiber.dispose() context = first if (root !== undefined) await rm(root, { recursive: true, force: true }) diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 1eec41b96e..49609bac9a 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -36,8 +36,11 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 /** * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the * 64-char public-name budget so typical raw tool names survive unhashed. + * Exported so upstream producers of Config inputs (repository-plugin's + * `.mcp.json` prepare-time validation) reject the same names this registry + * would. */ -const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ /** * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml index 5df258e899..e86449af9c 100644 --- a/packages/session-query/tool-session-query/README.i18n.yaml +++ b/packages/session-query/tool-session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md -README.md: 9a70f29d7c39af816c9efcf479ad129f0148883c -README.zh.md: 55717aef20d53686cce963d09b2e41350d274a75 +README.md: d973daf1124c4be05f7335b18661d431d45be39f +README.zh.md: b27d79a905a029d3750f24573e3c32785a314015 diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index 9a70f29d7c..d973daf112 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; the shipped TUI, Web, and headless compositions mount it by default, while ACP does not. +Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default. ## Configuration diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md index 55717aef20..b27d79a905 100644 --- a/packages/session-query/tool-session-query/README.zh.md +++ b/packages/session-query/tool-session-query/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已交付的 TUI、Web 与无头组合默认挂载它,而 ACP(Agent Client Protocol)不挂载。 +位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。 ## 配置 diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index d1fa4602be..269054f9f8 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md -README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453 -README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9 +README.md: f85cc2e6fd0c32cb88f28a2914a03e22b3a20657 +README.zh.md: 73a66831ad14b7edb346227cf6adb52ec8247fd7 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 2077cf852f..f85cc2e6fd 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -14,6 +14,8 @@ Requires `ctx.skills` (`inject: ['skills']`). | Field | Default | Meaning | |---|---|---| +| `providerName` | `local` | Unique name used to register this provider on `ctx.skills`. | +| `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs`; set false for an isolated custom-root provider. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | @@ -36,7 +38,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index ba1c71f1bc..73a66831ad 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -14,6 +14,8 @@ | 字段 | 默认值 | 含义 | |---|---|---| +| `providerName` | `local` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 | +| `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 | | `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 | @@ -36,7 +38,7 @@ | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index a19fa1dde5..d6df41237e 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -47,6 +47,10 @@ export const inject = ['skills'] /** 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`. */ @@ -65,11 +69,13 @@ 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 } export const Config: Schema = z.object({ + providerName: z.string().min(1).default('local'), + includeDefaultRoots: z.boolean().default(true), dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), @@ -138,7 +144,8 @@ export function apply(ctx: Context, config: Config = {}): void { /** Provider that maps local project/user skill roots into `ctx.skills`. */ export class LocalSkillProvider implements SkillProvider { - readonly name = 'local' + readonly name: string + private readonly includeDefaultRoots: boolean private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] @@ -151,12 +158,19 @@ export class LocalSkillProvider implements SkillProvider { control: SkillProviderControl, config: Config = {}, ) { + this.name = config.providerName ?? 'local' + this.includeDefaultRoots = config.includeDefaultRoots ?? true this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) - const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR + // The environment bundled root is a default root: an isolated provider + // (includeDefaultRoots: false — repository plugins) must see only its + // explicit custom roots, or every such provider would re-discover the + // app's bundled skills and claim them under its own provider name. + const bundledSkillDir = config.bundledSkillDir + ?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined) this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } @@ -177,7 +191,7 @@ export class LocalSkillProvider implements SkillProvider { } const candidates: SkillCandidate[] = [] for (const root of roots) { - for (const skill of await discoverRoot(root, this.ctx)) { + for (const skill of await discoverRoot(root, this.ctx, this.name)) { candidates.push(skill) } } @@ -227,21 +241,23 @@ export class LocalSkillProvider implements SkillProvider { private async roots(cwd: string | undefined): Promise { const roots: SkillRoot[] = [] - if (cwd !== undefined) { + if (this.includeDefaultRoots && cwd !== undefined) { const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx)) roots.push( { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot }, { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot }, ) } - roots.push( - ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })), - { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, - { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, - ...this.bundledSkillDir === undefined - ? [] - : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], - ) + roots.push(...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK }))) + if (this.includeDefaultRoots) { + roots.push( + { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, + { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, + ) + } + if (this.bundledSkillDir !== undefined) { + roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true }) + } return roots } } @@ -693,7 +709,7 @@ function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } -async function discoverRoot(root: SkillRoot, ctx: Context): Promise { +async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Promise { const skills: SkillCandidate[] = [] const entries = await listSkillRootEntries(root, ctx) for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { @@ -711,7 +727,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise { await ctx.plugin(SkillLocal, { watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill']) + // Isolated providers see only their explicit roots: the environment + // bundled root is a default root, so includeDefaultRoots: false must + // drop it — repository providers never re-claim the app's builtins. + const isolated = new Context() + await isolated.plugin(SkillService) + const customOnly = join(envHome, 'custom-only') + await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill') + await isolated.plugin(SkillLocal, { + providerName: 'isolated', + includeDefaultRoots: false, + customSkillDirs: [customOnly], + watch: false, + }) + expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill']) + await isolated.fiber.dispose() + process.env.DSH_HOME = join(envHome, 'empty-dsh') delete process.env.DSH_BUNDLED_SKILL_DIR process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index c61db1ae9c..3b126f1e76 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -147,12 +147,12 @@ describe('typert loader', () => { await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.list()).toHaveLength(1) - ctx.loader.remove(id) + await ctx.loader.remove(id) await ctx.loader.await() // The unmount reconciliation rides a queued microtask flush. await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() - ctx.loader.remove(plainId) + await ctx.loader.remove(plainId) await ctx.loader.await() await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 0ee7591bec..65a471f69a 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: efc8c42e192a02ecf37f8ea1356aa22261c70d0e -README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5 +README.md: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1 +README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index efc8c42e19..2eb9e904d5 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,17 +8,19 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | -| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR | +| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. @@ -26,11 +28,13 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Personal config -A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: +A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: - **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. - **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. +The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. + Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. ## Model Experience diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 927d6d1fb4..78bd99943f 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,17 +8,19 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | +| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 @@ -26,11 +28,13 @@ Loader 树结算不会向调用方传播两类故障,因此需要分别保护 ## 个人配置 -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: +开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: - **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 - **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 + 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 ## 模型体验 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index ef267e8588..18a42a27a1 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -30,6 +30,7 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { + "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -37,9 +38,16 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@cordisjs/plugin-hmr": { + "optional": true + } + }, "devDependencies": { + "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index febd724097..eb5003f72b 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -11,9 +11,10 @@ import { readFileSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -67,6 +68,8 @@ export function loadEnv( /** File inside the Harness home holding the personal loader overlay patches. */ export const PERSONAL_CONFIG_FILENAME = 'config.yaml' +const bootstrapIncludes = new WeakMap() + // The include's YAML dialect (`!!js` scalars become expression nodes the // Loader interpolates against each entry's context at mount time), imported // from the include itself so patch parsing and config dumping can never drift @@ -287,6 +290,99 @@ function groupedDump( return lines.join('\n') + '\n' } +/** Options for live personal-config reconciliation. */ +export interface PersonalPatchWatchOptions { + /** Diagnostic prefix used by {@link loadPersonalPatches}. */ + binName: string + /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */ + dir?: string + /** + * Compose the full patch list for a fresh personal-overlay generation — + * the same composition the app booted with, so a reload can interleave the + * new personal patches between app-owned layers (surface overlay below, + * profile/flag patches above). Identity when omitted: the personal overlay + * is the whole patch list. + */ + compose?: (personalPatches: PatchOptions[]) => PatchOptions[] +} + +/** + * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include. + * @param ctx - settled app context containing the root Include and an active HMR service. + * @param options - diagnostic, Harness-home, and patch-composition inputs. + * @returns an asynchronous disposer after the exact-path watcher is ready. + * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. + */ +export async function watchPersonalPatches( + ctx: Context, + options: PersonalPatchWatchOptions, +): Promise<() => Promise> { + const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options + const hmr = ctx.get('hmr') + if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`) + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) + const filename = join(dir, PERSONAL_CONFIG_FILENAME) + const register = hmr.registerConfig(filename, async () => { + // Re-read the include's non-patch options per refresh: a writer that + // updates the root Include's other options between refreshes (none exists + // today) must not have them silently reverted by a personal reload. + const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config + const personalPatches = loadPersonalPatches(binName, dir) ?? [] + const patches = compose(personalPatches) + await entry.update({ + config: { + ...includeConfig, + patches, + }, + }) + }) + try { + return await register + } catch (error) { + // A surface can dispose the whole tree while the watcher is still opening + // (a TUI `/exit` typed during startup): the HMR effect registration then + // fails with INACTIVE_EFFECT. That is the app exiting exactly as asked, + // not a watch failure — return a no-op disposer instead of crashing. + if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} + throw error + } +} + +/** + * Mount and remember the exact root Include entry used by app boot and personal-config HMR. + * @param ctx - context carrying an initialized Loader service. + * @param absoluteConfigPath - absolute YAML or JSON configuration path. + * @param patches - initial app and personal patches, applied in order. + * @returns the created root Include entry, or `undefined` when a surface + * disposed the whole tree (taking the Loader service with it) while the + * transactional create was still settling entry lifecycle. + */ +export async function mountRootInclude( + ctx: Context, + absoluteConfigPath: string, + patches: readonly PatchOptions[] = [], +): Promise { + ctx.loader.builtins.include = Include + // Pinned id: the bootstrap include is app glue, not a config row, and its + // id appears in Loader failure chains — a random id would make startup + // diagnostics unstable across runs (and snapshot fixtures). + const rootInclude: EntryOptions = { + id: 'include', + name: 'cordis:include', + config: { + path: pathToFileURL(absoluteConfigPath).href, + ...patches.length > 0 ? { patches: [...patches] } : {}, + }, + } + const includeId = await ctx.loader.create(rootInclude) + const loader = ctx.get('loader') + if (loader === undefined) return undefined + const entry = loader.resolve(includeId) + bootstrapIncludes.set(ctx, entry) + return entry +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. @@ -430,12 +526,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay * config-relative. The package build embeds Include while leaving Loader - * external, so the built include tree and host share one Loader peer. A - * missing fiber rejects here; a later init rejection is rethrown with its - * original stack by {@link assertEntriesActivated}; later unhandled - * rejections remain covered by {@link installFailLoud}. Built bins need the - * Loader's native helper for bare plugin specifiers; relative specifiers do - * not. + * external, so the built include tree and host share one Loader peer. Loader + * settlement rejects startup failures, which `boot` wraps after disposing the + * partial context; a missing fiber or never-activating entry is rejected by + * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's + * init rejection with its original stack; later unhandled rejections remain + * covered by {@link installFailLoud}. Built bins need the Loader's native + * helper for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -444,6 +541,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. + * @throws a labelled error after disposing the partial context — `host + * preparation failed` when `prepare` threw before any config-tree entry + * mounted, `plugin tree failed to load` afterwards. */ export async function boot( binName: string, @@ -452,28 +552,44 @@ export async function boot( prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - ctx.provide('dshHomePath', dshHomePath) - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await prepare?.(ctx) - await ctx.loader.create({ - name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches !== undefined && patches.length > 0 ? { patches } : {}, - }, - }) - await ctx.loader.await() - // A surface can finish and dispose the whole tree while that await is still - // pending: the TUI renders as soon as its own fiber starts, so an `/exit` - // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and the activation audit describes a live - // tree — reading `ctx.loader` here would throw a TypeError over an app that - // exited exactly as asked. - if (ctx.get('loader') === undefined) return ctx - await assertEntriesActivated(ctx, binName) - return ctx + // Two failure labels: `prepare` runs before any config-tree entry mounts, + // so its failure is host setup, not the plugin tree. + let stage = 'host preparation failed' + try { + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + ctx.provide('dshHomePath', dshHomePath) + await ctx.plugin(Loader) + await prepare?.(ctx) + stage = 'plugin tree failed to load' + await mountRootInclude(ctx, absoluteConfigPath, patches) + // A surface can finish and dispose the whole tree while startup is still + // in flight: the TUI renders as soon as its own fiber starts, so an `/exit` + // typed before the last entry settles tears the context down under us. The + // Loader service goes with it, and the activation audit describes a live + // tree — reading `ctx.loader` past this point would throw a TypeError over + // an app that exited exactly as asked. Transactional group updates settle + // lifecycle inside the mount, so the teardown can land before it returns; + // re-check after every await. + await ctx.get('loader')?.await() + if (ctx.get('loader') === undefined) return ctx + await assertEntriesActivated(ctx, binName) + return ctx + } catch (cause) { + // Root-fiber disposal contains cleanup failures per observer (Cordis + // fiber.ts hardening) and a repeated call returns the settled single-shot + // result, so this await cannot reject and replace `cause`. + await ctx.fiber.dispose() + const detail = cause instanceof Error ? cause.message : String(cause) + // The transactional Loader wraps a failing entry apply in one message per + // tree layer; every layer's message is folded into `detail` above, and the + // deepest cause is the plugin's own thrown error, whose stack names the + // real failure site — append it so the startup diagnostic preserves the + // original activation error instead of only the wrap chain. + let deepest: unknown = cause + while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause + const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) + } } /** Prompt-section name for the harness-source location line an app bin adds after boot. */ diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 4f64365a64..610f93b3d5 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -325,6 +325,22 @@ describe('boot', () => { } }) + it('disposes partial host setup and labels non-Error preparation failures', async () => { + const dir = tmp() + const failure = 42 + let disposed = false + const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => { + ctx.effect(() => () => { disposed = true }) + throw failure + }) + + await expect(task).rejects.toMatchObject({ + message: `${NAME}: host preparation failed: ${failure}`, + cause: failure, + }) + expect(disposed).toBe(true) + }) + it('exposes dshHomePath to Loader config expressions', async () => { const dir = tmp() const dshHome = join(dir, 'home') @@ -375,7 +391,37 @@ describe('boot', () => { it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow( + `${NAME}: plugin tree failed to load: failed to apply loader entry`, + ) + }) + + it('appends the deepest cause with its original stack to the load failure', async () => { + const dir = tmp() + writeFileSync(join(dir, 'failing.mjs'), [ + 'export function apply() {', + " const failure = new Error('pinned activation failure')", + " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'", + ' throw failure', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n') + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([ + String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`, + String.raw`Error: pinned activation failure\n {4}at failing-fixture$`, + ].join(''))) + }) + + it('falls back to the deepest cause message when its stack was erased', async () => { + const dir = tmp() + const deepest = new Error('stackless deep failure') + delete (deepest as { stack?: string }).stack + await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { + throw new Error('wrapped setup failure', { cause: deepest }) + })).rejects.toThrow( + `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`, + ) }) it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index c4f6c48d7f..d9f4ffa830 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -1,12 +1,7 @@ /** - * Config hot-reload resilience of the booted include tree. `dsh-app-boot` - * installs a fail-loud unhandled-rejection handler, so a `refresh()` that - * rethrows a config-file parse error would kill a live app on one bad - * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event - * callback nobody else catches). These tests pin the vendored - * `@cordisjs/plugin-include` contract that boot relies on: an invalid file - * keeps the last good tree, and a valid re-read re-applies overlay patches - * exactly like the initial load. + * Transactional config replacement through the booted Include and Loader tree. + * HMR contains rejected refreshes; direct callers receive the error after the + * previous generation has been retained or restored. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -15,6 +10,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { Include } from '@cordisjs/plugin-include' +import { Group } from '@cordisjs/plugin-loader' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -27,9 +23,10 @@ interface TreeFixture { include: Include } -async function bootTree(configBody: string): Promise { +async function bootTree(configBody: string, files: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) const ctx = await boot(NAME, join(dir, 'cordis.yml')) const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) @@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } +function entryById(ctx: Context, id: string) { + const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id) + if (!entry) throw new Error(`missing loader entry ${id}`) + return entry +} + +function plugin(name: string, body = ''): string { + return `export default function ${name}(_ctx, config = {}) { ${body} }\n` +} + +async function expectUpdateFailure(task: Promise, stage: string): Promise { + try { + await task + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`failed to ${stage} loader entry`) + return + } + throw new Error(`expected loader update to fail during ${stage}`) +} + describe('include refresh with an invalid file', () => { - it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { + it('rejects while keeping the last good tree, then applies the next valid edit', async () => { const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to parse config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) // An empty file parses to `undefined` without a YAML error; it must be // treated exactly like a parse failure, not crash the entry walk. writeFileSync(join(dir, 'cordis.yml'), '') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to validate config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') @@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => { }) }) +describe('loader entry replacement', () => { + it('imports a changed name before replacing the running plugin', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'new.mjs': plugin('newPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + await entry.update({ name: './new.mjs' }) + expect(entry.options.name).toBe('./new.mjs') + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('retains the running plugin when the replacement cannot be imported', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous plugin after replacement application fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const previous = entry.fiber + await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === previous).toBe(false) + expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous config when an in-place restart fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('does not persist a failed direct fiber update', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + if (!fiber) throw new Error('target entry has no fiber') + await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('loader tree replacement', () => { + it('rolls back earlier updates and additions when a later entry fails', async () => { + const { ctx, dir, include } = await bootTree([ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: old', + '', + ].join('\n'), { + 'configurable.mjs': plugin('configurablePlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: candidate', + '- id: added', + ' name: ./noop.mjs', + '- id: bad', + ' name: ./bad.mjs', + '', + ].join('\n')) + await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad') + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false) + + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: committed', + '- id: added', + ' name: ./noop.mjs', + '', + ].join('\n')) + await include.refresh() + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' }) + expect(entryById(ctx, 'added').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { + const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') + ctx.loader.builtins.group = Group + try { + const config = (disabled: boolean) => [ + '- id: parent', + ' name: cordis:group', + ' group: true', + ` disabled: ${disabled}`, + ' config:', + ' - id: child', + ' name: ./noop.mjs', + '', + ].join('\n') + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + + writeFileSync(join(dir, 'cordis.yml'), config(true)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeUndefined() + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores a programmatic entry move when its update fails', async () => { + const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { + 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + ctx.loader.builtins.group = Group + try { + const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) + const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) + const target = entryById(ctx, targetId) + const source = target.parent + const sourceIndex = source.data.indexOf(target.options) + const destination = entryById(ctx, groupId).subgroup + if (!destination) throw new Error('created loader group has no subgroup') + + await expectUpdateFailure( + ctx.loader.update(targetId, { config: { fail: true } }, groupId), + 'apply', + ) + + expect(target.parent).toBe(source) + expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx) + expect(source.data.indexOf(target.options)).toBe(sourceIndex) + expect(destination.data).not.toContain(target.options) + expect(target.options.config).toEqual({ fail: false }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) @@ -116,9 +328,9 @@ describe('include refresh with overlay patches', () => { await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) - // Removing every patch must revert to the file's own values: patching - // may not bake earlier patch results into the cached parse. - await entry.update({ config: { path: './base.yml', patches: [] } }) + // Omitting the patch list must remove the overlay rather than reuse the + // Include's previous config through a default parameter. + await entry.update({ config: { path: './base.yml' } }) await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) } finally { diff --git a/packages/ui/app-boot/tests/hmr-config.spec.ts b/packages/ui/app-boot/tests/hmr-config.spec.ts new file mode 100644 index 0000000000..1892a6e73a --- /dev/null +++ b/packages/ui/app-boot/tests/hmr-config.spec.ts @@ -0,0 +1,142 @@ +import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Hmr from '@cordisjs/plugin-hmr' +import Loader from '@cordisjs/plugin-loader' +import Timer from '@cordisjs/plugin-timer' +import { describe, expect, it } from 'vitest' + +async function bootHmr(dir: string): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dir).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + return ctx +} + +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +describe('HMR exact config paths', () => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + try { + observed.push(readFileSync(filename, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + observed.push('missing') + } + }) + + writeFileSync(filename, 'one', { flag: 'wx' }) + await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + writeFileSync(filename, 'two') + await eventually(() => observed.includes('two'), 'HMR did not observe config change') + unlinkSync(filename) + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + } finally { + await ctx.fiber.dispose() + } + }) + + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const dir = join(root, 'later') + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(root) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + observed.push(readFileSync(filename, 'utf8')) + }) + mkdirSync(dir) + writeFileSync(filename, 'created') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + } finally { + await ctx.fiber.dispose() + } + }) + + it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + writeFileSync(filename, 'one') + const ctx = await bootHmr(dir) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const observed: string[] = [] + let active = 0 + let maxActive = 0 + try { + const dispose = await ctx.hmr.registerConfig(filename, async () => { + active += 1 + maxActive = Math.max(maxActive, active) + observed.push(readFileSync(filename, 'utf8')) + if (observed.length === 1) { + started.resolve(undefined) + await release.promise + } + active -= 1 + }) + await started.promise + writeFileSync(filename, 'two') + // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond + // that window so this edit is queued before registration disposal. + await new Promise(resolve => setTimeout(resolve, 250)) + + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve(undefined) + await disposal + expect(maxActive).toBe(1) + expect(observed).toEqual(['one', 'two']) + } finally { + release.resolve(undefined) + await ctx.fiber.dispose() + } + }) + + it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const failure = Promise.withResolvers<{ filename: string; error: Error }>() + let failureCount = 0 + try { + ctx.on('hmr/config-update-failed', () => { + throw new Error('observer failed') + }) + ctx.on('hmr/config-update-failed', (failedFilename, error) => { + failureCount += 1 + failure.resolve({ filename: failedFilename, error }) + }) + await ctx.hmr.registerConfig(filename, () => { throw 42 }) + writeFileSync(filename, 'invalid') + + const observed = await failure.promise + expect(observed.filename).toBe(filename) + expect(observed.error).toBeInstanceOf(Error) + expect(observed.error.message).toBe('42') + + writeFileSync(filename, 'invalid again') + await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts index 5d72238cfa..53df1d84b7 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -4,21 +4,36 @@ * a real Loader tree. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import { Context } from 'cordis' +import Hmr from '@cordisjs/plugin-hmr' +import Loader from '@cordisjs/plugin-loader' +import Timer from '@cordisjs/plugin-timer' import { boot, loadPersonalPatches, PERSONAL_CONFIG_FILENAME, + watchPersonalPatches, } from '../src/index.ts' const NAME = 'dsh-test-bin' const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) + describe('loadPersonalPatches', () => { afterEach(() => { delete process.env.DSH_HOME @@ -86,7 +101,13 @@ describe('loadPersonalPatches', () => { describe('boot with personal patches', () => { function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'noop.mjs'), [ + 'export const name = "noop"', + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') return join(dir, 'cordis.yml') } @@ -138,4 +159,112 @@ describe('boot with personal patches', () => { await ctxEmpty.fiber.dispose() } }) + + it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { + const dir = tmp() + const personal = tmp() + const filename = join(personal, PERSONAL_CONFIG_FILENAME) + const basePatches = [{ id: 'noop', config: { value: 'generated' } }] + const ctx = await boot(NAME, writeTree(dir), basePatches) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const failures: Array<{ filename: string; error: Error }> = [] + ctx.on('hmr/config-update-failed', (failedFilename, error) => { + failures.push({ filename: failedFilename, error }) + }) + const dispose = await watchPersonalPatches(ctx, { + binName: NAME, + dir: personal, + compose: personalPatches => [...basePatches, ...personalPatches], + }) + try { + writeFileSync(filename, '- id: noop\n config:\n value: live\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') + + writeFileSync(filename, '- id: noop\n config:\n fail: true\n') + await eventually(() => failures.length === 1, 'failed candidate was not broadcast') + expect(failures[0]).toMatchObject({ filename }) + expect(failures[0]?.error).toBeInstanceOf(Error) + expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await settleChokidarChangeThrottle() + + writeFileSync(filename, 'invalid: [unclosed\n') + await eventually(() => failures.length === 2, 'parse failure was not broadcast') + expect(failures[1]?.error).toBeInstanceOf(Error) + expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await settleChokidarChangeThrottle() + + writeFileSync(filename, '- id: noop\n config:\n value: recovered\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied') + await settleChokidarChangeThrottle() + + unlinkSync(filename) + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') + expect(failures).toHaveLength(2) + await settleChokidarChangeThrottle() + + // Default compose: the personal overlay IS the whole patch list, so a + // fresh generation replaces the app-owned layer instead of stacking on it. + await dispose() + const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + try { + writeFileSync(filename, '- id: noop\n config:\n value: identity\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') + } finally { + await disposeDefault() + } + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + + it('fails loud when the exact watcher lacks HMR or a root Include', async () => { + const dir = tmp() + const withoutHmr = await boot(NAME, writeTree(dir)) + await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service') + await withoutHmr.fiber.dispose() + + const withoutInclude = new Context() + withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href + await withoutInclude.plugin(Loader) + await withoutInclude.plugin(Timer) + await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') + await withoutInclude.fiber.dispose() + }) + + it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { + // A TUI `/exit` typed during startup disposes the whole tree while + // registerConfig's effect registration is still in flight (the HMR effect + // then fails with INACTIVE_EFFECT); the app is exiting exactly as asked, + // so the watcher must not crash the process. The stub makes the race + // deterministic — the live-teardown ordering itself is not stageable. + const dir = tmp() + const ctx = await boot(NAME, writeTree(dir)) + try { + const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) + ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) + const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() }) + await expect(dispose()).resolves.toBeUndefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('propagates registration failures other than mid-teardown', async () => { + const dir = tmp() + const personal = tmp() + const ctx = await boot(NAME, writeTree(dir)) + try { + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + // Same personal path registered twice: HMR refuses; not a teardown race. + await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered') + await dispose() + } finally { + await ctx.fiber.dispose() + } + }) }) diff --git a/packages/ui/app-boot/tests/repository-cache.spec.ts b/packages/ui/app-boot/tests/repository-cache.spec.ts new file mode 100644 index 0000000000..b7d80470b8 --- /dev/null +++ b/packages/ui/app-boot/tests/repository-cache.spec.ts @@ -0,0 +1,159 @@ +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository' + +const execFileAsync = promisify(execFile) +const roots: string[] = [] + +async function temporaryRoot(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`)) + roots.push(root) + return root +} + +async function fakePackage(directory: string): Promise { + const target = join(directory, 'node_modules', 'repository') + await mkdir(target, { recursive: true }) + await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n') +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('RepositoryCache', () => { + it('single-flights and permanently reuses an exact specifier', async () => { + const root = await temporaryRoot('repository-cache') + const calls: string[] = [] + const install: RepositoryInstall = async (directory) => { + calls.push(directory) + await fakePackage(directory) + } + const cache = new RepositoryCache(root, install) + const specifier = 'github:owner/repository#0123456789abcdef' + + const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)]) + expect(concurrent).toBe(first) + expect(calls).toHaveLength(1) + + const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') }) + expect(await reopened.resolve(specifier)).toBe(first) + expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({ + packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, + dependencies: { repository: specifier }, + }) + + const second = await cache.resolve('github:owner/repository#fedcba9876543210') + expect(second).not.toBe(first) + expect(calls).toHaveLength(2) + }) + + it('accepts the valid winner when independent cache instances race', async () => { + const root = await temporaryRoot('repository-race') + const bothStarted = Promise.withResolvers() + let starts = 0 + const install: RepositoryInstall = async (directory) => { + await fakePackage(directory) + starts += 1 + if (starts === 2) bothStarted.resolve(undefined) + await bothStarted.promise + } + const specifier = 'github:owner/repository#race' + + const [first, second] = await Promise.all([ + new RepositoryCache(root, install).resolve(specifier), + new RepositoryCache(root, install).resolve(specifier), + ]) + + expect(second).toBe(first) + expect(starts).toBe(2) + expect(await readdir(root)).toHaveLength(1) + }) + + it('removes a failed staging tree and permits an exact retry', async () => { + const root = await temporaryRoot('repository-retry') + let attempts = 0 + const cache = new RepositoryCache(root, async (directory) => { + attempts += 1 + if (attempts === 1) throw new Error('install failed') + await fakePackage(directory) + }) + + await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository') + expect(await readdir(root)).toEqual([]) + await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules') + expect(attempts).toBe(2) + }) + + it('rejects empty or padded specifiers before touching the cache', async () => { + const root = await temporaryRoot('repository-input') + const cache = new RepositoryCache(root, fakePackage) + expect(() => cache.resolve('')).toThrow('non-empty unpadded string') + expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string') + await expect(readdir(root)).resolves.toEqual([]) + }) + + it('fails loud on a corrupt published marker instead of reinstalling it', async () => { + const root = await temporaryRoot('repository-corrupt') + const specifier = 'github:owner/repository#corrupt' + const key = createHash('sha256').update(specifier).digest('hex') + const entry = join(root, key) + await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true }) + await writeFile(join(entry, '.repository-cache.json'), '{}\n') + const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') }) + + await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') + }) + + it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => { + const root = await temporaryRoot('repository-pnpm') + const repository = join(root, 'source') + await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) + await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) + await writeFile(join(repository, 'package.json'), `${JSON.stringify({ + name: 'repository-fixture', + version: '1.0.0', + })}\n`) + await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') + await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ + name: 'repository-plugin-fixture', + version: '1.0.0', + scripts: { prepare: 'node prepare.mjs' }, + dsh: { skills: ['../skills'] }, + })}\n`) + await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [ + "import { cp, mkdir, writeFile } from 'node:fs/promises'", + "await mkdir('dsh-plugin-assets/skills', { recursive: true })", + "await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", + "await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')", + "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", + '', + ].join('\n')) + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) + await execFileAsync('git', ['add', '.'], { cwd: repository }) + await execFileAsync('git', [ + '-c', 'user.name=Repository Fixture', + '-c', 'user.email=repository@example.invalid', + 'commit', '--quiet', '-m', 'fixture', + ], { cwd: repository }) + const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }) + const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin` + vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') + vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') + + const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) + await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') + await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') + await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) + .resolves.toBe('repository skill source\n') + await expect(readFile(join(installed, 'package.json'), 'utf8')) + .resolves.toContain('repository-plugin-fixture') + }) +}) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index 23f83dda51..beb61317dc 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/hmr" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b38715d30..a8933ef4c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-repository-plugin': + specifier: workspace:^ + version: link:../../packages/cordis/repository-plugin '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../packages/sandbox/sandbox-local @@ -429,9 +432,6 @@ importers: '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-tool-session-query': - specifier: workspace:^ - version: link:../../packages/session-query/tool-session-query '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -488,7 +488,7 @@ importers: version: 15.0.0 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../vendor/cordis js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -671,6 +671,9 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:* version: link:../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-repository-plugin': + specifier: workspace:* + version: link:../packages/cordis/repository-plugin '@deepseek-ai/dsh-sandbox-local': specifier: workspace:* version: link:../packages/sandbox/sandbox-local @@ -841,7 +844,7 @@ importers: version: 0.25.1(zod@4.4.3) schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -869,7 +872,7 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/bash/bash: devDependencies: @@ -884,13 +887,13 @@ importers: version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/bash/bash-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -909,7 +912,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/bash/bash-sandbox: devDependencies: @@ -936,7 +939,7 @@ importers: version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -945,7 +948,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1009,7 +1012,7 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/connection: dependencies: @@ -1030,7 +1033,7 @@ importers: version: link:../../core/tools schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1040,13 +1043,13 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/hmr: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -1062,7 +1065,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/client/locale: devDependencies: @@ -1083,7 +1086,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1101,7 +1104,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/client/runtime: dependencies: @@ -1156,20 +1159,20 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/schema-form: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/test-runtime: dependencies: @@ -1206,7 +1209,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1252,7 +1255,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1310,7 +1313,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1352,7 +1355,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1382,7 +1385,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1424,7 +1427,7 @@ importers: version: 2.1.1 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1466,7 +1469,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1511,7 +1514,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1553,7 +1556,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1614,7 +1617,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-question: dependencies: @@ -1666,7 +1669,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-settings: dependencies: @@ -1700,7 +1703,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1715,7 +1718,7 @@ importers: version: link:../../settings/settings schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -1749,7 +1752,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1786,7 +1789,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1810,7 +1813,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-slash: dependencies: @@ -1841,7 +1844,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1856,7 +1859,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-subagent: devDependencies: @@ -1874,7 +1877,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-theme: dependencies: @@ -1905,7 +1908,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1936,7 +1939,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1976,7 +1979,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2028,7 +2031,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2053,7 +2056,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/code-runtime/code-runtime: devDependencies: @@ -2062,13 +2065,13 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/code-runtime/code-runtime-worker: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ @@ -2084,7 +2087,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/compact/command-compact: devDependencies: @@ -2114,7 +2117,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/compact/compact: devDependencies: @@ -2129,13 +2132,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/compact/compact-basic: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -2178,13 +2181,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/compact/compact-tool-result-prune: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -2203,13 +2206,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/context/session-reference: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2234,13 +2237,13 @@ importers: version: link:../../session-query/session-query cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/context/time-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2271,13 +2274,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/context/tmux-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2299,13 +2302,13 @@ importers: version: link:../../core/system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/context/workspace-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -2348,17 +2351,51 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis + + packages/cordis/repository-plugin: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../mcp/mcp-client + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../skill/skill-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis packages/cordis/tool-cordis: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer @@ -2391,7 +2428,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/agent: devDependencies: @@ -2415,13 +2452,13 @@ importers: version: link:../system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/agent-loop: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2452,7 +2489,7 @@ importers: version: link:../tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/scope: devDependencies: @@ -2461,7 +2498,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/session: devDependencies: @@ -2479,13 +2516,13 @@ importers: version: link:../scope cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/system-prompt: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -2498,13 +2535,13 @@ importers: version: link:../scope cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/tools: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2532,7 +2569,7 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/credentials/credentials: devDependencies: @@ -2544,7 +2581,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/credentials/credentials-local: dependencies: @@ -2556,7 +2593,7 @@ importers: version: 17.4.2 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -2572,7 +2609,7 @@ importers: version: link:../../util/paths cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/examples/acp-demo: devDependencies: @@ -2620,16 +2657,16 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/examples/agent-spine-demo: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-timer': specifier: workspace:^ @@ -2729,7 +2766,7 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -2777,10 +2814,10 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/examples/jsonrpc-demo: dependencies: @@ -2793,7 +2830,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs: devDependencies: @@ -2811,7 +2848,7 @@ importers: version: link:../../sandbox/sandbox cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs-local: dependencies: @@ -2820,7 +2857,7 @@ importers: version: 3.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-fs': specifier: workspace:^ @@ -2833,7 +2870,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs-policy: devDependencies: @@ -2848,7 +2885,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs-sandbox: devDependencies: @@ -2869,7 +2906,7 @@ importers: version: link:../../sandbox/sandbox-policy cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/tool-fs: dependencies: @@ -2878,7 +2915,7 @@ importers: version: 9.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2927,7 +2964,7 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/tool-fs-search: dependencies: @@ -2936,7 +2973,7 @@ importers: version: 1.18.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2970,13 +3007,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/tool-str-replace-editor: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3016,7 +3053,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/goal/command-goal: devDependencies: @@ -3043,13 +3080,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/goal/goal: dependencies: schemastery: specifier: ^3.17.2 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3080,7 +3117,7 @@ importers: version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/goal/goal-session: devDependencies: @@ -3113,13 +3150,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/goal/tool-goal: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -3147,13 +3184,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/guard/repeat-tool-guard: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3178,7 +3215,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/hooks/hook-protocol: devDependencies: @@ -3193,13 +3230,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/hooks/hooks-claude: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3245,13 +3282,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/hooks/hooks-codex: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3294,7 +3331,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/host/apiproxy: dependencies: @@ -3360,7 +3397,7 @@ importers: version: link:../../workspace/workspace schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3376,7 +3413,7 @@ importers: version: link:../../storage/storage-domain cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/host/directory-picker: devDependencies: @@ -3385,7 +3422,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/host/directory-picker-auto: devDependencies: @@ -3412,7 +3449,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/host/directory-picker-browse: dependencies: @@ -3424,7 +3461,7 @@ importers: version: 2.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -3452,7 +3489,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -3483,7 +3520,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -3492,20 +3529,20 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3518,7 +3555,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm-deepseek: dependencies: @@ -3527,7 +3564,7 @@ importers: version: 3.1.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-credentials': specifier: workspace:^ @@ -3546,7 +3583,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm-pi-ai: dependencies: @@ -3555,7 +3592,7 @@ importers: version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-credentials': specifier: workspace:^ @@ -3577,13 +3614,13 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm-retry: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3632,13 +3669,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/llm/token-meter: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3657,7 +3694,7 @@ importers: version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/lsp/lsp: devDependencies: @@ -3672,13 +3709,13 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/lsp/lsp-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3703,7 +3740,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -3715,7 +3752,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3749,7 +3786,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/mcp/mcp-client: dependencies: @@ -3758,7 +3795,7 @@ importers: version: 1.29.0(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3783,7 +3820,7 @@ importers: version: 2026.7.10(zod@4.4.3) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/plan/plan-mode: dependencies: @@ -3826,7 +3863,7 @@ importers: version: link:../../ui/user-interaction cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/pty/pty: devDependencies: @@ -3844,7 +3881,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/pty/pty-local: dependencies: @@ -3853,7 +3890,7 @@ importers: version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3878,13 +3915,13 @@ importers: version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/pty/tool-bash-persistent: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3927,13 +3964,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/pty/tool-pty: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3985,7 +4022,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/sandbox/sandbox: devDependencies: @@ -3997,7 +4034,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sandbox/sandbox-local: dependencies: @@ -4006,7 +4043,7 @@ importers: version: 0.0.0-test.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4019,13 +4056,13 @@ importers: version: link:../sandbox cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sandbox/sandbox-policy: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4044,7 +4081,7 @@ importers: version: link:../../core/system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/create-sdk: dependencies: @@ -4060,7 +4097,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/helper: dependencies: @@ -4109,7 +4146,7 @@ importers: version: link:../../web/tool-web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/scripts: dependencies: @@ -4134,7 +4171,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) @@ -4158,7 +4195,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/sdk-protocol: devDependencies: @@ -4176,7 +4213,7 @@ importers: version: link:../../subagent/subagent cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/telemetry: dependencies: @@ -4195,7 +4232,7 @@ importers: version: link:../../util/paths cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-persistence/session-checkpoint-policy: devDependencies: @@ -4234,7 +4271,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence: devDependencies: @@ -4252,7 +4289,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence-jsonl: dependencies: @@ -4261,7 +4298,7 @@ importers: version: 3.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4274,13 +4311,13 @@ importers: version: link:../session-persistence cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4293,7 +4330,7 @@ importers: version: link:../session-persistence cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-projection/session-projection: dependencies: @@ -4309,13 +4346,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-projection/session-projection-cache: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4340,7 +4377,7 @@ importers: version: link:../../storage/storage-domain cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-query/session-query: devDependencies: @@ -4364,13 +4401,13 @@ importers: version: link:../../session-title/session-title cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-query/session-query-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -4392,13 +4429,13 @@ importers: version: link:../session-query cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-query/tool-session-query: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4441,13 +4478,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-title/session-title: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4475,13 +4512,13 @@ importers: version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-title/session-title-all-messages-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4500,13 +4537,13 @@ importers: version: link:../session-title-llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-title/session-title-first-message-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -4534,13 +4571,13 @@ importers: version: link:../session-title-llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-title/session-title-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4559,7 +4596,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/settings/settings: devDependencies: @@ -4571,10 +4608,10 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/settings/settings-local: dependencies: @@ -4583,7 +4620,7 @@ importers: version: 4.0.3 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery yaml: specifier: ^2.9.0 version: 2.9.0 @@ -4602,20 +4639,20 @@ importers: version: link:../settings cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/skill/skill: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/skill/skill-local: dependencies: @@ -4624,7 +4661,7 @@ importers: version: 5.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery yaml: specifier: ^2.4.2 version: 2.9.0 @@ -4643,13 +4680,13 @@ importers: version: link:../skill cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/skill/tool-skill: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4677,7 +4714,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/spill/spill: devDependencies: @@ -4695,13 +4732,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/spill/spill-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -4720,13 +4757,13 @@ importers: version: link:../spill cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/spill/spill-policy: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4754,7 +4791,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage: devDependencies: @@ -4763,13 +4800,13 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage-domain: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4782,13 +4819,13 @@ importers: version: link:../storage cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage-json: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4798,13 +4835,13 @@ importers: version: link:../storage cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4814,7 +4851,7 @@ importers: version: link:../storage cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent: devDependencies: @@ -4841,7 +4878,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-acp: dependencies: @@ -4850,11 +4887,11 @@ importers: version: 0.25.1(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4881,17 +4918,17 @@ importers: version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-dsh-sdk: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4921,17 +4958,17 @@ importers: version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-fork: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4961,7 +4998,7 @@ importers: version: link:../subagent-spawn cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-inprocess: devDependencies: @@ -5006,17 +5043,17 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-spawn: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5058,17 +5095,17 @@ importers: version: link:../tool-subagent cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/tool-subagent: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5098,7 +5135,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subprocess/subprocess: devDependencies: @@ -5107,7 +5144,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subprocess/subprocess-local: devDependencies: @@ -5119,7 +5156,7 @@ importers: version: link:../subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/acp-snapshot: dependencies: @@ -5138,7 +5175,7 @@ importers: version: link:../invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/agent-loop-testkit: devDependencies: @@ -5165,17 +5202,17 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/invariants: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/llm-mock-server: devDependencies: @@ -5184,7 +5221,7 @@ importers: version: link:../invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/llm-replay: devDependencies: @@ -5199,7 +5236,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/loader-smoke: dependencies: @@ -5215,7 +5252,7 @@ importers: version: link:../invariants cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/tasks/tasks: devDependencies: @@ -5233,7 +5270,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/tasks/tasks-local: devDependencies: @@ -5257,13 +5294,13 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/tasks/tool-tasks: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5294,7 +5331,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/telemetry/session-telemetry: devDependencies: @@ -5309,7 +5346,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/telemetry/session-telemetry-otel: dependencies: @@ -5333,7 +5370,7 @@ importers: version: 0.220.0(@opentelemetry/api@1.9.1) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5358,7 +5395,7 @@ importers: version: link:../session-telemetry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/timeout/timeout-policy: devDependencies: @@ -5376,7 +5413,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/todo/tool-todo: dependencies: @@ -5419,7 +5456,7 @@ importers: version: link:../../ui/user-interaction cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/typert/generator: dependencies: @@ -5438,7 +5475,7 @@ importers: version: link:../registry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -5447,7 +5484,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5460,7 +5497,7 @@ importers: version: link:../registry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -5476,7 +5513,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/app-boot: dependencies: @@ -5484,12 +5521,18 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:^ + version: link:../../../vendor/hmr '@cordisjs/plugin-include': specifier: workspace:^ version: link:../../../vendor/include '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5504,7 +5547,7 @@ importers: version: 4.0.9 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/commands: devDependencies: @@ -5525,13 +5568,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/jsonrpc: dependencies: schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5568,13 +5611,13 @@ importers: version: link:../../subagent/subagent cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/permission: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -5608,7 +5651,7 @@ importers: version: link:../user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/tool-ask-user: devDependencies: @@ -5632,7 +5675,7 @@ importers: version: link:../user-interaction cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/tui: dependencies: @@ -5644,7 +5687,7 @@ importers: version: 6.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5720,13 +5763,13 @@ importers: version: 5.5.0 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/user-approval: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5751,7 +5794,7 @@ importers: version: link:../../core/system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/user-interaction: devDependencies: @@ -5766,7 +5809,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/atomic-write: devDependencies: @@ -5775,7 +5818,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/brand: devDependencies: @@ -5784,7 +5827,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/native-command: devDependencies: @@ -5793,7 +5836,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/paths: devDependencies: @@ -5802,7 +5845,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/retention: devDependencies: @@ -5811,7 +5854,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/timeout: devDependencies: @@ -5820,7 +5863,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/tool-web: dependencies: @@ -5829,7 +5872,7 @@ importers: version: 1.0.67 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery turndown: specifier: ^7.2.4 version: 7.2.4 @@ -5875,13 +5918,13 @@ importers: version: 5.0.6 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5891,13 +5934,13 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-fetch-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5910,13 +5953,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-search-deepseek: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5938,13 +5981,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-search-exa: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5954,13 +5997,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-search-perplexity: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5970,13 +6013,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/workflow/tool-ralph: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -6022,13 +6065,13 @@ importers: version: link:../workflow-workerthread cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/workflow/tool-workflow: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6059,7 +6102,7 @@ importers: version: link:../workflow-workerthread cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/workflow/workflow: devDependencies: @@ -6080,13 +6123,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/workflow/workflow-workerthread: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6126,7 +6169,7 @@ importers: version: link:../workflow cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis tsx: specifier: ^4.19.2 version: 4.22.4 @@ -6157,7 +6200,7 @@ importers: version: link:../../storage/storage-domain cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis python/sdk-runtime: dependencies: @@ -6460,16 +6503,16 @@ importers: dependencies: '@cordisjs/plugin-include': specifier: ^1.0.4 - version: 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) + version: link:../include '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit vendor/cosmokit: {} @@ -6477,10 +6520,10 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis vendor/hmr: dependencies: @@ -6489,22 +6532,22 @@ importers: version: 7.29.7 '@cordisjs/plugin-timer': specifier: ^1.1.2 - version: 1.1.2(cordis@4.0.0-rc.7) + version: link:../timer chokidar: specifier: ^4.0.3 version: 4.0.3 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit picomatch: specifier: ^4.0.3 version: 4.0.4 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../schemastery devDependencies: '@types/babel__code-frame': specifier: ^7.27.0 @@ -6520,13 +6563,13 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit js-yaml: specifier: ^4.1.0 version: 4.2.0 @@ -6535,25 +6578,28 @@ importers: dependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit node-addon-require-builtin: specifier: ^0.1.3 version: 0.1.3 + pnpm: + specifier: 11.7.0 + version: 11.7.0 vendor/logger-console: dependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../schemastery supports-color: specifier: ^9.4.0 version: 9.4.0 @@ -6565,16 +6611,16 @@ importers: version: 1.1.0 cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit vendor/timer: dependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit website: devDependencies: @@ -6950,26 +6996,6 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@cordisjs/plugin-include@1.0.4': - resolution: {integrity: sha512-b1Hm1wmue0v7d/jayoXoBjCV2J14XWTL5yyDZEYeL2L9HgcyTq6JbCw99ozSbei98uAbkwq/pBhimsp/HsySeg==} - peerDependencies: - '@cordisjs/plugin-loader': ^1.0.0-rc.4 - cordis: ^4.0.0-rc.5 - - '@cordisjs/plugin-loader@1.0.0-rc.5': - resolution: {integrity: sha512-084Wn2SzkFinbaASTq8blHOUqQt/oxZfX6gnrt0lnJ1CrystulFLL1+XVgF4o7lUMN9bHn4cfT1pMtkHprCtHw==} - peerDependencies: - cordis: ^4.0.0-rc.7 - node-addon-require-builtin: ^0.1.0 - peerDependenciesMeta: - node-addon-require-builtin: - optional: true - - '@cordisjs/plugin-timer@1.1.2': - resolution: {integrity: sha512-5z5C3Eewt8JzK9XGy5JgIoYFRqXPWZnT7hHFfuJMQNzSom6iEVeLXpYiMvqVqGfJicHA7IroaOjcLRf99sidrQ==} - peerDependencies: - cordis: ^4.0.0-rc.5 - '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -9407,18 +9433,6 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cordis@4.0.0-rc.7: - resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==} - hasBin: true - peerDependencies: - '@cordisjs/plugin-include': ^1.0.4 - '@cordisjs/plugin-loader': ^1.0.0-rc.5 - peerDependenciesMeta: - '@cordisjs/plugin-include': - optional: true - '@cordisjs/plugin-loader': - optional: true - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -9432,9 +9446,6 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - cosmokit@1.8.1: - resolution: {integrity: sha512-PDBv4l90xZKrUsZ0vtoycgZpO/j4iFsqJXrAxsyBDsnQRI7ZMJXIjgDJsKNjd5L8jnVnnlrDCdhkFbTncgCVjQ==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -11043,6 +11054,11 @@ packages: engines: {node: '>=18'} hasBin: true + pnpm@11.7.0: + resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} + engines: {node: '>=22.13'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -11263,9 +11279,6 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - schemastery@3.18.0: - resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} - scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -12520,33 +12533,6 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)': - dependencies: - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - js-yaml: 4.2.0 - - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7)': - dependencies: - '@cordisjs/plugin-loader': link:vendor/loader - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - cosmokit: 1.8.1 - js-yaml: 4.2.0 - optional: true - - '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3)': - dependencies: - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - optionalDependencies: - node-addon-require-builtin: 0.1.3 - - '@cordisjs/plugin-timer@1.1.2(cordis@4.0.0-rc.7)': - dependencies: - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - '@csstools/color-helpers@6.1.0': {} '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -14537,30 +14523,6 @@ snapshots: dependencies: is-what: 5.5.0 - cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) - - cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7) - '@cordisjs/plugin-loader': link:vendor/loader - - cordis@4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': link:vendor/include - '@cordisjs/plugin-loader': link:vendor/loader - core-util-is@1.0.3: {} cors@2.8.6: @@ -14576,8 +14538,6 @@ snapshots: dependencies: layout-base: 2.0.1 - cosmokit@1.8.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -16586,6 +16546,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pnpm@11.7.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -16917,11 +16879,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - schemastery@3.18.0: - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb7a7c6322..751037b822 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,10 @@ packages: # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime +# Vendored framework packages keep their upstream semver ranges, while local +# builds must resolve those matching names to this workspace's pinned sources. +linkWorkspacePackages: true + peerDependencyRules: allowedVersions: typescript: '>=5 <7' diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index dc5c392549..97685a9086 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -303,7 +303,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, - { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, + { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' }, + { name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' }, { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, diff --git a/scripts/verify-vendored-links.ts b/scripts/verify-vendored-links.ts new file mode 100644 index 0000000000..93b8390157 --- /dev/null +++ b/scripts/verify-vendored-links.ts @@ -0,0 +1,72 @@ +/** + * Verify that pnpm-lock.yaml resolves every vendored package name to its + * workspace `link:` — never a registry copy. `linkWorkspacePackages: true` + * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the + * pinned vendored sources; a registry copy of the same name coexisting with + * the vendored one silently forks the framework layer (vendor/README.md). + */ +import { readdir, readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import * as yaml from 'js-yaml' + +const root = resolve(import.meta.dirname, '..') + +async function vendoredNames(): Promise> { + const names = new Set() + for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue + let manifest: { name?: string } + try { + manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string } + } catch { + continue // not a package directory (e.g. vendor/README.md siblings) + } + if (manifest.name !== undefined) names.add(manifest.name) + } + return names +} + +interface Lockfile { + importers?: Record> + packages?: Record + snapshots?: Record +} + +const names = await vendoredNames() +if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/') +const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile + +const violations: string[] = [] + +// Importer resolutions: every dependency entry naming a vendored package must +// resolve to a link:, or the build silently uses a registry copy. +for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) { + for (const [section, dependencies] of Object.entries(sections)) { + if (typeof dependencies !== 'object' || dependencies === null) continue + for (const [dependency, entry] of Object.entries(dependencies as Record)) { + if (!names.has(dependency)) continue + const version = entry.version ?? '' + if (!version.startsWith('link:')) { + violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`) + } + } + } +} + +// Package/snapshot keys: a registry copy materializes as a `@` +// key; vendored names must never appear there at all. +for (const section of ['packages', 'snapshots'] as const) { + for (const key of Object.keys(lockfile[section] ?? {})) { + const atIndex = key.lastIndexOf('@') + if (atIndex <= 0) continue + const packageName = key.slice(0, atIndex) + if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`) + } +} + +if (violations.length > 0) { + console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`) + for (const violation of violations) console.error(` - ${violation}`) + process.exit(1) +} +console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`) diff --git a/tsconfig.base.json b/tsconfig.base.json index 2d078202e7..94b60804e4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,6 +32,7 @@ "cosmokit": ["./vendor/cosmokit/src"], "schemastery": ["./vendor/schemastery/src"], "@cordisjs/plugin-loader": ["./vendor/loader/src"], + "@cordisjs/plugin-loader/repository": ["./vendor/loader/src/repository.ts"], "@cordisjs/plugin-include": ["./vendor/include/src"], "@cordisjs/plugin-group": ["./vendor/group/src"], "@cordisjs/plugin-timer": ["./vendor/timer/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 5632ff05c1..ef72d6a43d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -195,6 +195,7 @@ { "path": "./packages/plan/plan-mode" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, + { "path": "./packages/cordis/repository-plugin" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, { "path": "./packages/hooks/hooks-codex" }, diff --git a/vendor/README.md b/vendor/README.md index 2d3e1b6b05..3872faa753 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages keep their **original npm names** (they are resolved through pnpm workspaces) and are marked `private: true` — they are never published from this repo. Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). @@ -35,11 +35,13 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. -6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. +6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. -9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -10. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. +11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 7831fa75d1..2e862c97d4 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -334,7 +334,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 61de8bed04..5511b39036 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -728,13 +728,13 @@ export class Fiber { * * @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) { this.assertActive() config = resolveConfig(this.runtime!, config) - this.context.waterfall(this, 'internal/update', config, noSave, () => { + return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 9727580efd..65ce923dc3 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,9 +1,10 @@ -import { Context, Inject, Service, type Plugin } from 'cordis' +import { Context, Service, type Plugin } from 'cordis' import type { Dict } from 'cosmokit' import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' -import { relative, resolve } from 'node:path' +import { dirname, relative, resolve } from 'node:path' +import { stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -19,6 +20,13 @@ declare module 'cordis' { interface Events { 'hmr/change'(url: string): void 'hmr/reload'(reloads: Map): void + /** + * A watched config-file refresh failed. + * @param filename - Absolute path observed by HMR. + * @param error - Normalized refresh failure. + * @mode parallel + */ + 'hmr/config-update-failed'(filename: string, error: Error): Promise | void } } @@ -44,13 +52,42 @@ interface Reload { runtime?: Plugin.Runtime } -@Inject('loader') -@Inject('timer') +interface ConfigRefresh { + dirty: boolean + running?: Promise +} + +interface ConfigRegistration { + watcher: FSWatcher +} + +async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { + let root = dirname(filename) + let depth = 0 + while (true) { + try { + if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) + return { root, depth } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(root) + if (parent === root) throw error + root = parent + depth += 1 + } + } +} + class Hmr extends Service { + static inject = ['loader', 'timer'] + public baseDir: string private internal: ModuleLoader private watcher!: FSWatcher + private readonly configs = new Map() + private readonly configRefreshes = new WeakMap() + private readonly refreshTasks = new Set>() /** * Changes from externals will always trigger a full reload. @@ -82,6 +119,65 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } + /** + * Watch one exact config path outside the configured module roots. + * @param filename - Config path, resolved against the HMR base directory. + * @param refresh - Refresh callback run serially on add, change, or unlink. + * @returns an asynchronous disposer once the exact watch is ready. + * @throws when HMR is inactive, the path is already registered, or watcher startup fails. + */ + async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { + if (!this.watcher) throw new Error('HMR is not active') + filename = resolve(this.baseDir, filename) + if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + + const { root, depth } = await findWatchRoot(filename) + const watcher = watch(root, { + ...this.config, + cwd: undefined, + depth, + ignored: undefined, + ignoreInitial: false, + }) + const registration = { watcher } + this.configs.set(filename, registration) + const onChange = (path: string) => { + if (resolve(path) !== filename) return + this.refreshConfig(registration, filename, refresh) + } + watcher.on('add', onChange) + watcher.on('change', onChange) + watcher.on('unlink', onChange) + + const ready = Promise.withResolvers() + let readyState: 'pending' | 'resolved' | 'rejected' = 'pending' + watcher.once('ready', () => { + readyState = 'resolved' + ready.resolve() + }) + watcher.on('error', (error) => { + if (readyState === 'pending') { + readyState = 'rejected' + ready.reject(error) + } else { + this.ctx.logger.warn(error) + } + }) + + try { + await ready.promise + return this.ctx.effect(() => async () => { + if (this.configs.get(filename) === registration) this.configs.delete(filename) + await watcher.close() + await this.configRefreshes.get(registration)?.running + }, 'hmr.registerConfig()') + } catch (error) { + this.configs.delete(filename) + await watcher.close() + throw error + } + } + /** * Resolve a module specifier to a URL, compatible with Node 22-24. */ @@ -93,7 +189,12 @@ class Hmr extends Service { } async* [Service.init]() { - yield () => this.watcher?.close() + yield async () => { + await this.watcher?.close() + await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close())) + this.configs.clear() + await Promise.allSettled([...this.refreshTasks]) + } const { loader } = this.ctx const { root, ignored } = this.config @@ -122,9 +223,18 @@ class Hmr extends Service { const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) - this.watcher.on('change', async (path) => { - this.ctx.logger.debug('change detected at %C', path) + const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { + this.ctx.logger.debug('%s detected at %C', kind, path) const filename = resolve(this.baseDir, path) + // Config reload: the file is a loader config file (e.g. cordis.yml). + for (const entry of loader.entries()) { + const include = entry.subtree as Include | undefined + if (include?.filename !== filename) continue + this.refreshConfig(include, filename, () => include.refresh()) + return + } + + if (kind !== 'change') return const url = pathToFileURL(filename).href // Full reload: the changed file is part of the framework @@ -138,16 +248,40 @@ class Hmr extends Service { return partialReload() } - // Config reload: the file is a loader config file (e.g. cordis.yml) - for (const entry of this.ctx.loader.entries()) { - const include = entry.subtree as Include | undefined - if (include?.filename !== filename) continue - await include.refresh() - return - } - this.ctx.emit('hmr/change', url) + } + this.watcher.on('add', path => onChange('add', path)) + this.watcher.on('change', path => onChange('change', path)) + this.watcher.on('unlink', path => onChange('unlink', path)) + } + + private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { + const state = this.configRefreshes.get(key) ?? { dirty: false } + this.configRefreshes.set(key, state) + state.dirty = true + if (state.running) return + const task = (async () => { + do { + state.dirty = false + try { + await refresh() + } catch (reason) { + const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) + this.ctx.logger.warn('config reload at %C failed', filename) + this.ctx.logger.warn(error) + try { + await this.ctx.parallel('hmr/config-update-failed', filename, error) + } catch (rejection) { + this.ctx.logger.warn(rejection) + } + } + } while (state.dirty) + })().finally(() => { + state.running = undefined + this.refreshTasks.delete(task) }) + state.running = task + this.refreshTasks.add(task) } // hide stack trace from HMR diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 29c894401c..a13d273bc2 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -35,7 +35,8 @@ const supported = new Set(Object.keys(writable)) * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated: patching shared entry objects would bake earlier patch + * is never mutated and the result is always detached from it (even with no + * patches): patching or mounting shared entry objects would bake earlier * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted entries are indexed * as they are added, so a later patch in the same list can target a row an @@ -50,8 +51,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { - if (!patches?.length) return [...data] data = structuredClone(data) + if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -117,6 +118,20 @@ export function applyEntryPatches( return data } +type ConfigUpdateStage = 'read' | 'parse' | 'validate' + +interface ReadCandidate { + content: string + data: EntryOptions[] +} + +class ConfigFileError extends Error { + constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) { + super(`failed to ${stage} config file ${path}`, { cause }) + this.name = 'ConfigFileError' + } +} + /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -169,17 +184,11 @@ export class Include extends EntryTree { this.readonly = !this.type this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href - ctx.on('internal/update', (config, _, next) => { + ctx.on('internal/update', async (config, _, next) => { if (config.path !== this.config.path) return next() - // Veto the fiber restart (children update in place), but persist the new - // config ourselves — `Fiber.update` only assigns `this.config` behind - // `next()`, and a stale `this.config.patches` would make the next - // `refresh()` re-apply the old overlay. + const data = this.applyPatches(this.data!, config.patches) + await this.root.update(data) this.config = config - this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { - this.ctx.logger.warn('config update at %C failed', this.filename) - this.ctx.logger.warn(error) - }) }) } @@ -192,75 +201,78 @@ export class Include extends EntryTree { } } - private async read(forced = false) { - const content = await readFile(this.filename, 'utf8') - if (!forced && this.content === content) return false + private async read(forced = false): Promise { + let content: string + try { + content = await readFile(this.filename, 'utf8') + } catch (error) { + throw new ConfigFileError('read', this.filename, error) + } + if (!forced && this.content === content) return let data: any - if (this.type === 'application/yaml') { - data = yaml.load(content, { schema }) - } else if (this.type === 'application/json') { - data = JSON.parse(content) - } else { - const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module + try { + if (this.type === 'application/yaml') { + data = yaml.load(content, { schema }) + } else if (this.type === 'application/json') { + data = JSON.parse(content) + } else { + const module = await import(/* @vite-ignore */ this.filename) + data = module.default || module + } + } catch (error) { + throw new ConfigFileError('parse', this.filename, error) } - // An empty or truncated file (common mid-edit: editors and `sed -i` write - // through temp states) parses to `undefined`, not an error; reject every - // non-array shape here so callers see one "invalid file" signal. Content - // and data commit only on success, so an edit that is later reverted to - // the exact last good content correctly reads as "unchanged". if (!Array.isArray(data)) { - throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) + throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array')) } - this.content = content - this.data = data - await this.checkAccess() - return true + return { content, data } } - private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { + private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] { return applyEntryPatches(data, patches, (message, ...args) => { this.ctx.root.logger?.('loader').warn(message, ...args) }) } async* [Service.init]() { + let candidate: ReadCandidate try { - await this.read() + candidate = (await this.read(true))! } catch (error) { - // Only a missing file falls back to `initial` (or the not-found error): - // an existing-but-invalid file must fail loud with its real parse error, - // never be mislabelled as absent or silently overwritten. - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error + if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error if (this.config.initial) { - this.writeFile(this.config.initial as any) - await this.read() + await this._writeFile(this.config.initial as any) + candidate = (await this.read(true))! } else { throw new Error(`config file not found: ${this.filename}`) } } yield () => this.stop() - await this.root.update(this.applyPatches(this.data!)) + await this.apply(candidate) } - stop() { - this.root.stop() + async stop() { + await this.root.stop() } /** - * Re-read the file and refresh child entries when content changed. An - * unreadable or unparsable file logs a warning and keeps the last good - * tree: a hot-reload of a live app must never take the process down. + * Re-read the file and transactionally refresh child entries when content changed. + * @returns a promise resolving after the new tree commits, or immediately when unchanged. + * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. */ async refresh() { - try { - if (!await this.read()) return - await this.root.update(this.applyPatches(this.data!)) - } catch (error) { - this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) - this.ctx.logger.warn(error) - } + const candidate = await this.read() + if (!candidate) return + await this.apply(candidate) + } + + private async apply(candidate: ReadCandidate) { + const data = this.applyPatches(candidate.data, this.config.patches) + await this.root.update(data) + this.content = candidate.content + this.data = candidate.data + await this.checkAccess() } private async _writeFile(config: EntryOptions[]) { diff --git a/vendor/loader/package.json b/vendor/loader/package.json index c7bbaf5176..e24e0657a6 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./repository": { + "types": "./lib/types/repository.d.ts", + "default": "./lib/repository.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/repository.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -32,6 +37,7 @@ } }, "dependencies": { - "cosmokit": "^1.8.1" + "cosmokit": "^1.8.1", + "pnpm": "11.7.0" } } diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..d479fa6c0f 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -21,6 +21,11 @@ export interface EntryOptions { inject?: Inject | null } +function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause) + return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause }) +} + function takeEntries(object: {}, keys: string[]) { const result: [string, any][] = [] for (const key of keys) { @@ -38,6 +43,11 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2])) } +function replaceKeys(target: T, source: T): T { + for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) + return Object.assign(target, source) +} + /** One configured plugin node inside an `EntryTree`. */ export class Entry { static readonly key = Symbol.for('cordis.entry') @@ -51,6 +61,7 @@ export class Entry { public subtree?: EntryTree _initTask?: Promise + _disposing = 0 constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -71,13 +82,18 @@ export class Entry { /** True when this entry or any owning parent entry is disabled. */ get disabled() { + return this._disabled(this.options) + } + + private _disabled(options: EntryOptions) { // group is always enabled - if (this.options.group) return false - let entry: Entry | undefined = this - do { + if (options.group) return false + if (options.disabled) return true + let entry = this.parent.ctx.fiber.entry + while (entry) { if (entry.options.disabled) return true entry = entry.parent.ctx.fiber.entry - } while (entry) + } return false } @@ -90,12 +106,12 @@ export class Entry { return interpolate(this.ctx, this.options.config) } - private _patchContext(diff: string[]) { - this.context.waterfall('loader/patch-context', this, () => { + private async _patchContext(diff: string[]) { + await this.context.waterfall('loader/patch-context', this, async () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) } }) } @@ -106,41 +122,122 @@ export class Entry { await this.init() } + async _dispose(fiber = this.fiber) { + if (!fiber) return + if (this.fiber === fiber) this.fiber = undefined + this._disposing += 1 + try { + await fiber.dispose() + } finally { + this._disposing -= 1 + } + } + /** Merge new options, restart as needed, and persist through the parent tree. */ async update(options: Partial, create = false, force = false) { - const legacy = { ...this.options } - - // step 1: update options - if (create) { - this.options = options as EntryOptions - } else { + const previousOptions = this.options + const legacy = { ...previousOptions } + const candidate = create ? options as EntryOptions : { ...previousOptions } + if (!create) { for (const [key, value] of Object.entries(options)) { if (isNullable(value)) { - delete this.options[key] + delete candidate[key as keyof EntryOptions] } else { - this.options[key] = value + candidate[key as keyof EntryOptions] = value as never } } } - sortKeys(this.options) + sortKeys(candidate) - // step 2: execute - if (this.disabled) { - this.fiber?.dispose() + const diff = Object + .keys({ ...candidate, ...legacy }) + .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions])) + if (!diff.length && !force) return + + const commit = () => { + if (create) return + this.options = replaceKeys(previousOptions, candidate) + } + + const previous = this.fiber + if (!previous?.uid) { + this.fiber = undefined + this.options = candidate + try { + if (!this._disabled(candidate)) await this.init() + } catch (error) { + this.options = previousOptions + throw error + } + commit() return } - // step 3: check if options are changed - if (this.fiber?.uid) { - const diff = Object - .keys({ ...this.options, ...legacy }) - .filter(key => !deepEqual(this.options[key], legacy[key])) - if (!diff.length && !force) return + if (this._disabled(candidate)) { + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + commit() this.context.emit('loader/partial-dispose', this, legacy, true) - this._patchContext(diff) - } else { - await this.init() + return } + + const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group') + if (!replace) { + this.options = candidate + try { + await this._patchContext(diff) + } catch (error) { + this.options = previousOptions + try { + await this._patchContext(diff) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) + return + } + + let plugin: any + try { + plugin = diff.includes('name') + ? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack)) + : previous.runtime!.callback + } catch (error) { + throw updateError('import', candidate, error) + } + + const previousPlugin = previous.runtime!.callback + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + + try { + await this._start(plugin) + } catch (error) { + this.options = previousOptions + try { + await this._start(previousPlugin) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) } getOuterStack = () => { @@ -159,26 +256,39 @@ export class Entry { await (this._initTask ??= this._init()) } finally { this._initTask = undefined + if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - this.fiber?.await().finally(() => { - if (this.loader.getTasks().length) return - this.ctx.reflect.notify(['loader']) - }) + await this.fiber?.await() } private async _init() { - let exports: any + let plugin: any try { - exports = await this.parent.tree.import(this.options.name, this.getOuterStack) + plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack)) } catch (error) { - this.ctx.logger.error(error) - return - } finally { - this._initTask = undefined + throw updateError('import', this.options, error) } - const plugin = this.loader.unwrapExports(exports) - this._patchContext([]) + try { + await this._start(plugin) + } catch (error) { + throw updateError('apply', this.options, error) + } + } + + private async _start(plugin: any) { + let fiber: Fiber | undefined + try { + fiber = await this._create(plugin) + await fiber.await() + } catch (error) { + await this._dispose(fiber) + throw error + } + } + + private async _create(plugin: any): Promise { + await this._patchContext([]) this.loader.showLog(this, 'apply') - this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) + return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index a73e4dea0f..8b96187275 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -19,12 +19,23 @@ export class EntryGroup { async create(options: Omit) { const id = this.tree.ensureId(options) - const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader) + const existing = this.tree.store[id] + const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader)) + const previousParent = entry.parent // Entry may be moved from another group, // so we need to update the parent reference. entry.parent = this // Use `create: true` to replace existing entry.options. - await entry.update(options, true, true) + try { + await entry.update(options, true, true) + } catch (error) { + if (existing) { + entry.parent = previousParent + } else { + delete this.tree.store[id] + } + throw error + } return entry.id } @@ -34,10 +45,10 @@ export class EntryGroup { if (index >= 0) config.splice(index, 1) } - remove(id: string, isDispose = false) { + async remove(id: string, isDispose = false) { const entry = this.tree.store[id] if (!entry) return - entry.fiber?.dispose() + await entry._dispose() if (!isDispose) { this.unlink(entry.options) } @@ -47,26 +58,52 @@ export class EntryGroup { async update(config: EntryOptions[]) { const oldConfig = this.data as EntryOptions[] - this.data = config + const seen = new Set() + for (const options of config) { + const id = this.tree.ensureId(options) + if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`) + seen.add(id) + } const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options])) - const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options])) + const newMap = Object.fromEntries(config.map(options => [options.id, options])) - // update inner plugins - const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[] - await Promise.all(ids.map(async (id) => { - if (newMap[id]) { - await this.create(newMap[id]).catch((error) => { - this.ctx.logger.error(error) - }) - } else { - this.remove(id) + try { + const outcomes = await Promise.allSettled(config.map(options => this.create(options))) + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'loader entries failed to apply') + for (const id of Object.keys(oldMap)) { + if (!newMap[id]) await this.remove(id, true) } - })) + this.data = config + } catch (error) { + const rollbackErrors: unknown[] = [] + for (const id of Object.keys(newMap).reverse()) { + if (oldMap[id]) continue + try { + await this.remove(id, true) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + for (const options of oldConfig) { + try { + await this.create(options) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + this.data = oldConfig + if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed') + throw error + } } - stop() { + async stop() { for (const options of this.data) { - this.remove(options.id, true) + await this.remove(options.id, true) } } } @@ -78,9 +115,7 @@ export class Group extends EntryGroup { constructor(public ctx: Context, public config: EntryOptions[]) { super(ctx, ctx.fiber.entry!.parent.tree) - ctx.on('internal/update', (config) => { - this.update(config) - }) + ctx.on('internal/update', config => this.update(config)) } async* [Service.init]() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 2361b41aaa..9142f3fda5 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -93,7 +93,7 @@ export default function isolate(ctx: Context) { entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate]) }) - ctx.on('loader/patch-context', (entry, next) => { + ctx.on('loader/patch-context', async (entry, next) => { // step 1: generate new isolate map const newMap: Dict = Object.create(entry.parent.ctx[Context.isolate]) for (const name of Object.keys(entry.options.isolate ?? {})) { @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - next() + await next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 79db440601..8cb9fb984d 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -39,12 +39,27 @@ export abstract class EntryTree { .filter(isNonNullable) } - /** Wait until this tree has no pending import or lifecycle tasks. */ + /** + * Wait until this tree has no active import or lifecycle tasks. + * @throws a settled fiber failure, or an aggregate when several fibers failed. + */ async await() { while (true) { const tasks = this.getTasks() - if (!tasks.length) return - await Promise.allSettled(tasks) + if (tasks.length) { + await Promise.allSettled(tasks) + continue + } + const outcomes = await Promise.allSettled( + [...this.entries()].map(entry => entry.fiber?.await()), + ) + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed') + this.ctx.reflect.notify(['loader']) + if (!this.getTasks().length) return } } @@ -81,15 +96,17 @@ export abstract class EntryTree { /** Create an entry in the root group or a nested group. */ async create(options: Omit, parent: string | null = null, position = Infinity) { const group = this.resolveGroup(parent) - group.data.splice(position, 0, options as EntryOptions) + const id = await group.create(options) + const entry = this.resolve(id) + group.data.splice(position, 0, entry.options) group.tree.write() - return group.create(options) + return id } /** Stop and remove an entry from its parent group. */ - remove(id: string) { + async remove(id: string) { const entry = this.resolve(id) - entry.parent.remove(id) + await entry.parent.remove(id) entry.parent.tree.write() } @@ -97,15 +114,31 @@ export abstract class EntryTree { async update(id: string, options: Omit, parent?: string | null, position?: number) { const entry = this.resolve(id) const source = entry.parent + const sourceIndex = source.data.indexOf(entry.options) + let target = source if (parent !== undefined) { - const target = this.resolveGroup(parent) + target = this.resolveGroup(parent) source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) - target.tree.write() entry.parent = target } + try { + await entry.update(options, false, true) + } catch (error) { + if (parent !== undefined) { + target.unlink(entry.options) + source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options) + entry.parent = source + try { + await entry.update({}, false, true) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`) + } + } + throw error + } source.tree.write() - return entry.update(options, false, true) + if (target !== source) target.tree.write() } /** Import a plugin module from a specifier or `cordis:` builtin. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 1e963ea073..798354c7b0 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -24,7 +24,7 @@ declare module 'cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void): void + 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise } interface Context { @@ -87,12 +87,12 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) - ctx.on('internal/update', function (config, noSave, next) { + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() + await next() const unparse = this.runtime?.Config?.['simplify'] this.entry.options.config = unparse ? unparse(config) : config this.entry.parent.tree.write() - return next() }, { global: true, prepend: true }) ctx.on('internal/update', function (config, _, next) { @@ -129,9 +129,12 @@ export class Loader extends EntryTree { // case 5: the entry's tree is being disposed if (!fiber.entry.parent.tree.ctx.fiber.uid) return + // case 6: Loader is replacing or removing this exact fiber + if (fiber.entry._disposing) return + this.showLog(fiber.entry, 'unload') - // case 6: fiber is disposed by loader behavior + // case 7: fiber is disposed by loader behavior // such as inject checker, config file update, ancestor group disable if (fiber.entry.disabled) return diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts new file mode 100644 index 0000000000..94a0c5cf16 --- /dev/null +++ b/vendor/loader/src/repository.ts @@ -0,0 +1,191 @@ +/** + * Exact-specifier repository packages installed through the Loader's bundled + * pnpm. The caller owns source validation and the cache root; this module owns + * isolated installation, single-flight reuse, and atomic cache publication. + */ + +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +/** Exact pnpm release shipped with the Loader for repository installation. */ +export const BUNDLED_PNPM_VERSION = '11.7.0' + +const DEPENDENCY_NAME = 'repository' +const MARKER_NAME = '.repository-cache.json' +const MAX_ERROR_OUTPUT = 32 * 1024 +const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i + +/** Injectable isolated-install boundary used by {@link RepositoryCache}. */ +export type RepositoryInstall = (directory: string) => Promise + +interface CacheMarker { + specifier: string +} + +function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) +} + +function appendOutput(current: string, chunk: Uint8Array): string { + const combined = current + Buffer.from(chunk).toString('utf8') + return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT) +} + +async function installWithBundledPnpm(directory: string): Promise { + const require = createRequire(import.meta.url) + const pnpmManifest = require.resolve('pnpm') + const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs') + let output = '' + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const child = spawn(process.execPath, [ + pnpmBin, + 'install', + '--no-frozen-lockfile', + '--reporter=append-only', + ], { + cwd: directory, + env: scrubEnvironment(), + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.stderr.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.once('error', reject) + child.once('close', (code, signal) => { resolve({ code, signal }) }) + }) + if (result.signal !== null) { + throw new Error(`bundled pnpm install was killed by ${result.signal}${output ? `\n${output.trimEnd()}` : ''}`) + } + if (result.code !== 0) { + throw new Error(`bundled pnpm install exited with code ${String(result.code)}${output ? `\n${output.trimEnd()}` : ''}`) + } +} + +function cacheKey(specifier: string): string { + return createHash('sha256').update(specifier).digest('hex') +} + +async function readCached(directory: string, specifier: string): Promise { + let content: string + try { + content = await readFile(join(directory, MARKER_NAME), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + let parsed: unknown + try { + parsed = JSON.parse(content) as unknown + } catch (error) { + throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`, { cause: error }) + } + if (typeof parsed !== 'object' || parsed === null || typeof (parsed as Partial).specifier !== 'string') { + throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`) + } + const marker = parsed as CacheMarker + if (marker.specifier !== specifier) { + throw new Error(`repository cache key collision for ${JSON.stringify(specifier)}`) + } + const packageDirectory = join(directory, 'node_modules', DEPENDENCY_NAME) + let packageStat + try { + packageStat = await stat(packageDirectory) + } catch (error) { + throw new Error(`repository cache entry is incomplete: ${directory}`, { cause: error }) + } + if (!packageStat.isDirectory()) throw new Error(`repository cache package is not a directory: ${packageDirectory}`) + return packageDirectory +} + +async function removeStaging(directory: string, cause: unknown): Promise { + try { + await rm(directory, { recursive: true, force: true }) + } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], `failed to clean repository staging directory ${directory}`) + } + throw cause +} + +/** + * Persistent exact-specifier package cache backed by bundled pnpm. + * + * One isolated project contains one dependency named `repository`. A successful + * install is atomically renamed into its SHA-256 key, so failed installs never + * become cache hits. The exact specifier is immutable: callers change the + * specifier (normally its Git ref) to request another generation. + */ +export class RepositoryCache { + /** Absolute directory containing immutable repository cache entries. */ + readonly directory: string + + private readonly tasks = new Map>() + + /** + * @param directory - caller-owned persistent cache root. + * @param install - isolated package installation boundary; defaults to the bundled pnpm. + */ + constructor(directory: string, private readonly install: RepositoryInstall = installWithBundledPnpm) { + this.directory = resolve(directory) + } + + /** + * Resolve one package-manager-native dependency specifier to its installed package directory. + * @param specifier - exact immutable dependency specifier used as the permanent cache identity. + * @returns the installed `repository` dependency directory. + * @throws when the specifier is empty/padded, installation fails, or a published cache entry is corrupt. + */ + resolve(specifier: string): Promise { + if (!specifier || specifier.trim() !== specifier) { + throw new TypeError('repository specifier must be a non-empty unpadded string') + } + const existing = this.tasks.get(specifier) + if (existing) return existing + const task = this.resolveUncached(specifier).finally(() => { + if (this.tasks.get(specifier) === task) this.tasks.delete(specifier) + }) + this.tasks.set(specifier, task) + return task + } + + private async resolveUncached(specifier: string): Promise { + const finalDirectory = join(this.directory, cacheKey(specifier)) + const cached = await readCached(finalDirectory, specifier) + if (cached) return cached + + await mkdir(this.directory, { recursive: true }) + const staging = await mkdtemp(join(this.directory, '.repository-')) + try { + await writeFile(join(staging, 'package.json'), `${JSON.stringify({ + name: 'cordis-repository-cache-entry', + private: true, + version: '0.0.0', + packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, + dependencies: { [DEPENDENCY_NAME]: specifier }, + }, undefined, 2)}\n`) + await writeFile(join(staging, 'pnpm-workspace.yaml'), [ + 'packages: []', + 'dangerouslyAllowAllBuilds: true', + '', + ].join('\n')) + await this.install(staging) + const packageDirectory = join(staging, 'node_modules', DEPENDENCY_NAME) + const packageStat = await stat(packageDirectory) + if (!packageStat.isDirectory()) throw new Error(`installed repository is not a directory: ${packageDirectory}`) + await writeFile(join(staging, MARKER_NAME), `${JSON.stringify({ specifier })}\n`) + try { + await rename(staging, finalDirectory) + } catch (error) { + const winner = await readCached(finalDirectory, specifier) + if (!winner) throw error + await rm(staging, { recursive: true, force: true }) + return winner + } + } catch (error) { + return removeStaging(staging, new Error(`failed to prepare repository ${JSON.stringify(specifier)}`, { cause: error })) + } + return (await readCached(finalDirectory, specifier))! + } +} diff --git a/vendor/loader/tsdown.config.ts b/vendor/loader/tsdown.config.ts new file mode 100644 index 0000000000..75e627cdd2 --- /dev/null +++ b/vendor/loader/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** Keep the browser-reachable Loader entry separate from the Node-only repository cache. */ +const shared = { + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + outputOptions: { codeSplitting: false }, + dts: false, + clean: false, +} as const + +export default defineConfig([ + { ...shared, entry: ['lib/types/index.js'] }, + { ...shared, entry: ['lib/types/repository.js'] }, +]) diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 8433f35ec8..f23fac56db 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -7,6 +7,15 @@ "main": "lib/index.cjs", "module": "lib/index.mjs", "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/index.mjs", + "require": "./lib/index.cjs" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, "files": [ "lib/index.mjs", "lib/index.cjs",