diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml new file mode 100644 index 0000000000..eed6bee5f0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-08-05-profile-plugin-bundles.md +2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee +2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md new file mode 100644 index 0000000000..11a8ac3d40 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -0,0 +1,33 @@ +# Agent Note: Profile plugin bundles replace the fixed surface overlays + +Status: implemented + +English | [中文](2026-08-05-profile-plugin-bundles.zh.md) + +## Problem + +The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.yml` shipped inside `apps/cli`, three bespoke entry modes (`--config`, `web`, `-p`) each with its own layer stack, and a single global personal overlay (`$DSH_HOME/config.yaml`). There was no way to install an out-of-tree plugin (a TUI, a provider pack) into a shipped surface without editing the repository, and no place where a third-party package could contribute a default composition. + +## Decision + +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. + +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). + +Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). + +Two supporting refactors: the webserver's built-in static dist serving became the single-owner **fallback seat** (`registerFallback`/`applyIndexTaps`), with the SPA server extracted to `@deepseek-ai/dsh-frontend-static` so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the [dsh CLI personal-config decision](../feature/2026-07-20-dsh-cli-personal-config.md) (`loadPersonalPatches`, `$DSH_HOME/config.yaml`) was retargeted to the per-profile and home-level `cordis.patch.yml` layers (`loadOptionalPatches`, `watchUserPatches` taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing. + +## Alternatives considered + +- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. +- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` seam is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. + +## Consequences + +- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape. +- `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone. +- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly. +- Backends reject nothing old on disk (pre-release stance): `$DSH_HOME/config.yaml` is simply no longer read. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md new file mode 100644 index 0000000000..0e9ebf657c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -0,0 +1,33 @@ +# Agent Note: profile 插件组合包取代固定的表层 overlay + +Status: implemented + +[English](2026-08-05-profile-plugin-bundles.md) | 中文 + +## Problem + +`dsh` 启动器硬编码了自己的组合:`base.cordis.yml` + `web.cordis.yml` 随 `apps/cli` 一起交付,三种各自定制的入口模式(`--config`、`web`、`-p`)各带一套层栈,外加一个全局的个人 overlay(`$DSH_HOME/config.yaml`)。想把树外插件(一个 TUI、一个提供方扩展包)装进已交付的表层,只能修改仓库;第三方包也没有任何位置可以贡献默认组合。 + +## Decision + +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 + +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 + +解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 + +两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的**回退席位**(`registerFallback`/`applyIndexTaps`),SPA 服务器提取到 `@deepseek-ai/dsh-frontend-static`,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;[dsh CLI 个人配置决策](../feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制(`loadPersonalPatches`、`$DSH_HOME/config.yaml`)改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层(`loadOptionalPatches`、接受文件名的 `watchUserPatches`),取代该笔记的各入口模式与文件位置,同时保留其 Harness home 根目录、patch 语义与大声失败的解析。 + +## Alternatives considered + +- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 +- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 +- **在组合包 manifest(元数据清单)中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` seam 是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 + +## Consequences + +- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装;仓库不再需要为每种部署形态各留一行。 +- `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。 +- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会大声失败。 +- 后端不拒绝磁盘上的任何旧格式(发布前姿态):`$DSH_HOME/config.yaml` 只是不再被读取。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml new file mode 100644 index 0000000000..bb344c5afe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md +2026-08-06-token-surface-unpriced-replace-compatibility.md: 77b778fba695f560eb68af6da2f416e61ccff181 +2026-08-06-token-surface-unpriced-replace-compatibility.zh.md: dd4c5883c4c6d37809c2f8100f266655d8ba600c diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md new file mode 100644 index 0000000000..77b778fba6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md @@ -0,0 +1,35 @@ +# Agent Note: unpriced surface replacements fold neutrally + +Status: implemented + +English | [中文](2026-08-06-token-surface-unpriced-replace-compatibility.zh.md) + +## Problem + +The `contextPressure` and `contextBreakdown` projections keep a running surface-token total plus at most one pending shadow-price claim, so their persisted checkpoints stay O(1) over a session's life. Current replace producers append a `compact/summary` or `compact/prune` metering event immediately before the replacement; its `shadowedTokenCount` prices the exact replaced range, and `foldSurfaceProjection` turns that into the signed delta. + +Sessions recorded before the shadow-price protocol log replacements with no adjacent metering event. The O(1) state cannot reconstruct the replaced range's price, and the fold treated every unpriced replacement as a contract violation and threw — so replaying such a session died at its first replacement (`token surface: replace at seq … has no adjacent shadow price`), leaving the session permanently unopenable. + +## Decision + +A replace that arrives with no armed claim folds price-neutrally: `foldSurfaceProjection` returns `deltaTokens: 0`, pricing the replaced range as if it had cost exactly what its replacement costs, and replay continues. A claim expired by an intervening event reaches the same neutral path, since the fold cannot distinguish it from a log that never metered. + +An armed claim naming a **different** range still throws. There the metering event was adjacent, so the producer wrote contradictory adjacent events — a live shadow-price contract violation, not historical data, and it must fail loud rather than let the total drift silently. + +Both projections share the one fold, so neither gains state fields nor bumps its `stateVersion`. `surface-fold.ts` and `ctx.tokenMeter.measure()` are unaffected: they hold the per-node priced surface and never needed the claim protocol. + +## Alternatives considered + +**Keep throwing.** Preserves the strict producer contract, but every pre-protocol session stays permanently unreplayable, and the projections exist to serve replay. + +**Persist the full priced surface in the projection state.** Could price any replaced range exactly, but grows the checkpoint by one node per model-visible message without bound — defeating the O(1) constraint the shadow-price protocol exists to preserve (see [the context-meter note](2026-08-05-context-meter-blind-to-compaction.md)). + +## Consequences + +An unpriced replacement holds the total still instead of shrinking it, so the compacted-away span stays counted: `contextBreakdown.messageTokens` retains the overcount, and `contextPressure.projectedTokens` overestimates occupancy only until the next usage sample re-anchors it, because that figure tracks movement since the sample rather than the absolute level. The error direction is safe — overestimating occupancy at worst invites an earlier compaction. + +The loud failure survives where it still means something: a range-mismatched adjacent claim is a current producer bug and still throws. + +## Testing + +`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins the neutral fold for the no-claim and expired-claim replacements, the throw for a mismatched claim, and the exact pricing for a matched one. `packages/llm/token-meter/tests/token-usage-projection.spec.ts` pins `contextPressure` holding still across an unpriced replacement. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md new file mode 100644 index 0000000000..dd4c5883c4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 未计价的表层替换以中性方式折叠 + +Status: implemented + +[English](2026-08-06-token-surface-unpriced-replace-compatibility.md) | 中文 + +## 问题 + +`contextPressure` 与 `contextBreakdown` 两个投影只维护一份滚动累计的表层 token 总量,外加至多一条待结算的影子价格(shadow price)声明,因此其持久化检查点在会话整个生命周期内保持 O(1)。当前的替换生产方会紧贴在替换之前追加一条 `compact/summary` 或 `compact/prune` 计量事件;其 `shadowedTokenCount` 对被替换区间精确计价,`foldSurfaceProjection` 再把它换算成有符号增量。 + +影子价格协议引入之前录制的会话,其日志中的替换没有相邻的计量事件。O(1) 状态无法重建被替换区间的价格,而折叠此前把每一次未计价替换都当作契约违规并抛出异常,于是回放这类会话会在第一处替换就中断(`token surface: replace at seq … has no adjacent shadow price`),会话从此永远无法打开。 + +## 决策 + +到达时没有已就位声明的替换以价格中性的方式折叠:`foldSurfaceProjection` 返回 `deltaTokens: 0`,相当于把被替换区间计价为恰好等于其替换内容的成本,回放随即继续。因中间插入的事件而过期的声明也走同一条中性路径,因为折叠无法把它与从未计量过的日志区分开。 + +已就位但指向**另一个**区间的声明仍会抛出异常。此时计量事件确实相邻,说明生产方写入了互相矛盾的相邻事件:这是现行影子价格契约的违规,不是历史数据,必须响亮失败,而不能任由总量悄然漂移。 + +两个投影共用同一个折叠,因此二者都不新增状态字段,也不提升 `stateVersion`。`surface-fold.ts` 与 `ctx.tokenMeter.measure()` 不受影响:它们持有逐节点的已计价表层,本来就不需要声明协议。 + +## 备选方案 + +**维持抛出异常。**保住了严格的生产方契约,但协议之前的每个会话都将永远无法回放,而投影本就是为服务回放而存在的。 + +**在投影状态中持久化完整的已计价表层。**可以对任意被替换区间精确计价,但检查点会随每条模型可见消息各增加一个节点、无上限地增长,恰恰破坏了影子价格协议所要守住的 O(1) 约束(见[上下文仪表的 Agent Note](2026-08-05-context-meter-blind-to-compaction.md))。 + +## 影响 + +未计价的替换让总量保持不动而不是缩小,因此被压缩(compaction)掉的区段仍被计入:`contextBreakdown.messageTokens` 保留这部分多计的量;`contextPressure.projectedTokens` 会高估占用率,但只持续到下一个用量样本重新锚定为止,因为该数字追踪的是自样本以来的增减,而非绝对水平。误差方向是安全的:高估占用率最坏不过是招致一次更早的压缩。 + +响亮失败保留在它仍有意义的地方:区间不匹配的相邻声明是现行生产方的缺陷,仍会抛出异常。 + +## 测试 + +`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住了无声明与声明过期两种替换的中性折叠、声明区间不匹配时的抛出异常,以及声明匹配时的精确计价。`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 钉住了 `contextPressure` 在一次未计价替换前后保持不动。 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 e4e9dfb93a..0406274fbe 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: 1fa8cda2b34b58cc7a28b722872520b68a9b7009 -2026-07-20-dsh-cli-personal-config.zh.md: e70b8914cf005e0a2e54ba2b29d3b7def84b00db +2026-07-20-dsh-cli-personal-config.md: 10f16a1cbabdd8cd383c59ad8e09787c02d0109a +2026-07-20-dsh-cli-personal-config.zh.md: 22435efbec8ea661c546ffd0c1aa9bb0ff2ebbb2 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 1fa8cda2b3..10f16a1cba 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 @@ -10,6 +10,8 @@ A developer's own preferences — which provider and model the TUI uses, persona ## Decision +The entry modes and the personal file's name and location below are superseded by the [profile plugin bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md): `dsh` boots profiles, and the personal layer became the per-profile and home-level `cordis.patch.yml`. What survives unchanged is this note's substance — the Harness home as the machine-level layer's root, patch semantics over a shipped composition, and fail-loud parsing. + 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/*` 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. @@ -46,4 +48,4 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Testing -`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. +`packages/ui/app-boot/tests/user-patches.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `apps/cli/tests/built-bin.e2e.ts` boots the real dsh bin over a profile and exercises the live patch layer end to end. 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 e70b8914cf..22435efbec 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 @@ -10,6 +10,8 @@ Status: implemented ## Decision +下文的各入口模式,以及个人文件的名称与位置,已被 [profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)取代:`dsh` 启动 profile,个人层变成逐 profile 与 home 级的 `cordis.patch.yml`。保留不变的是本笔记的实质:以 Harness home 作为机器级层的根目录、在随附组合之上使用 patch 语义,以及解析时的大声失败。 + 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: **`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` 运行同一入口。 @@ -46,4 +48,4 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Testing -`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。 +`packages/ui/app-boot/tests/user-patches.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`apps/cli/tests/built-bin.e2e.ts` 启动真实 dsh bin 并基于 profile 端到端验证实时 patch 层。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 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 d910be95cf..cbe53b9622 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: 312e61d017abad1a6ac57f2ba491a715f8fd92d0 -2026-07-31-even-out-shipped-tool-rosters.zh.md: c77370c312004052bc4f8ee9545ed287a6d92554 +2026-07-31-even-out-shipped-tool-rosters.md: d12db993654d9f2b41a16a4663dc64aefe8e3a2f +2026-07-31-even-out-shipped-tool-rosters.zh.md: 8381a457def1a909b300de52dc011e2437c7e9f3 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 312e61d017..d12db99365 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,7 +12,7 @@ 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 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. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.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. 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 c77370c312..8381a457de 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,7 +12,7 @@ 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)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.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 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml index 14facd28d9..1104d3f1d7 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.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-workspace-write-surface-default.md -2026-07-31-workspace-write-surface-default.md: a0b216122e301b5332ed761155d743dc78fa3bab -2026-07-31-workspace-write-surface-default.zh.md: 4391daa32b142ea976e3b04833163936913c17bc +2026-07-31-workspace-write-surface-default.md: e6a36ad4ee7179cabf958114681728c3ac7340b4 +2026-07-31-workspace-write-surface-default.zh.md: 5ced928b167892d9abe1f4423da72a0243590735 diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md index a0b216122e..e6a36ad4ee 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md @@ -10,7 +10,7 @@ The shipped terminal and browser surfaces exposed the same coding tools under di ## Decision -[`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) owns one sandbox and permission stack for every shipped TUI, Web, and browser-backed headless session: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`. The composition fallback is the `workspace-write` preset, which bundles `workspace-write` file effects with the `ask` approval policy. `DSH_PERMISSION_MODE` remains an explicit process override; a stored `permission.defaultPreset` remains the user preference for later sessions and outranks the fallback through the Settings seam. +[`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) owns one sandbox and permission stack for every shipped TUI, Web, and browser-backed headless session: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`. The composition fallback is the `workspace-write` preset, which bundles `workspace-write` file effects with the `ask` approval policy. `DSH_PERMISSION_MODE` remains an explicit process override; a stored `permission.defaultPreset` remains the user preference for later sessions and outranks the fallback through the Settings seam. A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Existing and resumed sessions retain their logged permission, and changing the General-settings default affects only sessions created afterward. The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there. diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md index 4391daa32b..5ced928b16 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 和 `dsh-permission`。组合回退值为 `workspace-write` preset,其中包含 `workspace-write` 文件效果模式与 `ask` 审批策略。`DSH_PERMISSION_MODE` 仍是显式的进程级覆盖;已存储的 `permission.defaultPreset` 仍是面向后续会话的用户偏好,并通过 Settings seam 优先于该回退值。 +[`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 和 `dsh-permission`。组合回退值为 `workspace-write` preset,其中包含 `workspace-write` 文件效果模式与 `ask` 审批策略。`DSH_PERMISSION_MODE` 仍是显式的进程级覆盖;已存储的 `permission.defaultPreset` 仍是面向后续会话的用户偏好,并通过 Settings seam 优先于该回退值。 真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。现有会话和恢复的会话保留日志中记录的权限,更改「通用」设置中的默认值只影响之后创建的会话。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。 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 index 4a9a16de25..d191e3926d 100644 --- 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 @@ -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-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 +2026-08-02-session-search-not-shipped-default.md: 65bd72fff76210b726e7562fb8e88e5f8802434a +2026-08-02-session-search-not-shipped-default.zh.md: 5e42a2c2323904117f9322b5c4a53c43c6ed3f2a 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 index ba7299712c..65bd72fff7 100644 --- 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 @@ -6,11 +6,11 @@ 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. +The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made `tool-session-query` a default row of the shared [`cordis.patch.yml`](../../../../packages/bundle/base/cordis.patch.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 shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `cordis.patch.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. 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 index 1678ebfb55..5e42a2c232 100644 --- 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 @@ -6,11 +6,11 @@ Status: implemented ## 问题 -[交付清单决策](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」。这份默认还交付了一个提示词段,向模型讲授一套既往工作搜索工作流,而没有任何用户要求过。 +[交付清单决策](2026-07-31-even-out-shipped-tool-rosters.md)把 `tool-session-query` 设为共享 [`cordis.patch.yml`](../../../../packages/bundle/base/cordis.patch.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 策略一起挂载该包。 +交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.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 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 diff --git a/AGENTS.md b/AGENTS.md index a42f39084a..0d27b20df0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// compact/ compaction seam + basic backend context/ request-context plugins subagent/ subagent seam + spawn/fork/ACP backends + delegation tool + bundle/ profile plugin bundles: installable patch layers for dsh --profile workflow/ workflow seam + worker-thread engine + workflow tool todo/ todo_write tool plan/ plan mode as logged per-agent collaboration state diff --git a/README.i18n.yaml b/README.i18n.yaml index e705f07877..0a7c3e49fe 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: b8e46044fb8857730b32d9fbbb9ed4de964d6017 -README.zh.md: e289d523bf61a577f1dd2335b3b4567736d9100d +README.md: d8d3e767d5a9805f34f4df57a5b1f8ff7fdaa955 +README.zh.md: 89abf8d817deeed2bf4416035790c8696c8c8e33 diff --git a/README.md b/README.md index b8e46044fb..d8d3e767d5 100644 --- a/README.md +++ b/README.md @@ -39,22 +39,24 @@ dsh web The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. -### Configured runtime +### Profiles -Raw `dsh` requires a patch-list configuration applied over the shipped base: +`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`: ```sh -dsh --config ./app.cordis.yml +dsh --profile web # the browser UI (same as: dsh web) +dsh plugin --profile tui add # install a plugin into a custom profile +dsh --profile tui # boot it ``` -The [CLI contract](apps/cli/README.md#raw-config) describes the base, overlay semantics, and config dump commands. +The [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. ### Headless Run one task, print the final answer, and exit: ```sh -dsh -p "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index e289d523bf..89abf8d817 100644 --- a/README.zh.md +++ b/README.zh.md @@ -39,22 +39,24 @@ dsh web 上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 -### 自定义运行时 +### Profile -原始 `dsh` 要求传入一份 patch 列表配置,并将其叠加在随附 base 之上: +`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层: ```sh -dsh --config ./app.cordis.yml +dsh --profile web # the browser UI (same as: dsh web) +dsh plugin --profile tui add # install a plugin into a custom profile +dsh --profile tui # boot it ``` -base、overlay 语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#raw-config)。 +profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。 ### Headless 运行一项任务,打印最终答案后退出: ```sh -dsh -p "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### 自动化与 SDK diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index e115e43e64..cbc74b6d0a 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: ce7af5a299e45d6f107686aff043246914dce8ed -README.zh.md: e97fec9d6bb726cb1e419a1ca2fa1871d4d203ca +README.md: f50f26ec5de54e094e17221f7cd355483483754e +README.zh.md: 242e64a0c42064b9f0b7621e665e85fe44523fe5 diff --git a/apps/cli/README.md b/apps/cli/README.md index ce7af5a299..f50f26ec5d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,24 +2,25 @@ English | [中文](README.zh.md) -The `dsh` command is the product launcher for raw Cordis configurations, the Web UI, and one-shot headless tasks. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. +The `dsh` command is the product launcher for profiles: ordered stacks of plugin-bundle patch layers under the user's own overrides. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. ## Entry modes | Command | Purpose | |---|---| -| `dsh --config ./app.cordis.yml` | Run an explicit patch-list configuration over the shipped base. | -| `dsh web` | Start the browser UI with the shipped Web composition and optional personal configuration. | -| `dsh -p "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | +| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | +| `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. Web and headless share the shipped provider, persistence, policy, tool, repository Plugin, and telemetry composition; raw config selects its own deployment-specific front door. +The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. -## Raw config +## Profiles -Raw `dsh` requires `--config`. The named patch list is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml); it is not a complete replacement tree and does not add a surface overlay or personal `$DSH_HOME/config.yaml`. Use `--dump-default-config` and `--dump-config` to inspect the resulting tree without booting it. +A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. -The [CLI behavior reference](reference/README.md) owns exact overlay precedence, flags, shutdown behavior, deployment defaults, and the source launcher. +The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher. ## Development -Production Web and headless runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract. +Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e97fec9d6b..242e64a0c4 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,24 +2,25 @@ [English](README.md) | 中文 -`dsh` 命令是原始 Cordis 配置、Web UI 和一次性无头任务的产品启动器。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 命令是 profile 的产品启动器:profile 是按序叠放的插件组合包 patch 层,之上再叠加用户自己的覆盖层。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 ## 入口模式 | 命令 | 用途 | |---|---| -| `dsh --config ./app.cordis.yml` | 在随附基础配置之上运行显式 patch 列表配置。 | -| `dsh web` | 使用随附 Web 组合和可选个人配置启动浏览器 UI。 | -| `dsh -p "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | +| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | +| `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。Web 与无头模式共享随附的提供方、持久化、策略、工具、repository Plugin 和遥测组合;原始配置自行选择部署专用前端入口。 +调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 -## 原始配置 +## Profile -原始 `dsh` 必须提供 `--config`。指定的 patch 列表直接应用到 [`config/base.cordis.yml`](config/base.cordis.yml) 之上;它不是完整替代树,也不会添加 surface overlay 或个人 `$DSH_HOME/config.yaml`。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查生成的配置树。 +profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 -[CLI(命令行界面)行为参考](reference/README.md)负责确切的 overlay 优先级、flag、关闭行为、部署默认值和源码启动器。 +[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。 ## 开发 -生产环境的 Web 和无头运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 +生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 28f58bcf4d..462b528a30 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -3,11 +3,11 @@ # DSH Base Composition -The raw CLI applies one required caller-selected patch list over this shared base; Web and headless apply their own shipped overlays. +The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user's profile layer patch over it. ```mermaid flowchart LR - cfg["apps/cli/config/base.cordis.yml
cordis.yml"] + cfg["packages/bundle/base/cordis.patch.yml
cordis.yml"] plugin_dsh_base_timer["timer
@cordisjs/plugin-timer"] cfg --> plugin_dsh_base_timer plugin_dsh_base_hmr["hmr
@cordisjs/plugin-hmr"] @@ -220,6 +220,6 @@ flowchart LR | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml). +Source config: [`packages/bundle/base/cordis.patch.yml`](../../packages/bundle/base/cordis.patch.yml). Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml deleted file mode 100644 index dddf2fc1b5..0000000000 --- a/apps/cli/config/base.cordis.yml +++ /dev/null @@ -1,403 +0,0 @@ -# The shared `dsh` core. Raw `dsh --config ` applies its required patch -# list directly over this file. Web and headless apply their shipped overlay, -# followed by an explicit or personal user layer. Every layer addresses these -# rows by id at one include level, with the last write winning per row. -# -# A patch replaces the targeted row's whole `config` rather than merging into -# it, so a row whose value differs by mode does NOT live here: it belongs to -# each overlay, keeping any single row down to one overlay layer plus the user's. -# Mode-specific rows appear below only with shared plugin identity and neutral -# defaults; each overlay restates its complete configuration. -# -# Row order carries no load semantics (activation is service-availability -# driven); the grouping is for readers. - -- id: timer - name: '@cordisjs/plugin-timer' - -- id: hmr - name: '@cordisjs/plugin-hmr' - 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' - -- id: session - name: '@deepseek-ai/dsh-session' - -- id: session-title - name: '@deepseek-ai/dsh-session-title' - config: - fallbackMaxWords: 5 - fallbackMaxBytes: 40 - maxTitleBytes: 80 - -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 60000 - -- id: user-interaction - name: '@deepseek-ai/dsh-user-interaction' - -- id: agent - name: '@deepseek-ai/dsh-agent' - -- id: tasks - name: '@deepseek-ai/dsh-tasks-local' - -- id: llm-retry - name: '@deepseek-ai/dsh-llm-retry' - -# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a -# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries -# below without a restart, and is what the web Models page writes. -- id: settings - name: '@deepseek-ai/dsh-settings-local' - -# Credential store: the live process environment over `$DSH_HOME/.env` -# (owner-only file, hot-reloaded). Adapters resolve their key references -# through it at each request, so no key is inlined in this file. The web -# Models page's key inputs write it through `credentials.set`; nothing hoists -# the document into the process environment, which would make every stored key -# read as an unrotatable ambient override. -- id: credentials - name: '@deepseek-ai/dsh-credentials-local' - -# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra -# models in the picker) until a `llm-pi-ai:` settings section supplies provider -# profiles — then those routes register live, keys resolving per request -# through their apiKeyEnv references, and drop again when the section empties. -# Supplying those profiles is exactly what the web Models page does. Which -# adapters exist is composition; which providers run is the user's settings -# document. -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js dshHomePath('sessions') - -# Raw configs can supply a process-local path or disable this shared session -# capability. The neutral default is process-local and opens only when used. -- id: session-query-sqlite - name: '@deepseek-ai/dsh-session-query-sqlite' - config: - path: ':memory:' - openAt: first-search - -# Session telemetry, on for every dsh mode: mirrors every session-log -# event (assistant/chunk projected to first-of-step) plus ops markers onto -# OTLP/HTTP log records, streaming on the batch processor's cadence -# (10s/batch here) — not at exit; a crash loses at most the last unexported -# interval. No telemetry/record redaction rule is mounted yet, so exports -# are the raw captured copy; the deployment stance, env seams, and -# follow-ups are pinned in the web-telemetry-default-mount Agent Note. -# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty -# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the -# process out (the launchers patch the row disabled; config cannot disable -# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid, -# random UUID; delete the file to reset the identity) as the Resource's -# user.id. The exporter/processor values normally bound the shutdown drain -# to ~1s against an unreachable collector: exporter.timeoutMillis is both -# the per-attempt socket timeout and the retry deadline (1s effectively -# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize -# (both explicit) makes the drain a single batch. The SDK awaits -# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s -# shutdownTimeoutMillis is the load-bearing outer bound when a transport -# promise never settles. Every CLI exit path drains it by disposing the root -# on SIGINT/SIGTERM. -- id: telemetry-otel - name: '@deepseek-ai/dsh-session-telemetry-otel' - config: - shutdownTimeoutMillis: 3000 - exporter: - url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' - compression: gzip - timeoutMillis: 1000 - processor: - scheduledDelayMillis: 10000 - maxQueueSize: 2048 - maxExportBatchSize: 2048 - exportTimeoutMillis: 1500 - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -# Every shipped CLI mode starts with the same file-effect boundary. -# The environment remains an explicit deployment override; otherwise fresh -# sessions pin workspace-write + ask through the permission service below. -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' - workspaceRoot: !!js process.cwd() - -- id: bash-sandbox - name: '@deepseek-ai/dsh-bash-sandbox' - config: - timeoutMs: 60000 - -- id: approval - name: '@deepseek-ai/dsh-user-approval' - config: - policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'" - -- id: permission - name: '@deepseek-ai/dsh-permission' - config: - presets: - read-only: - sandbox: read-only - approval: ask - workspace-write: - sandbox: workspace-write - approval: ask - danger-full-access: - sandbox: danger-full-access - approval: never - -- id: bash-env - name: '@deepseek-ai/dsh-bash-env' - -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' - -- id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - sampleOverCapGlobResults: false - -- id: workspace-context - name: '@deepseek-ai/dsh-workspace-context' - config: - maxBytes: 65536 - -- id: skill - name: '@deepseek-ai/dsh-skill' - -- id: skill-local - name: '@deepseek-ai/dsh-skill-local' - -- id: tool-skill - name: '@deepseek-ai/dsh-tool-skill' - -- id: commands - name: '@deepseek-ai/dsh-commands' - -- id: goal - name: '@deepseek-ai/dsh-goal' - -- id: goal-session - name: '@deepseek-ai/dsh-goal-session' - -- id: command-goal - name: '@deepseek-ai/dsh-command-goal' - -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -# Human `/compact`: one useful reduction below the automatic threshold. Backend -# independent, so it follows whichever compaction service this leaf mounts. -- id: command-compact - name: '@deepseek-ai/dsh-command-compact' - -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -# Continuable background children are selected per delegation tool. The -# separately loaded follow-up tool registers the one global `send_message`. -- id: tool-subagent-control - name: '@deepseek-ai/dsh-tool-subagent-control' - -- id: tool-subagent-list-agents - name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - backgroundMode: continuable - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - backgroundMode: continuable - -# Optional direct-child return channel; absent from roots and one-shot agents. -- id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' - -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 - -# Durability checkpoints before each model request and top-level dispatch. -- id: session-checkpoint-policy - name: '@deepseek-ai/dsh-session-checkpoint-policy' - -# Compacts oversized tool results before the broader conversation compactor -# runs, preserving the model-visible result within the configured budget. -- id: tool-result-prune - name: '@deepseek-ai/dsh-compact-tool-result-prune' - config: - thresholdChars: 8192 - headChars: 4096 - tailChars: 1024 - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# Persisted same-session goals reach the model and the slash menu here; the -# domain, driver, and `/goal` command are above. -- id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - -# Fresh-agent Ralph iteration over a build-time-fixed script. -- id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - config: - subagentProvider: spawn - maxRounds: 64 - -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - -# Consecutive-repeat reminders on the tool chain. -- id: repeat-tool-guard - name: '@deepseek-ai/dsh-repeat-tool-guard' - config: - thresholds: [3, 5, 8] - argumentsPreviewChars: 500 - -# Every mode enables the stable web_search model surface. DeepSeek search -# resolves the same DEEPSEEK_API_KEY credential the Models page manages for -# chat, at each search; its Messages endpoint is separate from the -# chat-completions endpoint, so it takes its own base-URL override. Fetch stays -# disabled and no fetch provider is mounted: that provider defers SSRF -# protection and the model would choose the request target. Search is a full -# auxiliary model request with server-side retrieval, so this shipped DeepSeek -# route gets 60s while the provider-neutral tool default remains 30s. -- id: web - name: '@deepseek-ai/dsh-web' - config: - searchProvider: deepseek-official - -- id: web-search-deepseek - name: '@deepseek-ai/dsh-web-search-deepseek' - config: - apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL - -- id: tool-web - name: '@deepseek-ai/dsh-tool-web' - config: - fetch: false - searchTimeoutMs: 60000 - -# ── rows every mode mounts, whose values each overlay may state ────────────── - -# The tool registry. Presentation mode is a deployment choice; omitting it here -# keeps the schema default (native). -- id: tools - name: '@deepseek-ai/dsh-tools' - -# The deployment persona is a deployment choice; plan-mode and tool plugins own -# their own prompt sections. -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - persona: '' - -# Agents created at startup. The base stays empty; raw overlays may create -# agents, while Web creates sessions on client request. -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: [] - -# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; an -# overlay can pin another workspace. -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - -# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per -# request from the `llm-deepseek:` settings section over this entry, with the -# key coming from the credential store below. Thinking defaults are a deployment -# choice. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/apps/cli/package.json b/apps/cli/package.json index 58b92bc41d..87677ce1f9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh", - "description": "dsh CLI: explicit config overlays, headless tasks, and the browser UI", + "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", "version": "0.0.1", "private": true, "type": "module", @@ -17,127 +17,31 @@ "@cordisjs/plugin-include": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-bash-env": "workspace:^", - "@deepseek-ai/dsh-bash-sandbox": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-hmr": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-command": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", - "@deepseek-ai/dsh-client-ui-goal": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-model": "workspace:^", - "@deepseek-ai/dsh-client-ui-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-permission": "workspace:^", - "@deepseek-ai/dsh-client-ui-plan": "workspace:^", - "@deepseek-ai/dsh-client-ui-question": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-slash": "workspace:^", - "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "@deepseek-ai/dsh-command-compact": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compact-basic": "workspace:^", - "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", - "@deepseek-ai/dsh-credentials-local": "workspace:^", - "@deepseek-ai/dsh-frontend": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-fs-policy": "workspace:^", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-base": "workspace:^", + "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-permission": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", - "@deepseek-ai/dsh-pwsh-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:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", - "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", - "@deepseek-ai/dsh-settings-local": "workspace:^", - "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-skill-local": "workspace:^", - "@deepseek-ai/dsh-spill-local": "workspace:^", - "@deepseek-ai/dsh-spill-policy": "workspace:^", - "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-storage-json": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-fork": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tasks-local": "workspace:^", - "@deepseek-ai/dsh-timeout-policy": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-tmux-context": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tool-fs-search": "workspace:^", - "@deepseek-ai/dsh-tool-goal": "workspace:^", - "@deepseek-ai/dsh-tool-ralph": "workspace:^", - "@deepseek-ai/dsh-tool-skill": "workspace:^", - "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh": "workspace:^", - "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", - "@deepseek-ai/dsh-tool-tasks": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tool-web": "workspace:^", - "@deepseek-ai/dsh-tool-workflow": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", - "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/dsh-web-app": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0", "node-addon-require-builtin": "^0.1.4" }, "devDependencies": { + "@deepseek-ai/dsh-frontend-static": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", "execa": "^10.0.0" } diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 7e5b8e5c58..6be68170b9 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: b37ec9ed61ea4e9899a51316065d4188f30997ad -README.zh.md: ca29808a6c8e670f0d0b82c59b1a2c1fa0e13565 +README.md: 62275139809364d80296433e804ade3d447f37e6 +README.zh.md: e508caaf05622c0c177eaacd070978368ff84af3 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index b37ec9ed61..6227513980 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,68 +2,66 @@ English | [中文](README.zh.md) -This reference defines the raw-config, Web, and headless command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. -## Raw config +## Profile boot -Raw `dsh` requires an explicit patch-list config: +`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch ` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. + +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). + +The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + web-app + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. + +A positional task (`dsh --profile headless "run the tests"`) requires the composition to mount the one-shot runner row (`headless-runner`); the launcher patches the task text into that row, the runner drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. + +Inspect the composed tree without booting it: ```sh -dsh --config ./app.cordis.yml +dsh --profile web --dump-default-config +dsh --profile web --patch ./extra.yml --dump-config ``` -The named file is applied directly over [`config/base.cordis.yml`](../config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. -A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve: +## Plugin management -```yaml -- id: agent-loop - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash -``` - -Inspect the effective tree without booting it: +`dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. ```sh -dsh --dump-default-config -dsh --config ./app.cordis.yml --dump-config +dsh plugin --profile tui add github:deepseek-harness/turtle-ui +dsh plugin --profile tui remove turtle-ui +dsh --profile tui ``` -`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. +Git-hosted plugins that ship sources build during install through their `prepare` script, which pnpm ≥10 blocks until the consumer allows it: the first `add` fails with pnpm's `allowBuilds` hint (and a dsh pointer at the profile's `pnpm-workspace.yaml`); copy the printed key there and re-run. Installing a built tarball or a local checkout needs no allowance. -## Web and headless +## Web alias -`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](../config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config ` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web -dsh web --config ./web-profile.cordis.yml -dsh web --dump-default-config +dsh web --patch ./extra.cordis.yml dsh web --dump-config ``` The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. -`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags. +Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. - -Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution. +All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup. New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. +`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional `--patch` overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. ## Shared deployment behavior -The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. -The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. +The empty `repository-plugins` row lets profile patch layers mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. ## Source launcher diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index ca29808a6c..e508caaf05 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,68 +2,66 @@ [English](README.md) | 中文 -本参考定义原始配置、Web 和无头命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 -## 原始配置 +## Profile 启动 -原始 `dsh` 必须提供显式 patch 列表配置: +`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch ` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 + +组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。 + +`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 + +位置参数任务(`dsh --profile headless "run the tests"`)要求组合挂载一次性运行器行(`headless-runner`);启动器把任务文本 patch 进该行,运行器通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 + +可在不启动的情况下检查组合出的配置树: ```sh -dsh --config ./app.cordis.yml +dsh --profile web --dump-default-config +dsh --profile web --patch ./extra.yml --dump-config ``` -指定文件通过 Include 插件的 patch 算法直接应用到 [`config/base.cordis.yml`](../config/base.cordis.yml) 之上。它不是完整替代树,也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。基础配置刻意不包含启动 agent(智能体)或交互前端入口;必填 overlay 负责选择这些部署细节。相对配置路径从调用目录解析。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 -patch 通过 `id` 定位基础配置行,并替换该行完整的 `config` 值,而不是深度合并各键。patch 列表也可插入新行,只要随附 Loader 能解析其插件模块: +## 插件管理 -```yaml -- id: agent-loop - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash -``` - -可在不启动的情况下检查生效的配置树: +`dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,`dsh.profile.bundles` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有组合包声明的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。 ```sh -dsh --dump-default-config -dsh --config ./app.cordis.yml --dump-config +dsh plugin --profile tui add github:deepseek-harness/turtle-ui +dsh plugin --profile tui remove turtle-ui +dsh --profile tui ``` -`--dump-default-config` 只打印随附基础配置。`--dump-config` 必须与 `--config` 同时使用,并打印基础配置和带来源注释的 overlay。组合使用 `@cordisjs/plugin-include` 的 `applyEntryPatches` 与 `entryListSchema`;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 +Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构建,而 pnpm ≥10 在消费方允许之前会阻止该脚本:首次 `add` 会失败并给出 pnpm 的 `allowBuilds` 提示(以及 dsh 指向该 profile 的 `pnpm-workspace.yaml` 的指引);把打印出的键复制到那里并重新运行即可。安装已构建的 tarball 或本地 checkout 不需要任何允许。 -## Web 与无头模式 +## Web 别名 -`dsh web` 启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](../config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续加载它。`dsh web --config ` 用显式 patch 列表替代该个人层。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为 Web 宿主 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 挂载客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web -dsh web --config ./web-profile.cordis.yml -dsh web --dump-default-config +dsh web --patch ./extra.cordis.yml dsh web --dump-config ``` 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 -`dsh -p "task"` 使用同一基础配置和 Web 组合,并加载启动时的个人配置;它在 OS 分配的端口上启动 Web 宿主,运行一个新的持久化会话,打印最终答案并退出。它不接受 `--config` 或原始配置 dump flag。 +进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。 -Web 和无头进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果无头模式正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。 - -两种模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。Web 监视有效的个人配置编辑;无头模式只在启动时读取该文件。[app-boot 个人配置契约](../../../packages/ui/app-boot/README.md#personal-config)负责配置层优先级、凭据存储、实时更新失败行为和 `$DSH_HOME` 解析。 +所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 -`DSH_TOOLS_MODE` 为 Web/无头进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选 Web overlay:它在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,把原生模型 surface 缩减为持久 `bash` 和 `str_replace_editor`。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 `--patch` overlay:它在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,把原生模型 surface 缩减为持久 `bash` 和 `str_replace_editor`。 ## 共享部署行为 -基础配置挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 overlay 插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 -空 `repository-plugins` 行让 Web/无头个人配置和原始 overlay 能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 契约](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为 overlay 的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。 +空 `repository-plugins` 行让 profile 的 patch 层能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 契约](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 ## 源码启动器 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts deleted file mode 100644 index 651dbca3f7..0000000000 --- a/apps/cli/src/app-cli-entry.ts +++ /dev/null @@ -1,355 +0,0 @@ -/** - * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share - * (`dsh web` and `dsh -p`). - * Everything here is what must exist before the Loader runs: the patch - * composition over the shipped base and Web overlay (profile json + CLI - * flags + the resolved frontend dist), and the fail-loud activation audit after the tree - * settles. The environment is what the bin already loaded (ambient plus the - * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential - * provider and is never hoisted here. - */ - -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { networkInterfaces } from 'node:os' -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, - 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' - -/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */ -const PROFILE_DIR = '.dsh-tmp-profile' -const PROFILE_FILE = 'config.json' - -/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */ -const TELEMETRY_ROW_ID = 'telemetry-otel' - -/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and - * the `trustedHosts` value built from them plus the explicit extras. The - * single sample is deliberate — display must advertise only addresses the - * fence was configured with, so both read this snapshot. Derived entries are - * port-less IP literals: DNS rebinding needs an attacker-controlled name, so - * an IP-literal Host is safe on any port, and the bound port may be - * OS-assigned, unknowable pre-boot. - * @param bindHost - the effective webserver bind host (CLI flag, else the yml default). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } -} - -/** - * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty - * value (including `'0'`/`'false'`) disables: a privacy switch prefers - * off-by-mistake over on-by-mistake. Throws when the switch is set but the - * row is absent — a silently no-op "disabled" privacy switch would keep - * exporting while the user believes it is off. - * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset). - * @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row. - * @returns the disable patch, or `undefined` when telemetry stays enabled. - */ -export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined { - if ((disabledEnv ?? '') === '') return undefined - if (!hasRow) { - throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`) - } - return { id: TELEMETRY_ROW_ID, disabled: true } -} - -/** - * Whether a config file carries the telemetry row, parsed under the same - * `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers - * that compose their patch lists outside {@link AppCLIEntry} (raw `dsh`). - * @param file - absolute path of the config or overlay file. - * @returns true when a top-level (or inserted) row has the telemetry id. - */ -export function configHasTelemetryRow(file: string): boolean { - const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`) - return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row => - row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID)) -} - -/** One profile-json key mapped onto a yml row's config field. */ -interface ProfileMapping { - jsonPath: string - entryId: string - configKey: string -} - -/** - * The static profile→row mapping table. json is user config and wins over the - * yml engineering default per field; a json key absent from this table fails - * loud (a typo silently ignored would read as "setting has no effect"). - * Developers extend deployments by adding rows here. - */ -const PROFILE_MAPPINGS: ProfileMapping[] = [ - { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' }, - { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' }, - { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' }, -] - -// The include's YAML dialect: `!!js` scalars become expression nodes the -// Loader evaluates at entry activation. The bypass parse below must accept -// them (and passing one through a patch unchanged is legal). -const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { - kind: 'scalar', - resolve: data => typeof data === 'string', - construct: data => ({ __jsExpr: String(data) }), -}) -const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) - -/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ -export interface AppCLIEntryOptions { - /** Absolute path of the shared base config the Loader includes. */ - configPath: string - /** - * Absolute path of this surface's overlay: a patch list applied over - * {@link configPath} before this entry's own profile/flag patches. Its rows - * are also merge inputs, so a flag override preserves the overlay's other - * fields on the same row. - */ - overlayPath: string - /** - * Optional explicit overlay applied after {@link overlayPath} and before - * this entry's own profile/flag patches. When absent, the personal - * `$DSH_HOME/config.yaml` overlay is applied instead. - */ - extraOverlayPath?: string - /** 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 - /** - * Listen port override onto the webserver row. Web passes the --port flag - * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs - * never collide — and the printed URL still opens the live session in a - * browser). - */ - port?: number - /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ - workspaceRoot?: string - /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ - trustedHosts?: string[] - /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context) => Promise | void -} - -/** - * Boot driver for the config-tree dsh surfaces (web and headless share the - * one composition; the surfaces differ only in constructor facts): holds only - * what exists independently of (and prior to) cordis — argv facts, the - * composed patch set, and finally the root ctx. - */ -export class AppCLIEntry { - /** The root context, set by {@link run}. */ - ctx!: Context - - /** - * LAN IPv4 addresses sampled once at patch composition — the exact snapshot - * the /api trust fence was configured with. Display reads this instead of - * re-sampling, so the advertised LAN URL can never name an address the - * fence rejects. Empty unless the effective bind is all-interfaces. - */ - lanAddresses: readonly string[] = [] - - private patches: PatchOptions[] = [] - - constructor(private readonly options: AppCLIEntryOptions) {} - - /** - * Run the boot chain: patch composition → Loader installation → surface - * preparation → config-tree boot (dev row before await) → fail-loud triple. - * @returns the settled root context and the listening port. - */ - async run(): Promise<{ ctx: Context; port: number }> { - this.composePatches() - await this.bootTree() - this.assertBoot() - const port = this.ctx.get('httpServer')?.port - /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ - if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot') - return { ctx: this.ctx, port } - } - - /** - * Compose the patch set from profile json, CLI flags, and the resolved - * frontend dist. Patches replace a row's config wholesale, so each patched row's yml - * static values are re-read here (bypass parse) and merged under the overrides. - */ - private composePatches(): void { - const rows = this.parseYmlRows() - const overrides = new Map>() - const put = (entryId: string, key: string, value: unknown): void => { - const bag = overrides.get(entryId) ?? {} - bag[key] = value - overrides.set(entryId, bag) - } - - // Source 1: profile json (missing file = empty; unmapped key = loud). - for (const [key, value] of Object.entries(this.readProfile())) { - const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) - if (mapping === undefined) { - throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) - } - put(mapping.entryId, mapping.configKey, value) - } - - // Source 2: CLI flags (field set disjoint from the json mappings). - if (this.options.host !== undefined) put('webserver', 'host', this.options.host) - if (this.options.port !== undefined) put('webserver', 'port', this.options.port) - if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) - - // Source 2b: authorities for the /api browser-trust fence (rationale on - // resolveLanTrust). - const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) - this.lanAddresses = lanAddresses - if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) - - // Source 3: the frontend dist — an assembly fact of this app, never yml - // user config. Workspace knowledge stays here. - put('webserver', 'distIndex', this.resolveDistIndex()) - - 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 - // plugin constructs — its exporter.url validation is load-time fail-loud. - const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) this.patches.push(telemetryPatch) - } - - /** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */ - private async bootTree(): Promise { - // One include of the shared base with every overlay as a sibling patch - // list: patches never cross an include boundary, so nesting them would - // silently stop reaching base rows. The surface overlay applies first, then - // this entry's profile-json and CLI-flag patches, which therefore win. - const compose = (overlay: PatchOptions[]): PatchOptions[] => [ - ...loadOverlayPatches('dsh', this.options.overlayPath), - ...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. */ - private assertBoot(): void { - installFailLoud('dsh') - } - - /** - * Bypass parse of the base and this surface's overlay (id → row) for - * patch-merge inputs; the Loader still reads both files itself. The overlay - * wins per row, matching the order its patches are applied in, and its - * `insert` rows are indexed too because a flag may target one of them. - */ - private parseYmlRows(): Map { - const rows = new Map() - const files = [this.options.configPath, this.options.overlayPath] - if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath) - for (const file of files) { - for (const row of this.parseRowList(file)) { - if (typeof row.id === 'string') rows.set(row.id, row) - for (const inserted of row.insert ?? []) { - if (typeof inserted.id === 'string') rows.set(inserted.id, inserted) - } - } - } - return rows - } - - /** - * Parse one entry or patch list, rejecting anything that is not a top-level - * array so a malformed file fails here rather than at row lookup. - * @param file - absolute path of the config or overlay file. - * @returns the parsed top-level entries. - */ - private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] { - const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`) - return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] - } - - /** Profile json under cwd; read-only — never created here, absent = no user config. */ - private readProfile(): Record { - let raw: string - try { - raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} - throw error - } - const parsed: unknown = JSON.parse(raw) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) - } - return parsed as Record - } - - /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */ - private resolveDistIndex(): string { - const require = createRequire(import.meta.url) - try { - return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') - } catch { - throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first') - } - } -} diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 18b31fc3a9..310b5b03a2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,43 +1,42 @@ /** * Commander adapter for the `dsh` command-line entry. The default command - * boots one required `--config` overlay over the shipped base; `-p` selects - * the one-shot headless path and `web` selects the browser application. - * Commander owns help, version, and parse errors. + * boots a named profile (`--profile `), optionally with extra `--patch` + * overlays and a positional task (one-shot mode for profiles mounting the + * headless runner). `web` is a hardcoded alias for `--profile web` that adds + * the Web flag family; `plugin` manages a profile's plugin dependencies by + * forwarding to pnpm. Commander owns help, version, and parse errors. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Boot a caller-selected overlay over the shipped base config. */ -interface ConfigInvocation { - mode: 'config' - config: string +/** Boot a named profile. */ +interface ProfileInvocation { + mode: 'profile' + profile: string + /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ + patches: string[] + /** Positional task text joined by spaces; non-empty only for one-shot runs. */ + task?: string } -/** Print a composed config tree and exit without booting. */ +/** Print a composed profile tree and exit without booting. */ interface DumpConfigInvocation { mode: 'dump-config' - surface: 'config' | 'web' - /** Omit every caller or personal layer and print the shipped tree. */ + profile: string + /** Omit the profile's user layer and --patch overlays; print bundle layers only. */ defaultOnly: boolean - /** Explicit overlay to compose over the base or Web surface. */ - config?: string -} - -/** Headless one-shot: `dsh -p "task"`. */ -interface HeadlessInvocation { - mode: 'headless' - prompt: string + patches: string[] } /** - * Browser UI: `dsh web`. Host and port remain unvalidated pass-throughs to - * the webserver schema; absent values leave the shipped Web overlay intact. + * Browser UI: `dsh web` (alias of `--profile web`). Host and port remain + * unvalidated pass-throughs to the webserver schema; absent values leave the + * shipped web bundle values intact. */ interface WebInvocation { mode: 'web' - /** Overlay applied over the shipped Web composition instead of the personal one. */ - config?: string + patches: string[] host?: string port?: number dev: boolean @@ -46,12 +45,20 @@ interface WebInvocation { trustedHosts?: string[] } +/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */ +interface PluginInvocation { + mode: 'plugin' + profile: string + /** Raw pnpm arguments, verbatim. */ + args: string[] +} + /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ConfigInvocation | DumpConfigInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { - config?: string + patch?: string[] host?: string port?: string dev?: boolean @@ -61,43 +68,11 @@ interface WebOptions { dumpDefaultConfig?: boolean } -/** Resolve config-dump flags for one command shape. */ -function resolveDump( - surface: 'config' | 'web', - options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean }, - error: (message: string) => never, -): DumpConfigInvocation | undefined { - if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && options.config !== undefined) { - error('error: --dump-default-config prints the shipped tree and takes no --config') - } - if (surface === 'config' && !defaultOnly && options.config === undefined) { - error('error: --dump-config requires --config ') - } - return { - mode: 'dump-config', - surface, - defaultOnly, - ...options.config !== undefined && { config: options.config }, - } -} - -/** Narrow raw `web` options into a {@link WebInvocation}. */ -function resolveWeb(options: WebOptions): WebInvocation { - return { - mode: 'web', - ...options.config !== undefined && { config: options.config }, - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - dev: options.dev === true, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, - ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, - } -} +/** + * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never + * variadic — a variadic `--patch` would swallow a following positional task. + */ +const collect = (value: string, previous: string[] = []): string[] => [...previous, value] /** * Resolve argv into one invocation, or print and exit for help, version, or an @@ -111,77 +86,122 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: boot a DeepSeek Harness config overlay over the shipped base configuration.') + .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.') .addHelpText('after', ` Examples: - dsh --config ./app.cordis.yml boot an overlay over the shipped base - dsh -p "run the tests" answer one task, print the result, and exit - dsh web serve the browser UI + dsh --profile web boot the web profile (same as: dsh web) + dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay + dsh plugin --profile tui add install a plugin into the tui profile + dsh web --port 8080 the web alias with its flag family `) .exitOverride() .enablePositionalOptions() - .option('-p, --prompt ', 'answer this task without an interactive UI, then exit') - .option('--config ', 'overlay of loader patches to apply over the shipped base') - .option('--dump-config', 'print the base plus --config overlay and exit') - .option('--dump-default-config', 'print the shipped base config and exit') - .action((options: { - config?: string - prompt?: string + .argument('[task...]', 'one-shot task text for profiles mounting the headless runner') + .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') + .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) + .option('--dump-config', 'print the composed profile tree and exit') + .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') + .action((task: string[], options: { + profile?: string + patch?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean }) => { - if (options.config === '') program.error('error: --config needs a path') - const dump = resolveDump('config', options, message => program.error(message)) - if (dump !== undefined) { - if (options.prompt !== undefined) { - program.error('error: --dump-config/--dump-default-config take no -p/--prompt') + const profile = options.profile ?? program.error('error: --profile is required') + if (profile === '') program.error('error: --profile needs a name') + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + if (options.dumpConfig === true || options.dumpDefaultConfig === true) { + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + program.error('error: --dump-config and --dump-default-config are mutually exclusive') } - resolved = dump + if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task') + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && patches.length > 0) { + program.error('error: --dump-default-config prints the bundle layers and takes no --patch') + } + resolved = { mode: 'dump-config', profile, defaultOnly, patches } return } - if (options.prompt !== undefined) { - if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined) program.error('error: --prompt takes no --config') - resolved = { mode: 'headless', prompt: options.prompt } - return + resolved = { + mode: 'profile', + profile, + patches, + ...task.length > 0 ? { task: task.join(' ') } : {}, } - const config = options.config ?? program.error('error: --config is required') - resolved = { mode: 'config', config } }) /** Reject parent options that crossed a subcommand boundary. */ const rejectParentOptions = (command: string): void => { const parent = program.opts<{ - config?: string - prompt?: string + profile?: string + patch?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean }>() - if (parent.config !== undefined || parent.prompt !== undefined + if (parent.profile !== undefined || parent.patch !== undefined || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { - program.error(`error: ${command} takes none of parent --config, -p/--prompt, --dump-config, or --dump-default-config`) + program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`) } } - const web = program.command('web').description('serve the browser UI on the configured host and port') + const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') web - .option('--config ', 'apply this overlay of loader patches over the shipped Web configuration') + .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') - .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit') - .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit') + .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') + .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') .action((options: WebOptions) => { rejectParentOptions('web') - if (options.config === '') program.error('error: --config needs a path') - const dump = resolveDump('web', options, message => program.error(message)) - if (dump !== undefined) { - resolved = dump + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + if (options.dumpConfig === true || options.dumpDefaultConfig === true) { + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + program.error('error: --dump-config and --dump-default-config are mutually exclusive') + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && patches.length > 0) { + program.error('error: --dump-default-config prints the bundle layers and takes no --patch') + } + // The dump is boot-free and does not derive flag patches; silently + // dropping them would print a tree that differs from the same + // invocation's boot. + if (options.host !== undefined || options.port !== undefined || options.dev === true + || options.workspaceRoot !== undefined || options.trustedHost !== undefined) { + program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') + } + resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } return } - resolved = resolveWeb(options) + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + resolved = { + mode: 'web', + patches, + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + dev: options.dev === true, + ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, + } + }) + + const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory') + plugin + .requiredOption('--profile ', 'the profile whose plugins to manage (initialized on first use)') + .allowUnknownOption() + .argument('[args...]', 'pnpm arguments, forwarded verbatim (add , remove , why , ...)') + .action((args: string[], options: { profile: string }) => { + rejectParentOptions('plugin') + if (options.profile === '') program.error('error: --profile needs a name') + if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add )') + resolved = { mode: 'plugin', profile: options.profile, args } }) try { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index fbdda23f2d..4783c2d175 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -28,24 +28,28 @@ loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { - case 'config': { - const { runConfig } = await import('./config.ts') - await runConfig(invocation.config) + case 'profile': { + const { runProfile } = await import('./profile-boot.ts') + await runProfile({ + profile: invocation.profile, + patchFiles: invocation.patches, + ...invocation.task !== undefined && { task: invocation.task }, + }) break } case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) + await runWeb(invocation) break } - case 'headless': { - const { runHeadless } = await import('./headless.ts') - await runHeadless(invocation.prompt) + case 'plugin': { + const { runPlugin } = await import('./plugin.ts') + process.exit(runPlugin(invocation.profile, invocation.args)) break } case 'dump-config': { const { runDumpConfig } = await import('./dump-config.ts') - runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config) + runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) break } default: diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts deleted file mode 100644 index f704a35bf3..0000000000 --- a/apps/cli/src/config.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Raw `dsh --config ` boot: apply one required patch-list overlay over - * the shipped base config, then leave process lifetime to the mounted plugins. - * @module @deepseek-ai/dsh/config - */ - -import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' -import { - boot, - installFailLoud, - loadOverlayPatches, - resolveConfigPath, -} from '@deepseek-ai/dsh-app-boot' -import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' - -const NAME = 'dsh' -const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) - -/* v8 ignore start -- the source-launch and built-bin acceptance paths own executable dispatch */ -/** - * Boot the shipped base with one explicit overlay. - * @param config - required patch-list path parsed from `--config`. - */ -export async function runConfig(config: string): Promise { - const app: { current?: Context } = {} - let exiting = false - const shutdown = (code: number): void => { - if (exiting) return - exiting = true - void Promise.resolve(app.current?.fiber.dispose()).finally(() => { process.exit(code) }) - } - // An inserted front door can publish readiness before sibling rows finish - // mounting. Signals must own teardown throughout that startup window, not - // only after boot() settles. - process.on('SIGTERM', () => { shutdown(0) }) - process.on('SIGINT', () => { shutdown(130) }) - installFailLoud(NAME, process, async () => { - await app.current?.fiber.dispose() - }) - const overlay = resolveConfigPath(config, undefined) - const telemetryPatch = resolveTelemetryPatch( - process.env.DSH_TELEMETRY_DISABLED, - configHasTelemetryRow(BASE_CONFIG), - ) - const ctx = await boot(NAME, BASE_CONFIG, [ - ...loadOverlayPatches(NAME, overlay), - ...telemetryPatch === undefined ? [] : [telemetryPatch], - ], (hostCtx) => { - app.current = hostCtx - }) - app.current = ctx -} -/* v8 ignore stop */ diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index cb88e8d655..9de7a55f60 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -1,52 +1,53 @@ /** - * Config-dump entry for raw `dsh --config` and `dsh web`: compose through the - * include plugin's patch algorithm without booting or evaluating `!!js`. + * Config-dump entry for `dsh --profile --dump-config`: compose the + * profile's patch layers through the include plugin's patch algorithm without + * booting or evaluating `!!js`, with one provenance layer per bundle, the + * profile's own patch file, and each `--patch` overlay. * @module @deepseek-ai/dsh/dump-config */ -import { basename, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' import { + loadOptionalPatches, loadOverlayPatches, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' const NAME = 'dsh' -const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) -const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)) /* v8 ignore start -- built-bin acceptance drives this boot-free dispatch */ /** - * Print a raw or Web composition with provenance comments. - * @param surface - raw base-plus-config composition, or the Web composition. - * @param defaultOnly - omit the explicit or personal user layer. - * @param config - explicit overlay path; required for a non-default raw dump. + * Print a profile composition with provenance comments. + * @param profile - the profile name. + * @param defaultOnly - omit the profile's user layer and `--patch` overlays + * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then + * never parsed). + * @param patches - `--patch` overlay paths, in argv order. */ -export function runDumpConfig(surface: 'config' | 'web', defaultOnly: boolean, config?: string): void { - const layers: ConfigDumpLayer[] = [] - if (surface === 'config') { - if (!defaultOnly) { - /* v8 ignore next -- parseDshArgs requires this combination */ - if (config === undefined) throw new Error('dsh: raw config dump requires an overlay') - layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) +export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void { + const loaded = prepareProfile(profile, !defaultOnly) + const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({ + label: layer.packageName, + patches: layer.patches, + })) + if (!defaultOnly) { + if (existsSync(loaded.patchPath)) { + layers.push({ label: loaded.patchPath, patches: loaded.patches }) } - } else { - layers.push({ label: basename(WEB_OVERLAY), patches: loadOverlayPatches(NAME, WEB_OVERLAY) }) - if (!defaultOnly) { - if (config === undefined) { - const personal = loadPersonalPatches(NAME) - if (personal !== undefined) { - layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal }) - } - } else { - layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) - } + const homePatchFile = homePatchPath() + const homePatches = loadOptionalPatches(NAME, homePatchFile) + if (homePatches !== undefined) { + layers.push({ label: homePatchFile, patches: homePatches }) + } + for (const file of patches) { + const absolute = resolve(file) + layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) }) } } - process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers)) + // The dump anchors on the same empty root file the boot includes. + process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers)) } /* v8 ignore stop */ diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts deleted file mode 100644 index 5ceccb330e..0000000000 --- a/apps/cli/src/headless.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry - * boots the same base plus Web overlay as `dsh web` (port 0, so parallel runs never - * collide), then in-process isomorphic injection (InProcessApiClient over - * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire - * serialization, zod, SSE framing — really runs). The printed URL opens the - * live session in a browser while the task runs. Runs one task turn, prints - * the final assistant text, exits (completed → 0, else 1). - */ - -import { fileURLToPath } from 'node:url' -import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { AppCLIEntry } from './app-cli-entry.ts' -import { createProcessShutdown } from './process-shutdown.ts' - -/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ -interface TurnOutcome { - text: string - reason: string -} - -/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */ -async function unwrap(response: RpcResponse, shutdown: () => Promise): Promise { - if (response.result.ok) return response.result.value - const { code, message } = response.result.error - process.stderr.write(`dsh: ${code}: ${message}\n`) - await shutdown() - process.exit(1) -} - -/** - * Consume mux frames until the agent reaches idle, per the one-shot CLI - * idle-to-idle contract: the stream opens immediately before the prompt, and - * its first observed turn/start begins the task. Text is the last committed - * assistant message of the whole interval (steering or injected work may run - * further turns before quiescence), and the outcome reason is the final - * turn/end's kind. Idleness is signalled out of band by the caller's - * `agent/status` subscription; the stream itself carries no status frame. - * @param frames - the mux stream opened before the prompt. - * @param sessionId - the headless session. - * @param idle - resolves when the agent reaches quiescence. - * @returns the aggregated outcome. - */ -async function consumeUntilIdle( - frames: AsyncIterable>, - sessionId: SessionId, - idle: Promise, -): Promise { - let started = false - let text = '' - let reason: string = 'error' - void (async () => { - try { - for await (const frame of frames) { - const payload = frame.payload - if (payload.type === 'stream/error') return - if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue - const event = payload.event - if (event.type === 'turn/start') { - started = true - continue - } - if (!started) continue - if (event.type === 'assistant/message') { - const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') - if (joined !== '') text = joined - } - if (event.type === 'turn/end') reason = event.data.reason.kind - } - } catch (error: unknown) { - process.stderr.write(`dsh: event stream failed: ${String(error)}\n`) - } - })() - await idle - return { text, reason } -} - -/** - * Run one headless turn for `task` and exit (completed → 0, else 1). The task - * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` - * (the adapter rejects an empty task, so no guard is needed here). - * @param task - the prompt text for the single turn. - */ -export async function runHeadless(task: string): Promise { - // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const entry = new AppCLIEntry({ - 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() - // Normal completion and signals share one bounded drain. A signal received - // during that drain escalates immediately instead of becoming a no-op. - const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() }) - process.on('SIGTERM', () => { shutdown.interrupt(143) }) - process.on('SIGINT', () => { shutdown.interrupt(130) }) - // The headless session is web-observable while it runs (same composition). - process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) - const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) - - const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1)) - - // Open the stream before prompting so no frame is lost — kept in this order - // even though in-process delivery has no race, so the code survives a move - // to a remote HTTP carrier unchanged. - const abort = new AbortController() - const frames = api.events.mux({}, abort.signal) - const idle = new Promise((resolve) => { - ctx.on('agent/status', ({ agent, status }) => { - if (agent.id === created.sessionId && status === 'idle') resolve() - }) - }) - const done = consumeUntilIdle(frames, created.sessionId, idle) - - await unwrap(await api.sessions.prompt({ - sessionId: created.sessionId, - mode: 'queue', - content: [{ type: 'text', text: task }], - }), () => shutdown.shutdown(1)) - - const outcome = await done - process.stdout.write(outcome.text + '\n') - abort.abort() - await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1) -} diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts new file mode 100644 index 0000000000..4a366a9a5d --- /dev/null +++ b/apps/cli/src/plugin.ts @@ -0,0 +1,158 @@ +/** + * `dsh plugin --profile ` — profile plugin management as a + * thin pnpm forwarder: initialize the profile on first use, run + * `pnpm ` in the profile directory, then reconcile the + * `dsh.profile.bundles` layer list against the installed state (a dependency + * resolving to a package that declares `dsh.bundle` joins the layer stack; a + * removed or bundle-less dependency leaves it). Reconciling by installed + * state, not by dependency diff, means `update` activates a package that + * gained its `dsh.bundle` declaration in a newer version. + * @module @deepseek-ai/dsh/plugin + */ + +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + DEFAULT_PROFILE_BUNDLES, + initProfile, + PROFILE_TEMPLATES, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + writeProfileManifest, + type ProfileManifest, +} from '@deepseek-ai/dsh-app-boot' +import { INSTALL_ANCHOR } from './profile-boot.ts' + +const NAME = 'dsh' + +/** + * Whether a resolved dependency exports a profile patch, i.e. is a bundle. + * @param packageName - the dependency's package name. + * @param profileDir - the profile directory (resolution anchor). + * @returns true when the package manifest declares `dsh.bundle`. + */ +function exportsPatch(packageName: string, profileDir: string): boolean { + let dir: string + try { + dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir) + } catch { + return false // pnpm reported success yet the package is unresolvable — treat as plain + } + const manifest = readProfileManifest(NAME, dir) + return manifest.dsh?.bundle?.patch !== undefined +} + +/** + * Reconcile `dsh.profile.bundles` against the installed state: pnpm has + * already written the real installed names (so a git/path/tarball/alias spec + * on the command line reconciles by its true package name) and materialized + * the packages. A dependency that resolves to a `dsh.bundle`-declaring + * package joins the layer stack (appended in dependency order); a + * dependency-listed name that no longer does — removed, or the installed + * version dropped the declaration — leaves it. In-box bundles from the + * profile template are not dependencies and are never touched. Warns once + * per newly-added bundle-less dependency (a plain library is fine; the + * warning is orientation). + */ +function reconcilePlugins(before: ProfileManifest, profileDir: string): void { + const after = readProfileManifest(NAME, profileDir) + const beforeDeps = new Set(Object.keys(before.dependencies ?? {})) + const dependencies = Object.keys(after.dependencies ?? {}) + const plugins = after.dsh?.profile?.bundles ?? [] + let changed = false + for (const packageName of dependencies) { + const isBundle = exportsPatch(packageName, profileDir) + if (isBundle && !plugins.includes(packageName)) { + plugins.push(packageName) + changed = true + } else if (!isBundle && !beforeDeps.has(packageName)) { + process.stderr.write( + `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer ` + + '(a later update that gains one activates it automatically)\n', + ) + } + } + const dependencySet = new Set(dependencies) + for (const packageName of [...plugins]) { + // Only dependency-managed entries are subject to removal; template + // bundles (dsh-base and friends) are not dependencies. + const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName) + const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir) + if (wasDependency && !stillBundle) { + plugins.splice(plugins.indexOf(packageName), 1) + changed = true + } + } + if (!changed) return + after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } } + writeProfileManifest(profileDir, after) +} + +/** + * Rewrite relative filesystem specs against the user's invoking directory. + * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin` + * (or their `file:`/`link:` forms) would silently resolve inside the profile + * — `add .` from a plugin checkout would self-link the profile. Absolute + * specs, registry names, and every other pnpm argument pass through + * untouched. + * @param argument - one pnpm argument, verbatim from argv. + * @param cwd - the directory `dsh` was invoked from. + * @returns the argument with a relative path spec anchored to `cwd`. + */ +function anchorPathSpec(argument: string, cwd: string): string { + const match = /^(?(?:file|link):)?(?\.{1,2}(?:[/\\].*)?)$/.exec(argument) + if (match?.groups?.path === undefined) return argument + // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's + // link-vs-copy semantics differ between `file:` and a plain directory + // path, and the anchor must not change which one the user asked for. + const prefix = match.groups.prefix ?? '' + return `${prefix}${resolve(cwd, match.groups.path)}` +} + +/** + * Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile. + * @param profile - the profile name. + * @param args - pnpm arguments with relative path specs anchored to the invoking directory. + * @returns the pnpm exit code. + */ +export function runPlugin(profile: string, args: readonly string[]): number { + const dir = resolveProfileDir(profile) + if (!existsSync(join(dir, 'package.json'))) { + initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES) + process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`) + } + const before = readProfileManifest(NAME, dir) + // Windows resolves pnpm through its .cmd shim, which spawn() refuses + // without a shell since the CVE-2024-27980 hardening. + const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), { + cwd: dir, + stdio: 'inherit', + shell: process.platform === 'win32', + }) + if (result.error !== undefined) { + const code = (result.error as NodeJS.ErrnoException).code + if (code === 'ENOENT') { + process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`) + return 127 + } + throw result.error + } + const exitCode = result.status ?? 1 + if (exitCode === 0) { + reconcilePlugins(before, dir) + } else { + // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH + // one; the profile owns it, and the commonest failure here is pnpm ≥10 + // blocking a git dependency's prepare (build) script until allowlisted. + process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`) + if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) { + process.stderr.write( + `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — ` + + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`, + ) + } + } + return exitCode +} diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts new file mode 100644 index 0000000000..44c943a7aa --- /dev/null +++ b/apps/cli/src/profile-boot.ts @@ -0,0 +1,266 @@ +/** + * Shared profile boot for every `dsh` surface: resolve the profile, stack its + * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own + * `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry + * switch), mount the tree over the profile's empty root config, keep the + * profile patch layer live, and wire fail-loud plus bounded shutdown. + * @module @deepseek-ai/dsh/profile-boot + */ + +import { writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { + boot, + composeEntries, + healProfilesModuleFallback, + installFailLoud, + loadOptionalPatches, + loadOverlayPatches, + loadProfile, + PROFILE_PATCH_FILENAME, + watchUserPatches, + type Profile, +} from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { HeadlessIo } from '@deepseek-ai/dsh-headless' +import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' + +const NAME = 'dsh' + +/** + * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied + * over every profile's own layer. Resolved per call, not at module load: + * `$DSH_HOME` may be set by the test or launcher after import. + * @returns the absolute patch-file path. + */ +export function homePatchPath(): string { + return join(resolveDshHome(), PROFILE_PATCH_FILENAME) +} + +/** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */ +export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url)) + +/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ +const TELEMETRY_ROW_ID = 'telemetry-otel' + +/** The one-shot runner row a positional task requires and configures. */ +const HEADLESS_ROW_ID = 'headless-runner' + +/** The empty root entry list every profile tree patches over. */ +const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches: +# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any +# --patch overlays. Edit cordis.patch.yml, not this file. +[] +` + +/** Root config filename inside a profile directory. */ +export const PROFILE_ROOT_FILENAME = 'cordis.yml' + +/** + * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty + * value (including `'0'`/`'false'`) disables: a privacy switch prefers + * off-by-mistake over on-by-mistake. A composition without the telemetry row + * exports nothing, so the switch is then trivially satisfied and no patch is + * generated — custom profiles need not mount telemetry to run with the + * switch set. + * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset). + * @param hasRow - whether the composition carries the telemetry row. + * @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted. + */ +export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined { + if ((disabledEnv ?? '') === '' || !hasRow) return undefined + return { id: TELEMETRY_ROW_ID, disabled: true } +} + +/** + * Load a resolved profile for `name`: heal the shared module fallback, then + * (re)write the empty root config. The root is always rewritten: the whole + * composition is patch layers, and the vendored Loader's tree write-back (a + * plugin self-disposing persists the current tree) can bake composed rows + * into this file — which would duplicate every bundle insert on the next + * boot. The file exists on disk only because the Loader needs a real include + * root to anchor `baseUrl` at the profile directory (the config dump anchors + * on the same file, so both compose over the identical base). + * @param name - the profile name. + * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump). + * @returns the loaded profile. + */ +export function prepareProfile(name: string, userLayer = true): Profile { + healProfilesModuleFallback(INSTALL_ANCHOR) + const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer }) + writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG) + return profile +} + +/** One profile's patch layers (application order) and the row index of its pre-flag composition. */ +interface ComposedProfile { + profile: Profile + /** Bundle layers concatenated — the part below the user layers on a live reload. */ + bundlePatches: PatchOptions[] + /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ + homePatches: PatchOptions[] + /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ + overlayAndFlags: PatchOptions[] + /** + * id → row of the pre-flag composition (bundles + user layers + overlays), + * for flag merges and row checks. Flag patches must not insert rows the + * launcher consults here (they only override values and insert dev glue). + */ + rows: Map +} + +/** The full patch stack of one composed profile, in application order. */ +function allPatches(composed: ComposedProfile): PatchOptions[] { + return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags] +} + +/** + * Load `name` and compose its effective patch stack: bundle layers in + * `dsh.profile.bundles` order, the profile's user layer, the home-level user layer + * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to + * every profile, so it outranks the per-profile layer), `--patch` overlays, + * then flag patches derived from the composed rows, then the telemetry + * switch. + * @param name - the profile name. + * @param patchFiles - `--patch` overlay paths, in argv order. + * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. + * @returns the profile, its patch layers, and the composed row index. + */ +function composeProfile( + name: string, + patchFiles: readonly string[], + deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [], +): ComposedProfile { + const profile = prepareProfile(name) + const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] + const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) + const bundlePatches = profile.layers.flatMap(layer => layer.patches) + const rows = new Map() + for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] + const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) + if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) + return { profile, bundlePatches, homePatches, overlayAndFlags, rows } +} + +/** Options for {@link runProfile}. */ +export interface RunProfileOptions { + /** The profile name to boot. */ + profile: string + /** `--patch` overlay paths, in argv order. */ + patchFiles: readonly string[] + /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ + deriveFlagPatches?: (rows: ComposedProfile['rows']) => PatchOptions[] + /** One-shot task text; requires the composition to mount the headless runner row. */ + task?: string + /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ + prepare?: (ctx: Context) => Promise | void +} + +/** + * Boot one profile invocation end to end and leave process lifetime to the + * mounted plugins (or to the one-shot runner when `task` is present). + * @param options - profile name, overlays, flag patches, and the optional task. + * @returns the settled root context and the shutdown controller. + */ +export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { + const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches) + if (options.task !== undefined) { + if (!composed.rows.has(HEADLESS_ROW_ID)) { + throw new Error( + `dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row ` + + '(the headless profile does)', + ) + } + composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) + } else if (composed.rows.has(HEADLESS_ROW_ID)) { + // The inverse misuse: a one-shot composition booted without its task + // would otherwise die in the runner row's schema with a raw "required" + // error naming no fix. + throw new Error( + `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` + + `dsh --profile ${options.profile} ""`, + ) + } + + const app: { current?: Context } = {} + const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) + // Signals own teardown throughout the startup window, not only after boot() + // settles: an inserted front door can publish readiness before sibling rows + // finish mounting. + process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) }) + process.on('SIGINT', () => { shutdown.interrupt(130) }) + installFailLoud(NAME, process, async () => { + await app.current?.fiber.dispose() + }) + + const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) + // Recomposition for the live user layers: bundle layers below, overlays + // and flag patches above, so a user edit can never displace them. BOTH + // user files are re-read per generation (the HMR watcher hands us only the + // changed file's patches, which one of the reads duplicates — fresh reads + // keep the two watchers from stitching in each other's stale copy). + // Fresh clones per generation: the include pushes `insert` rows into the + // mounted tree BY REFERENCE and later id-targeted patches mutate those + // objects in place. Reusing one parsed patch object across applications + // would bake a user override into the bundle's in-memory insert row, so + // removing the override could never revert the row to the bundle default. + const composeLive = (): PatchOptions[] => structuredClone([ + ...composed.bundlePatches, + ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], + ...loadOptionalPatches(NAME, homePatchPath()) ?? [], + ...composed.overlayAndFlags, + ]) + // One-shot runs exit through the runner; watching would only hold the + // process open after its exit request. + const watchProfilePatch = options.task === undefined + // Cloned for the same insert-aliasing reason as composeLive: the boot + // application must not mutate the objects later reloads recompose from. + const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { + app.current = hostCtx + if (options.task !== undefined) { + const io: HeadlessIo = { + stdout: process.stdout, + stderr: process.stderr, + exit: (code) => { void shutdown.shutdown(code) }, + } + hostCtx.provide('headlessIo', io) + } + await options.prepare?.(hostCtx) + }) + app.current = ctx + // A surface can dispose the whole tree while startup was still in flight + // (early SIGTERM); the Loader service goes with it and there is nothing to + // keep live. + if (watchProfilePatch && ctx.get('loader') !== undefined) { + // Config-only HMR for the live profile patch layer: the web bundle + // disables the shared module-reload `hmr` row (its reload lifecycle is + // untested), so when the composition leaves no HMR service, mount a + // watch-only instance with no module roots — cordis.patch.yml edits stay + // live on every long-lived surface. A silent skip would break the + // documented hot-reload contract. HMR injects the timer service, which a + // bare custom profile may not mount either. + if (ctx.get('hmr') === undefined) { + if (ctx.get('timer') === undefined) { + await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + } + await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) + } + await watchUserPatches(ctx, { + binName: NAME, + filename: composed.profile.patchPath, + compose: composeLive, + }) + await watchUserPatches(ctx, { + binName: NAME, + filename: homePatchPath(), + compose: composeLive, + }) + } + return { ctx, shutdown } +} diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index d2186e097a..4301af6e1a 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,133 +1,127 @@ /** - * `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the - * already-parsed host/port/dev, print the URL line, wire signals. All - * composition lives in the shared base plus Web overlay; all boot glue lives in AppCLIEntry. Host and - * port are unvalidated pass-through overrides — the `dsh-host-webserver` schema - * gates them at boot. + * `dsh web` — the browser-surface alias over the profile boot: `--profile web` + * plus the Web flag family (`--host/--port/--dev/--workspace-root/ + * --trusted-host`), each flag becoming a patch over the composed profile + * tree. All web runtime glue (dist serving, prompt section, URL line) lives + * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives + * flag patches and the LAN-trust snapshot. + * @module @deepseek-ai/dsh/web */ +import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' -import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import type {} from '@deepseek-ai/dsh-host-webserver' -import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-bash-env' -import { AppCLIEntry } from './app-cli-entry.ts' -import { createProcessShutdown } from './process-shutdown.ts' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import { runProfile } from './profile-boot.ts' -// The shipped base plus the Web application's overlay. -const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) -const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) -const DSH_WEB_URL = 'DSH_WEB_URL' as const -const DSH_WEB_MODE = 'DSH_WEB_MODE' as const +/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ +const ALL_INTERFACES_HOST = '0.0.0.0' -type WebMode = 'production' | 'development' - -// Display-only mirror of the webserver schema's loopback host: the address the -// local URL always prints. Not a source of truth — the schema is. -const LOOPBACK_HOST = '127.0.0.1' - -/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ -function webSurfacePrompt(webUrl: string, mode: WebMode): string { - const updateContract = mode === 'development' - ? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. ' - + 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. ' - + 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. ' - : 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. ' - + 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. ' - return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` - + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' - + 'The browser provides no implicit DOM, route, or screenshot context. ' - + updateContract - + 'Starting another server does not update this GUI. ' - + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' - + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.' -} - -/** Resolve the canonical loopback URL from the active Web server. */ -function localWebUrl(ctx: Context): string { - const port = ctx.get('httpServer')?.port - if (port === undefined) throw new Error('dsh web: httpServer service missing while resolving Web runtime') - return `http://${LOOPBACK_HOST}:${String(port)}` +/** + * Non-internal IPv4 interface addresses of this machine — the IP-literal + * authorities an all-interfaces bind is reachable by on the LAN. + * @returns the addresses in interface order (possibly empty). + */ +function lanIPv4Addresses(): string[] { + return Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) } /** - * Register the launcher-owned prompt and shell runtime context before the - * shared config tree mounts. The earlier injections install the prompt - * sections and managed Bash contributor when their owning services activate; - * dynamic values read the bound server only when consumed. - * @param ctx - Web root context with Loader installed but no config tree mounted. - * @param sourceRoot - absolute checkout root resolved from the launcher module. - * @param mode - whether this process mounted the client-plugin HMR receiver. + * One LAN-trust resolution for one invocation, sampled exactly once: the + * machine's LAN IP literals when the effective bind is all-interfaces, and + * the `trustedHosts` value built from them plus the explicit extras. The + * single sample is deliberate — display must advertise only addresses the + * fence was configured with, so the web-app row receives this same snapshot. + * Derived entries are port-less IP literals: DNS rebinding needs an + * attacker-controlled name, so an IP-literal Host is safe on any port, and + * the bound port may be OS-assigned, unknowable pre-boot. + * @param bindHost - the effective webserver bind host (CLI flag, else the composed row value). + * @param extra - `--trusted-host` values, in argv order. + * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). */ -export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void { - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, sourceRoot) - promptCtx.systemPrompt.section({ - name: 'app:web-surface', - order: -98, - text: () => webSurfacePrompt(localWebUrl(promptCtx), mode), - }) - }) - ctx.inject(['bashEnv'], (runtimeCtx) => { - runtimeCtx.bashEnv.register({ - name: 'web-runtime', - variables: { - [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, - [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' }, - }, - resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: mode }), - }) - }) +export function resolveLanTrust( + bindHost: string | undefined, + extra: readonly string[], +): { lanAddresses: string[]; trustedHosts: string[] } { + const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} + +/** The `dsh web` flag family, already parsed by the argument adapter. */ +export interface WebFlags { + patches: string[] + host?: string + port?: number + dev: boolean + workspaceRoot?: string + trustedHosts?: string[] } /** - * Serve the browser UI from the shipped config tree. `host`/`port` are passed - * through only when the flag was given; absent, the shipped Web overlay value stands. - * @param host - the bind host, or `undefined` to keep the config default. - * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. - * @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles. - * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. - * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. - * @param config - an overlay of loader patches applied over the shipped web - * composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the - * personal overlay; already parsed from `--config`. + * Derive the web alias's flag patches over an already-composed profile tree. + * Patches replace a row's whole config, so each patched row's composed values + * are re-read and merged under the overrides. + * @param rows - the composed row index from {@link composeProfile}. + * @param flags - the parsed flag family. + * @returns the flag patch list, in application order. */ -export async function runWeb( - host: string | undefined, - port: number | undefined, - dev: boolean, - workspaceRoot: string | undefined, - trustedHosts: string[] | undefined, - config?: string, -): Promise { - const mode: WebMode = dev ? 'development' : 'production' - const entry = new AppCLIEntry({ - configPath: BASE_CONFIG, - overlayPath: WEB_OVERLAY, - ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, - dev, - prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) }, - watchPersonalConfig: true, - ...host !== undefined && { host }, - ...port !== undefined && { port }, - ...workspaceRoot !== undefined && { workspaceRoot }, - ...trustedHosts !== undefined && { trustedHosts }, +function deriveWebFlagPatches( + rows: Map, + flags: WebFlags, +): PatchOptions[] { + const overrides = new Map>() + const put = (entryId: string, key: string, value: unknown): void => { + const bag = overrides.get(entryId) ?? {} + bag[key] = value + overrides.set(entryId, bag) + } + if (flags.host !== undefined) put('webserver', 'host', flags.host) + if (flags.port !== undefined) put('webserver', 'port', flags.port) + if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot) + const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host + const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) + if (trustedHosts.length > 0) { + // Additive over the composed value: a cordis.patch.yml-configured fence + // authority must survive the derived LAN literals and flag extras — a + // silent drop of security-relevant fence configuration. + const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] + put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts]) + } + // mode and lanAddresses are launcher-derived on every boot (--dev also + // inserts the client-hmr row), never pass-throughs of composed values. + put('web-runtime', 'mode', flags.dev ? 'development' : 'production') + put('web-runtime', 'lanAddresses', lanAddresses) + const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { + const composed = rows.get(id) + if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) + return { id, config: { ...(composed.config ?? {}) as Record, ...bag } } + }) + if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] }) + return patches +} + +/** + * Serve the browser UI from the web profile. Host/port/workspace-root flags + * are passed through only when given (absent, the composed profile values + * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on + * every boot. The URL line is printed by the web-app bundle's runtime row + * after Loader settlement. + * @param flags - the parsed `dsh web` flag family. + */ +export async function runWeb(flags: WebFlags): Promise { + await runProfile({ + profile: 'web', + patchFiles: flags.patches, + deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), + prepare: (ctx: Context) => { + ctx.inject(['systemPrompt'], (promptCtx) => { + addHarnessSourceSection(promptCtx, SOURCE_ROOT) + }) + }, }) - const { ctx, port: boundPort } = await entry.run() - const resolvedLocalWebUrl = localWebUrl(ctx) - - const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() }) - - // Install shutdown handling before publishing readiness: supervisors may - // send a signal as soon as they observe the URL line. - process.on('SIGTERM', () => { shutdown.interrupt(0) }) - process.on('SIGINT', () => { shutdown.interrupt(130) }) - - // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL - // must name an address the /api trust fence was configured with. - const lanCandidate = entry.lanAddresses[0] - console.log(`dsh web: ${resolvedLocalWebUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`) } diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 38eed06eb4..93bfb62cc6 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,49 +21,71 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes the required raw config, one-shot prompt, and Web command', () => { - expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'config', config: 'custom.yml' }) - expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) - expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' }) + it('routes profile boots, one-shot tasks, and the web alias', () => { + expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) + expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) + .toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' }) + expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) + expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) + expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] }) expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) - .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) + .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) - it('routes raw and Web config dumps', () => { - expect(parse(['--config', 'c.yml', '--dump-config'])) - .toEqual({ mode: 'dump-config', surface: 'config', defaultOnly: false, config: 'c.yml' }) - expect(parse(['--dump-default-config'])) - .toEqual({ mode: 'dump-config', surface: 'config', defaultOnly: true }) + it('routes the plugin pnpm forwarder', () => { + expect(parse(['plugin', '--profile', 'tui', 'add', 'turtle-ui'])) + .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] }) + expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui'])) + .toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] }) + expect(parse(['plugin', '--profile', 'tui', 'why', 'cordis'])) + .toEqual({ mode: 'plugin', profile: 'tui', args: ['why', 'cordis'] }) + // Unknown pnpm flags forward verbatim. + expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x'])) + .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] }) + }) + + it('routes profile and web config dumps', () => { + expect(parse(['--profile', 'web', '--dump-config'])) + .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] }) + expect(parse(['--profile', 'web', '--dump-default-config'])) + .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] }) + expect(parse(['--profile', 'tui', '--dump-config', '--patch', 'x.yml'])) + .toEqual({ mode: 'dump-config', profile: 'tui', defaultOnly: false, patches: ['x.yml'] }) expect(parse(['web', '--dump-config'])) - .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false }) - expect(parse(['web', '--dump-config', '--config', 'w.yml'])) - .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' }) + .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] }) expect(parse(['web', '--dump-default-config'])) - .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true }) + .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] }) }) - it('rejects missing config, removed commands, and contradictory inputs', () => { + it('rejects missing profile, removed flags, and contradictory inputs', () => { expect(exitCode([])).toBe(1) - expect(exitCode(['tui'])).toBe(1) - expect(exitCode(['meta'])).toBe(1) - expect(exitCode(['upgrade'])).toBe(1) + expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile + expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed + expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['--profile', ''])).toBe(1) + expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) - expect(exitCode(['--dump-config', '--dump-default-config', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['--dump-config', '--config', 'c.yml', '-p', 'task'])).toBe(1) - expect(exitCode(['-p', ''])).toBe(1) - expect(exitCode(['--config='])).toBe(1) - expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['--profile', 'x', '--dump-config', '--dump-default-config'])).toBe(1) + expect(exitCode(['--profile', 'x', '--dump-default-config', '--patch', 'p.yml'])).toBe(1) + expect(exitCode(['--profile', 'x', '--dump-config', 'task'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) - expect(exitCode(['bogus-positional'])).toBe(1) - expect(exitCode(['web', '-p', 'task'])).toBe(1) - expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + expect(exitCode(['--profile', 'x', 'web'])).toBe(1) expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) - expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) - expect(exitCode(['web', '--config='])).toBe(1) + expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) + expect(exitCode(['web', '--patch='])).toBe(1) + // Boot-free dumps derive no flag patches; silently dropping the flags + // would print a tree that differs from the same invocation's boot. + expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) + expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1) + // A non-numeric port fails at the flag, not deep in the webserver schema. + expect(exitCode(['web', '--port', 'abc'])).toBe(1) + expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required + expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward + expect(exitCode(['plugin', '--profile', ''])).toBe(1) + expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1) }) it('exits 0 for help and version', () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index fcfe8b3829..e8c7d18b18 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,15 +1,14 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -/** Published-entry acceptance for raw argument errors and boot-free config dumps. */ +/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const rawOverlay = fileURLToPath(new URL('./fixtures/raw-overlay.cordis.yml', import.meta.url)) -const rawInvalidProvider = fileURLToPath(new URL('./fixtures/raw-invalid-provider.cordis.yml', import.meta.url)) +const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url)) async function runBuiltBin( args: readonly string[] = [], @@ -31,60 +30,95 @@ async function runBuiltBin( async function waitForFile(file: string): Promise { const deadline = Date.now() + 20_000 while (!existsSync(file)) { - if (Date.now() >= deadline) throw new Error(`dsh raw lifecycle marker did not appear: ${file}`) + if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`) await new Promise(resolve => setTimeout(resolve, 20)) } } -interface RawLifecycleFixture { +interface ProfileLifecycleFixture { home: string ready: string settled: string disposed: string - overlay: string } -function createRawLifecycleFixture(): RawLifecycleFixture { - const home = mkdtempSync(join(tmpdir(), 'dsh-raw-lifecycle-')) +/** + * A minimal custom profile: one lifecycle-marker plugin bundle listed in + * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without + * booting the entire product tree. + */ +function createProfileLifecycleFixture(): ProfileLifecycleFixture { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-')) const ready = join(home, 'ready') const settled = join(home, 'settled') const disposed = join(home, 'disposed') - const plugin = join(home, 'lifecycle.mjs') - const overlay = join(home, 'overlay.cordis.yml') - writeFileSync(plugin, [ + const bundleDir = join(home, 'lifecycle-bundle') + mkdirSync(bundleDir, { recursive: true }) + writeFileSync(join(bundleDir, 'plugin.mjs'), [ "import { writeFileSync } from 'node:fs'", - "export const name = 'raw-lifecycle-fixture'", - "export const inject = ['sessionQuery']", - 'export function apply(ctx) {', + "import { join } from 'node:path'", + "export const name = 'profile-lifecycle-fixture'", + 'export function apply(ctx, config = {}) {', ' let active = true', + ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.', + ' const heartbeat = setInterval(() => {}, 1000)', + ' // Echo the mounted generation so the hot-reload e2e can assert both an', + ' // applied override and its removal reverting to this bundle default.', + " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", " writeFileSync(process.env.RAW_READY_FILE, 'ready')", ' void ctx.loader.await().then(() => {', " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')", ' })', ' ctx.effect(() => () => {', ' active = false', + ' clearInterval(heartbeat)', " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')", ' })', '}', '', ].join('\n')) - writeFileSync(overlay, [ + writeFileSync(join(bundleDir, 'cordis.patch.yml'), [ '- insert:', - ' - id: raw-lifecycle-fixture', - ` name: ${pathToFileURL(plugin).href}`, + ' - id: profile-lifecycle-fixture', + ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`, '', ].join('\n')) - return { home, ready, settled, disposed, overlay } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: 'dsh-lifecycle-bundle', + version: '0.0.0', + type: 'module', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + }, undefined, 2)) + const profileDir = join(home, 'profiles', 'lifecycle') + mkdirSync(join(profileDir, 'node_modules'), { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-lifecycle', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } }, + }, undefined, 2)) + // Hand-place the "installed" bundle where profile resolution finds it. + writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') + const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle') + mkdirSync(join(profileDir, 'node_modules'), { recursive: true }) + try { + rmSync(linkTarget, { recursive: true, force: true }) + } catch { /* fresh dir */ } + // Copy-free: a package.json redirecting via a relative main is enough for require.resolve. + mkdirSync(linkTarget, { recursive: true }) + for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) { + writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file))) + } + return { home, ready, settled, disposed } } -function startRawLifecycle(fixture: RawLifecycleFixture) { - return execa(process.execPath, [dshBin, '--config', fixture.overlay], { +function startProfileLifecycle(fixture: ProfileLifecycleFixture) { + return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], { cwd: fixture.home, input: '', reject: false, env: { DSH_HOME: fixture.home, - DSH_TELEMETRY_DISABLED: '1', RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, @@ -93,35 +127,57 @@ function startRawLifecycle(fixture: RawLifecycleFixture) { } describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { - it('requires --config for the raw command and rejects removed commands', async () => { + it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) expect(bare.stdout).toBe('') - expect(bare.stderr).toContain('--config is required') + expect(bare.stderr).toContain('--profile is required') const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) - expect(help.stdout).toContain('dsh --config ./app.cordis.yml') + expect(help.stdout).toContain('dsh --profile web') + expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const command of ['tui', 'meta', 'upgrade']) { - const removed = await runBuiltBin([command]) - expect(removed.code).toBe(1) - expect(removed.stderr).not.toContain('experimental') + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) { + const result = await runBuiltBin(removed) + expect(result.code).toBe(1) } }, 30_000) - it('reports a raw overlay boot failure without hanging', async () => { - const result = await runBuiltBin(['--config', rawInvalidProvider], { - DEEPSEEK_API_KEY: 'keyless-invalid-config', - DSH_TELEMETRY_DISABLED: '1', - }) - expect(result.code).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('llm-pi-ai') + it('fails loud on a nonexistent profile with the plugin-command hint', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-')) + try { + const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home }) + expect(result.code).toBe(1) + expect(result.stderr).toContain('profile "nope" does not exist') + expect(result.stderr).toContain('dsh plugin --profile nope add') + } finally { + rmSync(home, { recursive: true, force: true }) + } }, 30_000) - it('applies an inserted raw plugin and disposes it on a startup-time signal', async () => { - const fixture = createRawLifecycleFixture() - const child = startRawLifecycle(fixture) + it('reports a patch-overlay boot failure without hanging', async () => { + // The HMR main watcher's initial scan once refreshed the include + // mid-initial-apply, deadlocking the failing apply's rollback against the + // refresh drain: dsh exited 13 with no diagnostic instead of settling + // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)). + const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) + try { + const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], { + DSH_HOME: home, + DEEPSEEK_API_KEY: 'keyless-invalid-config', + DSH_TELEMETRY_DISABLED: '1', + }) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('llm-pi-ai') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { + const fixture = createProfileLifecycleFixture() + const child = startProfileLifecycle(fixture) try { await waitForFile(fixture.ready) child.kill('SIGTERM') @@ -135,11 +191,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('fully settles a valid raw overlay and disposes it on a signal', async () => { - const fixture = createRawLifecycleFixture() - const child = startRawLifecycle(fixture) + it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => { + const fixture = createProfileLifecycleFixture() + const child = startProfileLifecycle(fixture) + const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml') + const configFile = join(fixture.home, 'config-echo') try { await waitForFile(fixture.settled) + // The live profile layer: even without an hmr row in the composition, + // the launcher mounts a config-only watcher, so an edited + // cordis.patch.yml lands in the running tree (the reload disposes the + // patched row's old fiber — observable as the disposed marker — and + // mounts the new config, which echoes its generation and re-writes the + // ready marker). + rmSync(fixture.ready) + writeFileSync(profilePatch, [ + '- id: profile-lifecycle-fixture', + ' config:', + ' generation: 2', + '', + ].join('\n')) + await waitForFile(fixture.ready) + expect(readFileSync(configFile, 'utf8')).toBe('2') + // Removal reverts: the bundle's inserted row must return to its own + // default config, not keep the removed override — the insert-aliasing + // regression (a shared patch object mutated in place by a former + // generation would make this impossible). + rmSync(fixture.ready) + writeFileSync(profilePatch, '[]\n') + await waitForFile(fixture.ready) + expect(readFileSync(configFile, 'utf8')).toBe('bundle-default') + // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too + // and outranks the per-profile layer. + rmSync(fixture.ready) + writeFileSync(join(fixture.home, 'cordis.patch.yml'), [ + '- id: profile-lifecycle-fixture', + ' config:', + ' generation: home', + '', + ].join('\n')) + await waitForFile(fixture.ready) + expect(readFileSync(configFile, 'utf8')).toBe('home') child.kill('SIGTERM') const result = await child expect(result.exitCode).toBe(0) @@ -151,55 +243,129 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('anchors a relative add spec to the invoking directory, not the profile', async () => { + // `dsh plugin --profile x add .` from a plugin checkout must install THAT + // checkout — pnpm's cwd is the profile directory, so an un-anchored `.` + // would self-link the profile. + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-')) + const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-')) + try { + writeFileSync(join(checkout, 'package.json'), JSON.stringify({ + name: 'anchored-bundle', + version: '1.0.0', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n') + const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], { + cwd: checkout, + input: '', + timeout: 60_000, + killSignal: 'SIGKILL', + reject: false, + env: { DSH_HOME: home }, + }) + expect(result.exitCode).toBe(0) + const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as { + dependencies: Record + dsh: { profile: { bundles: string[] } } + } + expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle']) + expect(manifest.dsh.profile.bundles).toContain('anchored-bundle') + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(checkout, { recursive: true, force: true }) + } + }, 90_000) + + it('activates a dependency that gained dsh.bundle in a later update', async () => { + // Reconcile runs against the INSTALLED state on every successful pnpm + // run, so `update` (not only `add`) activates a package whose newer + // version declares dsh.bundle. Simulated without a registry: hand-place + // the installed package, flip its manifest, and run a benign pnpm verb. + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-')) + try { + const profileDir = join(home, 'profiles', 'up') + const installed = join(profileDir, 'node_modules', 'late-bundle') + mkdirSync(installed, { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-up', + private: true, + dependencies: { 'late-bundle': 'file:./late-bundle' }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } }, + })) + writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') + // v1: no dsh manifest — a plain dependency. + writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' })) + const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home }) + expect(first.code).toBe(0) + let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } } + expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base']) + // v2: the installed package now declares dsh.bundle (an update landed). + writeFileSync(join(installed, 'package.json'), JSON.stringify({ + name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n') + const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home }) + expect(second.code).toBe(0) + manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } } + expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle']) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + describe('config dump', () => { let home: string beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) }) afterEach(() => { rmSync(home, { recursive: true, force: true }) }) - it('prints the shipped base without a user layer', async () => { - const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) + it('prints the web profile bundle layers without a user layer', async () => { + const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) expect(code).toBe(0) expect(stderr).toBe('') expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") expect(stdout).toContain('agents: []') - expect(stdout).toContain('# == base.cordis.yml') + expect(stdout).toContain('# == @deepseek-ai/dsh-base') + expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) - it('composes the required raw overlay directly over the base', async () => { - writeFileSync(join(home, 'config.yaml'), [ + it('composes the profile user layer and a --patch overlay in order', async () => { + // Auto-init the web profile first, then write its user layer. + const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) + expect(init.code).toBe(0) + const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml') + writeFileSync(profilePatch, [ '- id: agent-loop', ' config:', ' agents:', ' - id: personal', ' provider: personal-provider', ' model: personal-model', + '- id: absent-row', + ' config:', + ' x: 1', + '', + ].join('\n')) + const overlay = join(home, 'overlay.cordis.yml') + writeFileSync(overlay, [ + '- id: agent-loop', + ' config:', + ' agents:', + ' - id: configured', + ' provider: configured-provider', + ' model: configured-model', '', ].join('\n')) const { stdout, code, stderr } = await runBuiltBin( - ['--config', rawOverlay, '--dump-config'], + ['--profile', 'web', '--patch', overlay, '--dump-config'], { DSH_HOME: home }, ) expect(code).toBe(0) expect(stdout).toContain('provider: configured-provider') expect(stdout).not.toContain('personal-provider') - expect(stdout).toContain(`patched by ${rawOverlay}`) + // Both layers patched the row; provenance lists them in application order. + expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) - - it('keeps the Web overlay and personal layer on the Web command', async () => { - writeFileSync(join(home, 'config.yaml'), [ - '- id: agent-loop', - ' config:', - ' agents:', - ' - id: personal', - ' provider: personal-provider', - ' model: personal-model', - '', - ].join('\n')) - const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home }) - expect(code).toBe(0) - expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") - expect(stdout).toContain('provider: personal-provider') - }, 30_000) }) }) diff --git a/apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/invalid-provider.cordis.yml similarity index 58% rename from apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml rename to apps/cli/tests/fixtures/invalid-provider.cordis.yml index 159d300aba..88534a1680 100644 --- a/apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml +++ b/apps/cli/tests/fixtures/invalid-provider.cordis.yml @@ -1,4 +1,4 @@ -# Invalid raw overlay used to prove boot failures settle and exit. +# Invalid `--patch` overlay used to prove boot failures settle and exit. - id: llm-pi-ai config: diff --git a/apps/cli/tests/fixtures/raw-overlay.cordis.yml b/apps/cli/tests/fixtures/raw-overlay.cordis.yml deleted file mode 100644 index f205972450..0000000000 --- a/apps/cli/tests/fixtures/raw-overlay.cordis.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Raw CLI overlay used by the built config-dump acceptance test. - -- id: agent-loop - config: - agents: - - id: configured - provider: configured-provider - model: configured-model - -- id: absent-row - config: - value: unmatched diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index 81089b3598..cfa87b03de 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -65,8 +65,17 @@ async function runHeadlessPtySmoke(): Promise { const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-')) try { const home = join(cwd, '.dsh') - await mkdir(home, { recursive: true }) - await writeFile(join(home, 'config.yaml'), [ + // Pre-initialize the headless profile with the never-dispose row in its + // user patch layer (the same file `dsh --profile headless` hot-reloads). + const profileDir = join(home, 'profiles', 'headless') + await mkdir(profileDir, { recursive: true }) + await writeFile(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-headless', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] } }, + }, undefined, 2)) + await writeFile(join(profileDir, 'cordis.patch.yml'), [ '- insert:', ' - id: never-dispose', ` name: '${neverDisposePlugin}'`, @@ -74,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['-p', 'never complete'], + configArgs: ['--profile', 'headless', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts index 6e6d0b6e85..b1477a63d0 100644 --- a/apps/cli/tests/lazy-search-startup.compat.spec.ts +++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts @@ -4,8 +4,8 @@ * Only the dedicated Node compatibility gate opts this test in after building * both artifacts; ordinary Vitest inventory deterministically skips it. * The child runs built artifacts under plain Node with the real shipped - * config (base.cordis.yml + the web.cordis.yml overlay). - * Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the + * web profile (dsh-base + dsh-web-app bundle patches, auto-initialized). + * Its URL line follows the settled profile boot; SIGTERM then exercises the * shipped quiescent disposer. */ @@ -21,8 +21,8 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const builtBin = join(repoRoot, 'apps/cli/lib/bin.js') const webDist = join(repoRoot, 'apps/web/dist/index.html') -// The web overlay owns the session-query-sqlite lazy-open patch row. -const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml') +// The web bundle's patch owns the session-query-sqlite lazy-open row. +const configPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml') const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1' interface ConfigRow { diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index f8ee51216a..6ce11dc7f0 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -16,7 +16,7 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' describe('dsh SOURCE launcher (node --import tsx/esm)', () => { - it('boots the source entry and requires the raw config overlay', async () => { + it('boots the source entry and requires a profile', async () => { const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], { cwd: repoRoot, input: '', @@ -28,7 +28,7 @@ describe('dsh SOURCE launcher (node --import tsx/esm)', () => { throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) } expect(result.exitCode).not.toBe(0) - expect(result.stderr).toContain('--config is required') + expect(result.stderr).toContain('--profile is required') expect(result.stdout).toBe('') }, 30_000) }) diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts index 0735aa93c7..0f44819564 100644 --- a/apps/cli/tests/telemetry-switch.spec.ts +++ b/apps/cli/tests/telemetry-switch.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveTelemetryPatch } from '../src/app-cli-entry.ts' +import { resolveTelemetryPatch } from '../src/profile-boot.ts' describe('resolveTelemetryPatch', () => { it('keeps telemetry enabled when the switch is unset or empty', () => { @@ -13,11 +13,10 @@ describe('resolveTelemetryPatch', () => { } }) - it('fails loud when the switch is set but the row is absent', () => { - expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition') - }) - - it('ignores a missing row while the switch is unset', () => { + it('is trivially satisfied by a composition without the telemetry row', () => { + // A custom profile need not mount telemetry: nothing exports, so the + // privacy switch has nothing to disable and generates no patch. + expect(resolveTelemetryPatch('1', false)).toBeUndefined() expect(resolveTelemetryPatch(undefined, false)).toBeUndefined() }) }) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts index 571a9f76b7..11b925604a 100644 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ b/apps/cli/tests/trusted-hosts.spec.ts @@ -1,7 +1,7 @@ /** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust } from '../src/app-cli-entry.ts' +import { resolveLanTrust } from '../src/web.ts' vi.mock('node:os', () => ({ networkInterfaces: () => ({ diff --git a/apps/cli/tests/web-prompt-context.spec.ts b/apps/cli/tests/web-prompt-context.spec.ts deleted file mode 100644 index 64280bda47..0000000000 --- a/apps/cli/tests/web-prompt-context.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { sep } from 'node:path' -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot' -import type {} from '@deepseek-ai/dsh-host-webserver' -import { prepareWebRuntimeContext } from '../src/web.ts' - -describe('prepareWebRuntimeContext', () => { - it('installs both sections before a later systemPrompt consumer activates', async () => { - const ctx = new Context() - const sourceRoot = `${sep}opt${sep}harness-src` - let observedSections: { name: string; text: string }[] | undefined - try { - prepareWebRuntimeContext(ctx, sourceRoot, 'production') - ctx.provide('httpServer', { port: 3080 } as Context['httpServer']) - const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => { - const assembly = await promptCtx.systemPrompt.assemble() - observedSections = assembly.sections - }) - - await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' }) - await consumer - - expect(observedSections?.map(section => section.name)).toContain(HARNESS_SOURCE_SECTION) - expect(observedSections?.find(section => section.name === 'app:web-surface')?.text) - .toContain('http://127.0.0.1:3080') - } finally { - await ctx.fiber.dispose() - } - }) -}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 44730e9f37..36c4bad6dd 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -11,17 +11,53 @@ { "path": "../../vendor/cordis" }, + { + "path": "../../vendor/loader" + }, + { + "path": "../../vendor/include" + }, + { + "path": "../../packages/ui/app-boot" + }, + { + "path": "../../packages/bundle/base" + }, + { + "path": "../../packages/bundle/headless" + }, + { + "path": "../../packages/bundle/web-app" + }, { "path": "../../packages/host/apiproxy" }, { "path": "../../packages/host/webserver" }, + { + "path": "../../packages/host/frontend-static" + }, { "path": "../../packages/core/session" }, { - "path": "../../packages/ui/app-boot" + "path": "../../packages/core/system-prompt" + }, + { + "path": "../../packages/core/tools" + }, + { + "path": "../../packages/util/paths" + }, + { + "path": "../../packages/mcp/mcp-client" + }, + { + "path": "../../packages/support/loader-smoke" + }, + { + "path": "../../packages/session-query/session-query-sqlite" }, { "path": "../../packages/bash/bash-env" @@ -29,12 +65,6 @@ { "path": "../../packages/bash/tool-bash" }, - { - "path": "../../packages/util/paths" - }, - { - "path": "../../packages/session-query/session-query-sqlite" - }, { "path": "../../packages/client/connection" }, diff --git a/apps/web/tests/pin-browse-picker.overlay.yml b/apps/web/tests/pin-browse-picker.overlay.yml index 266c35e94b..d48dfa3538 100644 --- a/apps/web/tests/pin-browse-picker.overlay.yml +++ b/apps/web/tests/pin-browse-picker.overlay.yml @@ -1,4 +1,4 @@ -# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the +# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the # in-browser directory picker. The shipped row is `-auto`, which resolves to # the native OS chooser on a loopback bind with a local display — an # interaction a Playwright page cannot drive, so the resolved backend would diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 52eb7f151d..dc68cbf67d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1,7 +1,8 @@ // Shared scaffold for the keyless browser e2e lane (Agent Note: // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). -// Boots the REAL web composition — the shipped base plus web overlay through -// the vendored Loader (the same include boot AppCLIEntry drives), patched the +// Boots the REAL web composition — the dsh-base and dsh-web-app bundle +// patches over the empty profile root through the vendored Loader (the same +// layer stack the profile boot composes), patched the // snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket // downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: // replay (default, keyless: normally disables the llm-deepseek row and @@ -11,7 +12,7 @@ // masking its credential, without making a model call. // // Composition divergences from `dsh web`, all deliberate, all via include -// patches after the shipped surface overlay, over the SAME tree (never a +// patches after the shipped bundle layers, over the SAME tree (never a // second yml): temp persistenceRoot; host-level skill roots confined to the // temp workspace while project skill discovery remains real; workspace-context // disabled (recorded fixtures must not embed this repo's AGENTS.md); @@ -22,9 +23,9 @@ // (the plugin-row path discards the ReplayHandle; the direct install keeps // assertConsumed for the teardown fixture-consumption check). import { existsSync } from 'node:fs' -import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { pathToFileURL } from 'node:url' import type { Page } from 'playwright' import { expect } from 'vitest' @@ -53,8 +54,8 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' -import { prepareWebRuntimeContext } from '../../cli/src/web.ts' -import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' +import { addHarnessSourceSection, healProfilesModuleFallback } from '@deepseek-ai/dsh-app-boot' +import { REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */ export type WebSnapshotMode = 'replay' | 'record' | 'refresh' @@ -70,9 +71,11 @@ export function webSnapshotMode(): WebSnapshotMode { throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) } -/** The shipped composition under test: apps/cli's shared base and web overlay. */ -const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/config/base.cordis.yml') -const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml') +/** The shipped composition under test: the dsh-base and dsh-web-app bundle patches over the empty profile root. */ +const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') +const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') +/** The installation anchor whose dependency surface the profile module fallback mirrors. */ +const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -117,7 +120,7 @@ export interface WebScaffold { export interface LaunchOptions { /** * Optional product overlay applied after the shipped Web surface and before - * the scaffold's hermetic test patches, matching AppCLIEntry's `--config` + * the scaffold's hermetic test patches, matching the launcher's `--patch` * ordering. */ extraOverlayPath?: string @@ -240,14 +243,17 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/profiles. + healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) + const profileDir = join(harnessHome, 'profiles', 'scaffold') + await mkdir(profileDir, { recursive: true }) + const rootConfig = join(profileDir, 'cordis.yml') + await writeFile(rootConfig, '[]\n') + ctx.baseUrl = pathToFileURL(profileDir).href + '/' // This direct Loader harness supplies the same root-path capability as app-boot. ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) @@ -329,10 +346,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { addHarnessSourceSection(promptCtx, REPO_ROOT) }) await ctx.loader.create({ name: 'cordis:include', - config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches }, + config: { path: pathToFileURL(rootConfig).href, patches }, }) await ctx.loader.await() assertEntriesLoaded(ctx, 'web e2e scaffold') diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 03faab05cd..f32daebfe4 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -487,7 +487,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port), // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. - '--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), ], { cwd: sessionsDir, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 710022c11c..f6e88f2a27 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -439,6 +439,20 @@ export interface Config { Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) +## `@deepseek-ai/dsh-frontend-static` + +Requires: `httpServer` + +```ts config-catalog +/** Plugin config: the dist anchor. */ +export interface Config { + /** Absolute path of index.html inside the dist root. */ + distIndex: string +} +``` + +Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/frontend-static/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -483,6 +497,20 @@ export interface Config { Source: [`packages/goal/goal/src/index.ts:114`](../packages/goal/goal/src/index.ts) +## `@deepseek-ai/dsh-headless` + +Requires: `apiProxy` · `httpServer` + +```ts config-catalog +/** Plugin config: the task, patched in by the launcher. */ +export interface Config { + /** The prompt text for the single turn. */ + task: string +} +``` + +Source: [`packages/bundle/headless/src/index.ts:33`](../packages/bundle/headless/src/index.ts) + ## `@deepseek-ai/dsh-hooks-claude` Requires: `bash` @@ -577,18 +605,16 @@ Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/h ## `@deepseek-ai/dsh-host-webserver` ```ts config-catalog -/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +/** Gateway config: the listen address. */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number - /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ - distIndex: string } ``` -Source: [`packages/host/webserver/src/index.ts:47`](../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts) ## `@deepseek-ai/dsh-invariants` @@ -2216,6 +2242,39 @@ export interface WebServiceConfig { Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) +## `@deepseek-ai/dsh-web-app` + +Requires: `httpServer` + +```ts config-catalog +/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +export interface Config { + /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ + mode: WebMode + /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + printUrl: boolean + /** + * Register the model-visible surface context (the `app:web-surface` prompt + * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot + * layer turns it off: its user is not interacting through the GUI, so the + * orientation text would be false. + */ + surfaceContext: boolean + /** + * LAN IPv4 addresses sampled once by the launcher when the effective bind + * is all-interfaces — the exact snapshot the /api trust fence was + * configured with, so the printed LAN URL can never name an address the + * fence rejects. Empty on a loopback bind. + */ + lanAddresses: string[] +} + +/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ +export type WebMode = 'production' | 'development' +``` + +Source: [`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) + ## `@deepseek-ai/dsh-web-fetch-local` Requires: `web` @@ -2446,6 +2505,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) +- `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-schema-form` ([`packages/client/schema-form/src/index.ts`](../packages/client/schema-form/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 896c17fb56..44a44a139e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -769,7 +769,7 @@ Source: [`packages/goal/goal/src/index.ts:181`](../../packages/goal/goal/src/ind ## `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. ```ts cordis-catalog /** @@ -789,15 +789,33 @@ register(route: WebRoute): () => void registerUpgrade(route: WebUpgradeRoute): () => void /** - * Register an index.html transform, applied to every index response in - * registration order. + * Claim the fallback seat: the handler answering every request no named + * route matches (the SPA dist server in the shipped Web composition). One + * owner only — a second registration throws, because two fallbacks cannot + * compose. + * @param handler - owns the full response lifecycle of unmatched requests. + * @returns the disposer releasing the seat. + */ +registerFallback(handler: WebRoute['handler']): () => void + +/** + * Register an index.html transform, applied by the fallback owner to every + * index response ({@link applyIndexTaps}) in registration order. * @param transform - pure html-to-html function. * @returns the disposer removing the transform. */ tapIndex(transform: (html: string) => string): () => void + +/** + * Run an index.html body through the registered taps in registration order + * — called by the fallback owner on every index response it renders. + * @param html - the raw index.html body. + * @returns the transformed body. + */ +applyIndexTaps(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:63`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index d958eeac32..4e829dcb8f 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: c9f4889398222793005fce6832d0917a20d0be30 -01-first-plugin.zh.md: b9d6994fad8e26cdfc52db0fbcef5631d5d53b03 +01-first-plugin.md: c44e7f95fb11d5337ecfaf4251c8b2f2b9b14680 +01-first-plugin.zh.md: 9461884d312ad2e64af12fa42952a986e1ad5d8a diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index c9f4889398..c44e7f95fb 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -48,7 +48,7 @@ The process exits on its own once nothing is left running. What happened: 2. The Loader read `cordis.yml`, resolved `./hello.ts`, and mounted it as a child plugin. 3. Cordis called your `apply(ctx)`. -There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [`dsh` base](../../apps/cli/config/base.cordis.yml), for example, is a longer plugin composition that deployment overlays patch. +There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [`dsh` base](../../packages/bundle/base/cordis.patch.yml), for example, is a longer plugin composition that deployment overlays patch. ## The two other plugin shapes diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index b9d6994fad..9461884d31 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -48,7 +48,7 @@ hello from my first plugin 2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。 3. Cordis 调用你的 `apply(ctx)`。 -你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[`dsh` base](../../apps/cli/config/base.cordis.yml) 就是一份更长的插件组合,由部署 overlay 对它进行修补。 +你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[`dsh` base](../../packages/bundle/base/cordis.patch.yml) 就是一份更长的插件组合,由部署 overlay 对它进行修补。 ## 其他两种插件形态 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3de02d1b4c..23794b5c9d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 0e2e7e0c37..3ad8ef0b7e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -144,6 +144,11 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_bundle["packages/bundle"] + pkg_base["base"] + pkg_headless["headless"] + pkg_web_app["web-app"] + end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] @@ -199,6 +204,7 @@ flowchart TD pkg_repeat_tool_guard["repeat-tool-guard"] end subgraph group_host["packages/host"] + pkg_frontend_static["frontend-static"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] @@ -284,6 +290,7 @@ flowchart TD pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants + pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -326,6 +333,8 @@ flowchart TD pkg_client_ui_trajectory --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants + pkg_frontend_static --> pkg_host_webserver + pkg_frontend_static --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -551,6 +560,11 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_headless --> pkg_agent + pkg_headless --> pkg_host_apiproxy + pkg_headless --> pkg_host_webserver + pkg_headless --> pkg_invariants + pkg_headless --> pkg_session pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -949,6 +963,9 @@ flowchart TD pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools + pkg_web_app --> pkg_bash_env + pkg_web_app --> pkg_invariants + pkg_web_app --> pkg_system_prompt pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime @@ -1089,6 +1106,7 @@ flowchart TD | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | +| [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | @@ -1113,6 +1131,7 @@ flowchart TD | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -1173,6 +1192,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | @@ -1242,6 +1262,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`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) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`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) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`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), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 03f4ce3bca..65a2760517 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -31,7 +31,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-subagent-control` | `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.sessionQuery (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | @@ -1189,7 +1189,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-subagent-control` diff --git a/docs/user/develop/basic/config.i18n.yaml b/docs/user/develop/basic/config.i18n.yaml index 7fca045189..6bf0ddea72 100644 --- a/docs/user/develop/basic/config.i18n.yaml +++ b/docs/user/develop/basic/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 docs/user/develop/basic/config.md -config.md: 11a2311464789f74537cc7c4435f83ec07ca26fd -config.zh.md: 4e827ecafa6bfaf87c3e3f118425e656e1254787 +config.md: 02998c32415b5ba7acf82700034cabc1f7314f33 +config.zh.md: 42af432b36d8f82871aa7d7b6a3a2eaf7427cdae diff --git a/docs/user/develop/basic/config.md b/docs/user/develop/basic/config.md index 11a2311464..02998c3241 100644 --- a/docs/user/develop/basic/config.md +++ b/docs/user/develop/basic/config.md @@ -101,5 +101,6 @@ A configuration edit hot-replaces the plugin: the framework unloads the old inst ## Next steps +- [Package and install a plugin](./publish.md) — ship the plugin as an installable package - [Plugins and lifecycle](../framework/) — understand the full plugin lifecycle - [Services and dependencies](../framework/service.md) — provide a service to other plugins diff --git a/docs/user/develop/basic/config.zh.md b/docs/user/develop/basic/config.zh.md index 4e827ecafa..42af432b36 100644 --- a/docs/user/develop/basic/config.zh.md +++ b/docs/user/develop/basic/config.zh.md @@ -101,5 +101,6 @@ export interface Config { ## 下一步 +- [打包与安装插件](./publish.md) — 把插件以可安装包的形式交付 - [插件与生命周期](../framework/) — 深入了解插件的完整生命周期 - [服务与依赖](../framework/service.md) — 让你的插件对外提供服务 diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 4298808fc7..2bb1d0ce7d 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: 45c8dfe495cd99da46a7b259b407af8deff570b3 -index.zh.md: 9d8ee47e6f07fb4a897f49487cb7327904573c1b +index.md: efedb07c8d757ef1f90d99fe1bf503a35c0f1a37 +index.zh.md: 2293a6086dc80fa77c88ef734ae17576ea513a10 diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 45c8dfe495..efedb07c8d 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -56,7 +56,7 @@ Create `scratch-plugin/cordis.yml` as a Web overlay that inserts the local plugi Start the Web UI with that overlay: ```sh -pnpm run dsh web --config ./scratch-plugin/cordis.yml +pnpm run dsh web --patch ./scratch-plugin/cordis.yml ``` Open `http://127.0.0.1:3080`. The terminal prints `[hello-plugin] plugin loaded!` during startup. diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 9d8ee47e6f..2293a6086d 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { 使用该覆盖层启动 Web UI: ```sh -pnpm run dsh web --config ./scratch-plugin/cordis.yml +pnpm run dsh web --patch ./scratch-plugin/cordis.yml ``` 打开 `http://127.0.0.1:3080`。启动期间,终端会打印 `[hello-plugin] plugin loaded!`。 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml new file mode 100644 index 0000000000..da74ce8d7c --- /dev/null +++ b/docs/user/develop/basic/publish.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 docs/user/develop/basic/publish.md +publish.md: 1d1179a78c4d3a7e9e7055e3f5ee41381e28147e +publish.zh.md: 26d1c2b737a3498bce0757d5b2d14e254fc13527 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md new file mode 100644 index 0000000000..1d1179a78c --- /dev/null +++ b/docs/user/develop/basic/publish.md @@ -0,0 +1,140 @@ +# Package and install a plugin + +English | [中文](publish.zh.md) + +The previous tutorials loaded a local plugin through a `--patch` overlay. This tutorial packages it as an installable **bundle**, installs it into a **profile** with `dsh plugin add`, and explains the layer order that determines the composed configuration. Complete [plugin configuration](./config.md) first. + +## Two concepts, two manifests + +Installation is built on two concepts. Both are described by a `package.json`, but they carry different kinds of manifest under the `dsh` key, and they answer different questions: + +- A **bundle** is an npm package that ships a configuration layer. Its manifest declares `dsh.bundle`, answering "what does this package contribute?": a patch file that inserts or overrides plugin rows. +- A **profile** is a directory under `$DSH_HOME/profiles/` describing one runnable composition. Its manifest declares `dsh.profile`, answering "which bundles compose this setup, in what order?". + +A bundle is what you author and distribute; a profile is what a user boots with `dsh --profile `. Nothing is both. + +### The bundle manifest + +``` +hello-plugin/ +├── package.json # declares dsh.bundle +├── cordis.patch.yml # the layer applied when a profile lists this bundle +└── index.js # plugin modules the patch rows reference +``` + +```json +{ + "name": "dsh-hello-plugin", + "version": "0.1.0", + "type": "module", + "main": "index.js", + "files": ["index.js", "cordis.patch.yml"], + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} +``` + +The patch file has the same shape as the `--patch` overlays you have been writing — a YAML array of patch entries — except plugin rows reference the package by name instead of a relative source path, so Node resolution finds the installed code: + +```yaml +- insert: + - id: hello + name: dsh-hello-plugin +``` + +A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. That is the correct shape for a library that plugin packages import rather than a plugin users enable. + +### The profile manifest + +A profile directory holds two files: + +- `package.json` — the profile's out-of-tree plugin dependencies (managed by pnpm) plus the `dsh.profile` manifest with its ordered `bundles` list. +- `cordis.patch.yml` — the user's own patch layer, applied after every bundle layer. + +You never write a profile manifest by hand: `dsh plugin` creates and maintains it. The next section shows the result. + +## Install into a profile + +`dsh plugin --profile ` forwards to pnpm in the profile directory, so every pnpm verb works. Install your package from its checkout: + +```sh +cd hello-plugin +dsh plugin --profile demo add . +``` + +The first use initializes the profile (with `@deepseek-ai/dsh-base` as its first bundle), pnpm links the checkout, and `dsh` appends the bundle to `dsh.profile.bundles` because the package declares `dsh.bundle`: + +```json +{ + "name": "dsh-profile-demo", + "private": true, + "dependencies": { + "dsh-hello-plugin": "link:/path/to/hello-plugin" + }, + "dsh": { + "profile": { + "bundles": [ + "@deepseek-ai/dsh-base", + "dsh-hello-plugin" + ] + } + } +} +``` + +Verify the layer without booting, then boot: + +```sh +dsh --profile demo --dump-config # shows a "# == dsh-hello-plugin" layer +dsh --profile demo +``` + +`dsh plugin --profile demo remove dsh-hello-plugin` removes both the dependency and the layer. + +## The loading order + +The effective configuration composes over an empty root by applying, in order: + +1. Each bundle patch named in the profile's `dsh.profile.bundles` list, in list order — `@deepseek-ai/dsh-base` first, then each installed bundle in the order it was added. +2. The profile's own `cordis.patch.yml`. +3. The home-level `$DSH_HOME/cordis.patch.yml` — machine-local preferences shared by every profile. +4. Each `--patch ` overlay, in argv order. +5. Launcher flag patches (for example `dsh web --port`). + +Later layers win per row, and a patch replaces a row's entire `config` value rather than deep-merging keys. Two consequences for bundle authors: + +- Your patch can override rows from earlier layers by `id` — the same way [the `dsh-web-app` bundle](../../../../packages/bundle/web-app/cordis.patch.yml) overrides `dsh-base` rows — but must restate every key the row needs, not just the changed one. +- Users can override your rows in their profile's `cordis.patch.yml` without touching your package, so prefer configuration defaults users are likely to keep and let the schema carry the rest. + +In-box bundle names always resolve from the dsh installation itself; pnpm manages only out-of-tree packages, so your bundle can rely on `@deepseek-ai/dsh-base` being present and current. + +## Installing from GitHub: the build-script catch + +Publishing to a registry is not required — users can install straight from a git host: + +```sh +dsh plugin --profile demo add github:you/hello-plugin +``` + +But a git install fetches **sources, not built artifacts**: nothing runs your `build` script, so a TypeScript package arrives without its `lib/` output and fails to load. Two things must happen, one on each side: + +- **The author** ships a `prepare` script — pnpm runs it after a git install — that builds the published entry points from source, self-contained: it must not assume dev-only context such as a sibling monorepo checkout. [turtle-ui](https://github.com/deepseek-harness/turtle-ui) is a working example: its `prepare` runs a dedicated tsdown config that transpiles `src/` without project references or type checking. +- **The user** allowlists the build. pnpm ≥10 refuses to run a git dependency's `prepare` script until it is explicitly allowed, so the first `add` fails; `dsh` points at the fix — copy the exact package key pnpm printed into the profile's `pnpm-workspace.yaml`: + + ```yaml + allowBuilds: + dsh-hello-plugin: true + ``` + + and re-run the `add`. + +Treat that allowance as what it is: **permission to execute the package's code on your machine at install time**, outside any sandbox the agent runs under. Only allow packages whose source you trust, and pin a commit (`github:you/hello-plugin#`) so a later push cannot silently change what runs. + +If you would rather not ask users for the allowance, distribute built artifacts instead — neither form needs any build permission: + +- **Publish to npm** with `lib/` built at `pnpm publish` time; `dsh plugin add your-package` then installs prebuilt code. +- **Ship a tarball** from `pnpm pack`; users run `dsh plugin add ./hello-plugin-0.1.0.tgz`. + +## Next steps + +- [Plugins and lifecycle](../framework/) — the full plugin lifecycle +- [CLI behavior reference](../../../../apps/cli/reference/README.md) — exact layer precedence, flags, and profile mechanics diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md new file mode 100644 index 0000000000..26d1c2b737 --- /dev/null +++ b/docs/user/develop/basic/publish.zh.md @@ -0,0 +1,140 @@ +# 打包与安装插件 + +[English](publish.md) | 中文 + +前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**(bundle),用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 + +## 两个概念,两种 manifest + +安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest(元数据清单)种类不同,回答的问题也不同: + +- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是"这个包贡献什么?":一个插入或覆盖插件行的 patch 文件。 +- **profile** 是位于 `$DSH_HOME/profiles/` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是"这套配置由哪些组合包按什么顺序组成?"。 + +组合包是你编写并分发的东西;profile 是用户用 `dsh --profile ` 启动的东西。没有东西同时是两者。 + +### 组合包 manifest + +``` +hello-plugin/ +├── package.json # declares dsh.bundle +├── cordis.patch.yml # the layer applied when a profile lists this bundle +└── index.js # plugin modules the patch rows reference +``` + +```json +{ + "name": "dsh-hello-plugin", + "version": "0.1.0", + "type": "module", + "main": "index.js", + "files": ["index.js", "cordis.patch.yml"], + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} +``` + +patch 文件的形状与你一直在写的 `--patch` overlay 相同——一个 patch 条目的 YAML 数组——只是插件行按包名而不是相对源码路径引用这个包,这样 Node 的模块解析才能找到已安装的代码: + +```yaml +- insert: + - id: hello + name: dsh-hello-plugin +``` + +没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。这正是"供插件包 import 的库"应有的形状,区别于"供用户启用的插件"。 + +### profile manifest + +profile 目录包含两个文件: + +- `package.json` — profile 的树外插件依赖(由 pnpm 管理),加上 `dsh.profile` manifest 及其有序的 `bundles` 列表。 +- `cordis.patch.yml` — 用户自己的 patch 层,在每个组合包层之后应用。 + +profile manifest 从不需要手写:`dsh plugin` 负责创建和维护它。下一节展示其结果。 + +## 安装进 profile + +`dsh plugin --profile ` 在 profile 目录内转发给 pnpm,因此所有 pnpm 子命令都可用。从 checkout 安装你的包: + +```sh +cd hello-plugin +dsh plugin --profile demo add . +``` + +首次使用会初始化 profile(`@deepseek-ai/dsh-base` 作为它的第一个组合包),pnpm 链接该 checkout,而 `dsh` 因为这个包声明了 `dsh.bundle`,把它追加进 `dsh.profile.bundles`: + +```json +{ + "name": "dsh-profile-demo", + "private": true, + "dependencies": { + "dsh-hello-plugin": "link:/path/to/hello-plugin" + }, + "dsh": { + "profile": { + "bundles": [ + "@deepseek-ai/dsh-base", + "dsh-hello-plugin" + ] + } + } +} +``` + +先不启动、只验证该层,再启动: + +```sh +dsh --profile demo --dump-config # shows a "# == dsh-hello-plugin" layer +dsh --profile demo +``` + +`dsh plugin --profile demo remove dsh-hello-plugin` 会同时移除依赖和对应的层。 + +## 加载顺序 + +生效配置在空根之上按以下顺序逐层组合: + +1. profile 的 `dsh.profile.bundles` 列表所列的各个组合包 patch,按列表顺序——先是 `@deepseek-ai/dsh-base`,然后是每个已安装组合包,按其加入顺序。 +2. profile 自己的 `cordis.patch.yml`。 +3. home 级的 `$DSH_HOME/cordis.patch.yml`——各 profile 共享的机器本地偏好。 +4. 每个 `--patch ` overlay,按 argv 顺序。 +5. 启动器 flag patch(例如 `dsh web --port`)。 + +后应用的层按行胜出,且 patch 会替换目标行的整个 `config` 值,而不是深度合并各键。这给组合包作者带来两个推论: + +- 你的 patch 可以按 `id` 覆盖前面各层的行——就像 [`dsh-web-app` 组合包](../../../../packages/bundle/web-app/cordis.patch.yml)覆盖 `dsh-base` 的行那样——但必须重述该行需要的每一个键,而不是只写改动的那个。 +- 用户可以在自己 profile 的 `cordis.patch.yml` 中覆盖你的行,无需改动你的包,所以优先给出用户大概率会保留的配置默认值,其余交给 schema 承担。 + +内置组合包名称始终从 dsh 安装目录本身解析;pnpm 只管理树外的包,所以你的组合包可以放心依赖 `@deepseek-ai/dsh-base` 存在且与安装保持一致。 + +## 从 GitHub 安装:构建脚本这道坎 + +发布到注册表不是必须的——用户可以直接从 git 托管安装: + +```sh +dsh plugin --profile demo add github:you/hello-plugin +``` + +但 git 安装拉取的是**源码,不是构建产物**:没有任何环节运行你的 `build` 脚本,因此 TypeScript 包到手时没有 `lib/` 输出,加载会失败。必须两边各做一件事: + +- **作者**提供一个 `prepare` 脚本——pnpm 在 git 安装后运行它——从源码构建出发布入口,且必须自包含:不能假设仅开发环境才有的上下文,例如旁边有一份 monorepo checkout。[turtle-ui](https://github.com/deepseek-harness/turtle-ui) 是一个可用的例子:它的 `prepare` 运行一份专用的 tsdown 配置,直接转译 `src/`,不用项目引用,也不做类型检查。 +- **用户**为构建授权。pnpm ≥10 在得到显式允许之前拒绝运行 git 依赖的 `prepare` 脚本,所以第一次 `add` 会失败;`dsh` 会指出修法——把 pnpm 打印的确切包键复制进该 profile 的 `pnpm-workspace.yaml`: + + ```yaml + allowBuilds: + dsh-hello-plugin: true + ``` + + 然后重新执行 `add`。 + +请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent 运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#`),让后续推送无法悄悄改变实际运行的内容。 + +如果不想让用户做这项授权,就改为分发构建产物——以下两种形式都不需要任何构建权限: + +- **发布到 npm**,在 `pnpm publish` 时构建好 `lib/`;`dsh plugin add your-package` 安装的就是预构建代码。 +- **交付 tarball**:用 `pnpm pack` 打包;用户执行 `dsh plugin add ./hello-plugin-0.1.0.tgz`。 + +## 下一步 + +- [插件与生命周期](../framework/) — 插件的完整生命周期 +- [CLI 行为参考](../../../../apps/cli/reference/README.md) — 确切的层优先级、flag 与 profile 机制 diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index 0aa1bbb4cb..467ab841d6 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/tool.md -tool.md: 93a1a96feba814a564f8800c8e7b865fe9c0cb73 -tool.zh.md: 18e6b9b5d9c17b00c26aa7b98e6ac4531315dc5e +tool.md: 8505bdaf6fcece3235b0302d54e82ee8aed3cbab +tool.zh.md: 1831afa9be74756cb9bb1e96515fefd3685ee4c2 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 93a1a96feb..8505bdaf6f 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -40,7 +40,7 @@ export function apply(ctx: Context) { Restart the development command if it is not running: ```sh -pnpm run dsh web --config ./scratch-plugin/cordis.yml +pnpm run dsh web --patch ./scratch-plugin/cordis.yml ``` Open `http://127.0.0.1:3080` and ask: `Use the greet tool to greet Ada.` The model can call `greet` and receives `Hello, Ada!` as the tool result. diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index 18e6b9b5d9..1831afa9be 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -40,7 +40,7 @@ export function apply(ctx: Context) { 如果开发命令未在运行,请重新启动: ```sh -pnpm run dsh web --config ./scratch-plugin/cordis.yml +pnpm run dsh web --patch ./scratch-plugin/cordis.yml ``` 打开 `http://127.0.0.1:3080`,然后输入:`Use the greet tool to greet Ada.` 模型可以调用 `greet`,并收到 `Hello, Ada!` 这一工具结果。 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 372e0e6c73..172af1bcac 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/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 docs/user/guide/config.md -config.md: ddf4df264e5534fc3b74991941c2f3f82376d53f -config.zh.md: 56ac0146ddae83dbfc86f479030efdb5772a3aaf +config.md: 0f1a99ed0afdab13d052ae2714fc2e1718d3d85e +config.zh.md: 74e14e4e6e1fbb38a7a8d5167a4747080a662128 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index ddf4df264e..0f1a99ed0a 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -8,8 +8,8 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: -- [the shared `dsh` base](../../../apps/cli/config/base.cordis.yml) provides the common model, tools, persistence, policy, and telemetry rows; raw `dsh --config ` requires a patch list that selects deployment-specific agents and front doors. -- [the Web overlay](../../../apps/cli/config/web.cordis.yml) adds the browser host, Workspace management, browser interaction, and client plugins. +- [the `dsh-base` bundle patch](../../../packages/bundle/base/cordis.patch.yml) provides the common model, tools, persistence, policy, and telemetry rows every profile starts from. +- [the `dsh-web-app` bundle patch](../../../packages/bundle/web-app/cordis.patch.yml) adds the browser host, Workspace management, browser interaction, and client plugins. - [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) exposes fresh sessions to programmatic ACP clients. @@ -49,9 +49,9 @@ A minimal configuration is a list of plugin entries: Cordis starts sibling entries concurrently. A plugin declares required services through `inject`; Cordis waits for those services before applying the plugin, so file order does not establish dependency readiness. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. -## CLI overlays +## CLI patch layers -Raw `dsh --config ` requires a patch list and applies it directly over `base.cordis.yml`. It does not add a surface overlay or `~/.dsh/config.yaml`, and the named file is not a complete replacement tree. `dsh web` composes `base.cordis.yml` and `web.cordis.yml`, then applies `~/.dsh/config.yaml`; `dsh web --config ` replaces that personal layer with the named overlay. Web profile and CLI-flag patches follow the user layer. +`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, then each `--patch ` overlay, then CLI-flag patches. Later layers win per row. A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 56ac0146dd..74e14e4e6e 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -8,8 +8,8 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [共享的 `dsh` base](../../../apps/cli/config/base.cordis.yml) 提供通用的模型、工具、持久化、策略与遥测配置项;原始 `dsh --config ` 要求传入一份 patch 列表,用于选择部署特定的 agent 和前端入口。 -- [Web overlay](../../../apps/cli/config/web.cordis.yml) 添加浏览器宿主、Workspace 管理、浏览器交互与客户端插件。 +- [`dsh-base` 组合包补丁](../../../packages/bundle/base/cordis.patch.yml) 提供通用的模型、工具、持久化、策略与遥测配置项,每个 profile 都以此为起点。 +- [`dsh-web-app` 组合包补丁](../../../packages/bundle/web-app/cordis.patch.yml) 添加浏览器宿主、Workspace 管理、浏览器交互与客户端插件。 - [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 向程序化 ACP(Agent Client Protocol)客户端提供全新会话。 @@ -49,9 +49,9 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务;Cordis 会等到这些服务就绪后再应用该插件,因此文件顺序不能保证依赖已就绪。引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 -## CLI 覆盖层 +## CLI 补丁层 -原始 `dsh --config ` 要求传入一份 patch 列表,并将其直接应用在 `base.cordis.yml` 之上。它不会添加 surface overlay 或 `~/.dsh/config.yaml`,指定文件也不是完整替换树。`dsh web` 先组合 `base.cordis.yml` 与 `web.cordis.yml`,再应用 `~/.dsh/config.yaml`;`dsh web --config ` 会以指定 overlay 替代该个人层。Web profile 与 CLI(命令行界面)标志 patch 位于用户层之后。 +`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、每个 `--patch ` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 2cf494f71a..45ace4d681 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 199b3f092159fa6fbaf3ae298151487c924ac6f1 -quickstart.zh.md: 9327ed646ba211bcce6426beb6bf76fca50acbf6 +quickstart.md: 4b43291342f80ff9c6dcb844fdc505ef797d1f6c +quickstart.zh.md: 13294c50c2d7d4005c0fd090cc2eca3c4780d0a6 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 199b3f0921..4b43291342 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -53,7 +53,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## What happened -headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead composes [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) with [`apps/cli/config/web.cordis.yml`](../../../apps/cli/config/web.cordis.yml) and no app bundle. Both select the DeepSeek model and capability plugins appropriate to their entry mode. +headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead boots the `web` profile: the [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) bundle patch layers composed over an empty root. Both select the DeepSeek model and capability plugins appropriate to their entry mode. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 9327ed646b..13294c50c2 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -53,7 +53,7 @@ pnpm run dsh web ## 回头看 -headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) 与 [`apps/cli/config/web.cordis.yml`](../../../apps/cli/config/web.cordis.yml),不使用 app 组合包。二者都会根据各自入口模式选择 DeepSeek 模型和能力插件。 +headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则启动 `web` profile:由 [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 两个组合包的 patch 层在空根之上组合而成。二者都会根据各自入口模式选择 DeepSeek 模型和能力插件。 ## 下一步 diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index 870762db51..f89035cfcc 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: b5dd7ffc4ad248d38e108d9aa28c7c26e0c76913 -README.zh.md: ea27dc1a5bd644de13d4ecad8afcae3a7452160e +README.md: 023e6aefce0e78cbbf52620426376e1dd0a6b8cf +README.zh.md: 44ace680cd583f41903437a69c62e30817308ba2 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index b5dd7ffc4a..023e6aefce 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -25,10 +25,10 @@ The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` vari Pass one overlay to DSH: ```sh -dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--config` keeps all three disabled. +Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--patch` keeps all three disabled. Without a repository checkout, download the selected overlay directly: @@ -37,12 +37,12 @@ mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml -dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" +dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. -To keep the selection in personal configuration, merge the chosen file's single `insert` patch into `$DSH_HOME/config.yaml` (normally `~/.dsh/config.yaml`). Do not copy over an existing file: it may already contain unrelated personal patches. +To keep the selection across runs, merge the chosen file's single `insert` patch into a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml` for one profile, or `$DSH_HOME/cordis.patch.yml` for every profile on the machine. Do not copy over an existing file: it may already contain unrelated user patches. ## Provider setup @@ -50,7 +50,7 @@ To keep the selection in personal configuration, merge the chosen file's single ```sh npm install --global memorix@1.3.0 -dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` Memorix works in local heuristic mode without an LLM or embedding service. Configure optional providers in Memorix's own `~/.memorix/config.toml` or project `memorix.toml`. The example keeps Memorix's Git-project identity from the DSH working directory and uses Memorix's own `~/.memorix/data` default. Set `MEMORIX_DATA_DIR` before starting DSH to override it. @@ -59,7 +59,7 @@ Memorix works in local heuristic mode without an LLM or embedding service. Confi ```sh npm install --global @modelcontextprotocol/server-memory@2026.7.4 -dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" ``` This reference server stores a local knowledge graph and exposes entity, relation, observation, read, search, and open tools. It needs no model or embedding service. The example stores its JSONL at `$HOME/.dsh-mcp-reference-memory.jsonl` instead of the installed npm package directory. Set `MEMORY_FILE_PATH` before starting DSH to override it. @@ -70,7 +70,7 @@ Search is case-insensitive substring matching over entity names, types, and obse ```sh go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 -dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/engram.cordis.yml" ``` Engram owns storage and project selection: it uses `~/.engram` by default, detects the Git project from the DSH working directory, and accepts `ENGRAM_DATA_DIR` or `ENGRAM_PROJECT` as ambient overrides. diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index ea27dc1a5b..44ace680cd 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -25,10 +25,10 @@ stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据 将一份 overlay 传给 DSH: ```sh -dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--config` 就会让这三项全部保持关闭。 +请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--patch` 就会让这三项全部保持关闭。 如果本地没有仓库 checkout,可直接下载所选 overlay: @@ -37,12 +37,12 @@ mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml -dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" +dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` 若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 -如果要把所选配置保存在个人配置中,请将对应文件中的单个 `insert` patch 合并到 `$DSH_HOME/config.yaml`(通常是 `~/.dsh/config.yaml`)。不要覆盖已有文件,其中可能已经包含无关的个人 patch。 +如果要跨次运行保留所选配置,请将对应文件中的单个 `insert` patch 合并到用户 patch 层:只对一个 profile 生效则写入 `$DSH_HOME/profiles//cordis.patch.yml`,对本机所有 profile 生效则写入 `$DSH_HOME/cordis.patch.yml`。不要覆盖已有文件,其中可能已经包含无关的用户 patch。 ## 提供方设置 @@ -50,7 +50,7 @@ dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ```sh npm install --global memorix@1.3.0 -dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启发式模式下运行。请在 Memorix 自己的 `~/.memorix/config.toml` 或项目 `memorix.toml` 中配置可选提供方。该示例沿用 DSH 工作目录中的 Git 项目标识,并使用 Memorix 自身的默认目录 `~/.memorix/data`。若要覆盖该目录,请在启动 DSH 前设置 `MEMORIX_DATA_DIR`。 @@ -59,7 +59,7 @@ Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启 ```sh npm install --global @modelcontextprotocol/server-memory@2026.7.4 -dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" ``` 该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 JSONL 存储在 `$HOME/.dsh-mcp-reference-memory.jsonl`,而不是已安装的 npm 包目录中。若要覆盖该路径,请在启动 DSH 前设置 `MEMORY_FILE_PATH`。 @@ -70,7 +70,7 @@ dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" ```sh go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 -dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" +dsh web --patch "$PWD/examples/mcp-memory/engram.cordis.yml" ``` Engram 负责存储和项目选择:它默认使用 `~/.engram`,从 DSH 工作目录检测 Git 项目,并接受 `ENGRAM_DATA_DIR` 或 `ENGRAM_PROJECT` 作为环境覆盖项。 diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml index ff857643f4..27d905676e 100644 --- a/examples/web-cordis/cordis.yml +++ b/examples/web-cordis/cordis.yml @@ -1,21 +1,16 @@ # Opt-in Web composition for inspecting the self-referential Cordis tools. # Temporary Plugin code can reach every injected live capability; treat this # deployment like shell access, not as a security boundary. -# This file is an OVERLAY over the shipped web composition (`base.cordis.yml` + -# `web.cordis.yml`), not a tree: `dsh web --config` applies it as one more -# sibling patch list at the same include level, so these patches reach base and -# overlay rows alike. A patch replaces the targeted row's whole `config`. +# This file is a PATCH OVERLAY over the web profile (dsh-base + dsh-web-app +# bundle layers), not a tree: `dsh web --patch` applies it as one more sibling +# patch list at the same include level, so these patches reach every bundle +# row. A patch replaces the targeted row's whole `config`. -# AppCLIEntry normally injects the assembly-owned dist path before `dsh web` -# boots; pinning the port here keeps this demo off the default 3080. +# Pinning the port here keeps this demo off the default 3080. - id: webserver config: host: 127.0.0.1 port: 3081 - # Plain concatenation, not URL.pathname: a cwd with spaces - # percent-encodes through the URL round-trip and the encoded - # path never resolves. - distIndex: !!js "process.cwd() + '/apps/web/dist/index.html'" - insert: - id: tool-cordis diff --git a/knip.json b/knip.json index b16e085723..0430d4b1ed 100644 --- a/knip.json +++ b/knip.json @@ -669,6 +669,17 @@ "src/**/*.ts", "tests/**/*.ts" ] + }, + "packages/bundle/base": { + "ignoreDependencies": [ + "@deepseek-ai/.+", + "@cordisjs/.+" + ] + }, + "packages/bundle/web-app": { + "ignoreDependencies": [ + "@deepseek-ai/.+" + ] } } } diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 88bcc06368..e721814e79 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: 4832fffbc8963b8a7b1f8332e691083195bf94bc -README.zh.md: 076b4f877070fcf0ee6b98d2310d1121cbbe63d6 +README.md: dec4d71ca2d323fe05f918dd3bf4709cfa01878e +README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009 diff --git a/packages/README.md b/packages/README.md index 4832fffbc8..dec4d71ca2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,9 +31,10 @@ Packages live at `packages///`; groups are containers, while names r | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | | [`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) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface | +| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface | +| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface | +| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | +| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, 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 076b4f8770..9596dfe8bf 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -31,9 +31,10 @@ | [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | -| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 | -| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 | -| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin,以及受限 repository Plugin 加载 | 产品:稳定表面 | +| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 | +| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 | +| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 | +| [`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/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml new file mode 100644 index 0000000000..27e50409cd --- /dev/null +++ b/packages/bundle/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/bundle/README.md +README.md: 4759170435a80e85731446cef21d24fff2abed66 +README.zh.md: 1ef610a1b7b3c591c9a900e04f2d8096b0b086b9 diff --git a/packages/bundle/README.md b/packages/bundle/README.md new file mode 100644 index 0000000000..4759170435 --- /dev/null +++ b/packages/bundle/README.md @@ -0,0 +1,13 @@ +# bundle/ — profile plugin bundles + +English | [中文](README.zh.md) + +Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../ui/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts. + +| Package | Role | ctx key | +|---|---|---| +| [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) | +| [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows | +| [`headless/`](headless/README.md) | One-shot task mode over base + web-app | mounts `headless-runner` | + +In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile add `. diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md new file mode 100644 index 0000000000..1ef610a1b7 --- /dev/null +++ b/packages/bundle/README.zh.md @@ -0,0 +1,13 @@ +# bundle/ — profile 插件组合包 + +[English](README.md) | 中文 + +Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 契约](../ui/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。 + +| 包 | 职责 | ctx key | +|---|---|---| +| [`base/`](base/README.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) | +| [`web-app/`](web-app/README.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 | +| [`headless/`](headless/README.md) | 叠加在 base + web-app 之上的一次性任务模式 | 挂载 `headless-runner` | + +内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile add ` 安装进 profile。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml new file mode 100644 index 0000000000..2ae7df0bdc --- /dev/null +++ b/packages/bundle/base/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/bundle/base/README.md +README.md: 301d397d4c87687b382665cf63af47ab5e3f85be +README.zh.md: f007bc817b6cbad84725fe8abe72549cf67d8cd7 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md new file mode 100644 index 0000000000..301d397d4c --- /dev/null +++ b/packages/bundle/base/README.md @@ -0,0 +1,19 @@ +# `@deepseek-ai/dsh-base` + +English | [中文](README.zh.md) + +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. + +The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. + +## Model Experience + +Indirectly, through the inserted rows: this bundle selects the shipped persona-less prompt base, tool set, and DeepSeek adapter that mode bundles specialize, and contributes no model-visible text of its own. + +#### KV Cache effect + +None directly; each inserted row's package owns its effect. + +## Known Limitations and Deferred Work + +- **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md new file mode 100644 index 0000000000..f007bc817b --- /dev/null +++ b/packages/bundle/base/README.zh.md @@ -0,0 +1,19 @@ +# `@deepseek-ai/dsh-base` + +[English](README.md) | 中文 + +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 + +行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 + +## 模型体验 + +通过插入的行间接产生影响:该组合包选定了随发行版交付的无 persona 提示词基座、工具集合与 DeepSeek 适配器,供各模式组合包进一步特化;它自身不贡献任何模型可见文本。 + +#### KV Cache 影响 + +无直接影响;每条插入行的影响归其所属的包负责。 + +## 已知限制与延期工作 + +- **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml new file mode 100644 index 0000000000..c09c7da39c --- /dev/null +++ b/packages/bundle/base/cordis.patch.yml @@ -0,0 +1,404 @@ +# The dsh-base bundle patch: the shared core of every dsh profile, applied as +# ONE insert over the empty profile root. Later bundle patches and the user's +# profile cordis.patch.yml address these rows by id, with the last write +# winning per row. +# +# A patch replaces the targeted row's whole `config` rather than merging into +# it, so a row whose value differs by mode does NOT live here: it belongs to +# each mode bundle, keeping any single row down to one bundle layer plus the +# user's. Mode-specific rows appear below only with shared plugin identity and +# neutral defaults; each mode bundle restates its complete configuration. +# +# Row order carries no load semantics (activation is service-availability +# driven); the grouping is for readers. + +- insert: + - id: timer + name: '@cordisjs/plugin-timer' + + - id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + + # The profile's cordis.patch.yml 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' + + - id: session + name: '@deepseek-ai/dsh-session' + + - id: session-title + name: '@deepseek-ai/dsh-session-title' + config: + fallbackMaxWords: 5 + fallbackMaxBytes: 40 + maxTitleBytes: 80 + + - id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + + - id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + + - id: agent + name: '@deepseek-ai/dsh-agent' + + - id: tasks + name: '@deepseek-ai/dsh-tasks-local' + + - id: llm-retry + name: '@deepseek-ai/dsh-llm-retry' + + # User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a + # `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries + # below without a restart, and is what the web Models page writes. + - id: settings + name: '@deepseek-ai/dsh-settings-local' + + # Credential store: the live process environment over `$DSH_HOME/.env` + # (owner-only file, hot-reloaded). Adapters resolve their key references + # through it at each request, so no key is inlined in this file. The web + # Models page's key inputs write it through `credentials.set`; nothing hoists + # the document into the process environment, which would make every stored key + # read as an unrotatable ambient override. + - id: credentials + name: '@deepseek-ai/dsh-credentials-local' + + # The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra + # models in the picker) until a `llm-pi-ai:` settings section supplies provider + # profiles — then those routes register live, keys resolving per request + # through their apiKeyEnv references, and drop again when the section empties. + # Supplying those profiles is exactly what the web Models page does. Which + # adapters exist is composition; which providers run is the user's settings + # document. + - id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + + - id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + + # Raw configs can supply a process-local path or disable this shared session + # capability. The neutral default is process-local and opens only when used. + - id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: ':memory:' + openAt: first-search + + # Session telemetry, on for every dsh mode: mirrors every session-log + # event (assistant/chunk projected to first-of-step) plus ops markers onto + # OTLP/HTTP log records, streaming on the batch processor's cadence + # (10s/batch here) — not at exit; a crash loses at most the last unexported + # interval. No telemetry/record redaction rule is mounted yet, so exports + # are the raw captured copy; the deployment stance, env seams, and + # follow-ups are pinned in the web-telemetry-default-mount Agent Note. + # DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty + # DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the + # process out (the launchers patch the row disabled; config cannot disable + # a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid, + # random UUID; delete the file to reset the identity) as the Resource's + # user.id. The exporter/processor values normally bound the shutdown drain + # to ~1s against an unreachable collector: exporter.timeoutMillis is both + # the per-attempt socket timeout and the retry deadline (1s effectively + # disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize + # (both explicit) makes the drain a single batch. The SDK awaits + # exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s + # shutdownTimeoutMillis is the load-bearing outer bound when a transport + # promise never settles. Every CLI exit path drains it by disposing the root + # on SIGINT/SIGTERM. + - id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + shutdownTimeoutMillis: 3000 + exporter: + url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' + compression: gzip + timeoutMillis: 1000 + processor: + scheduledDelayMillis: 10000 + maxQueueSize: 2048 + maxExportBatchSize: 2048 + exportTimeoutMillis: 1500 + + - id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + + # Every shipped CLI mode starts with the same file-effect boundary. + # The environment remains an explicit deployment override; otherwise fresh + # sessions pin workspace-write + ask through the permission service below. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() + + - id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + config: + timeoutMs: 60000 + + - id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'" + + - id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never + + - id: bash-env + name: '@deepseek-ai/dsh-bash-env' + + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + + - id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + + - id: skill + name: '@deepseek-ai/dsh-skill' + + - id: skill-local + name: '@deepseek-ai/dsh-skill-local' + + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + + - id: commands + name: '@deepseek-ai/dsh-commands' + + - id: goal + name: '@deepseek-ai/dsh-goal' + + - id: goal-session + name: '@deepseek-ai/dsh-goal-session' + + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + # Human `/compact`: one useful reduction below the automatic threshold. Backend + # independent, so it follows whichever compaction service this leaf mounts. + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: subagent + name: '@deepseek-ai/dsh-subagent' + + - id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + + - id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + + # Continuable background children are selected per delegation tool. The + # separately loaded follow-up tool registers the one global `send_message`. + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + # Optional direct-child return channel; absent from roots and one-shot agents. + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + + - id: spill-local + name: '@deepseek-ai/dsh-spill-local' + + - id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 + + # Durability checkpoints before each model request and top-level dispatch. + - id: session-checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' + + # Compacts oversized tool results before the broader conversation compactor + # runs, preserving the model-visible result within the configured budget. + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + + # Persisted same-session goals reach the model and the slash menu here; the + # domain, driver, and `/goal` command are above. + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + + # Fresh-agent Ralph iteration over a build-time-fixed script. + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + + - id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + + # Consecutive-repeat reminders on the tool chain. + - id: repeat-tool-guard + name: '@deepseek-ai/dsh-repeat-tool-guard' + config: + thresholds: [3, 5, 8] + argumentsPreviewChars: 500 + + # Every mode enables the stable web_search model surface. DeepSeek search + # resolves the same DEEPSEEK_API_KEY credential the Models page manages for + # chat, at each search; its Messages endpoint is separate from the + # chat-completions endpoint, so it takes its own base-URL override. Fetch stays + # disabled and no fetch provider is mounted: that provider defers SSRF + # protection and the model would choose the request target. Search is a full + # auxiliary model request with server-side retrieval, so this shipped DeepSeek + # route gets 60s while the provider-neutral tool default remains 30s. + - id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: deepseek-official + + - id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKeyEnv: DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL + + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + + # ── rows every mode mounts, whose values each overlay may state ────────────── + + # The tool registry. Presentation mode is a deployment choice; omitting it here + # keeps the schema default (native). + - id: tools + name: '@deepseek-ai/dsh-tools' + + # The deployment persona is a deployment choice; plan-mode and tool plugins own + # their own prompt sections. + - id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: '' + + # Agents created at startup. The base stays empty; raw overlays may create + # agents, while Web creates sessions on client request. + - id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: [] + + # The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; an + # overlay can pin another workspace. + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + + # The native DeepSeek adapter. No key or endpoint is inlined: both resolve per + # request from the `llm-deepseek:` settings section over this entry, with the + # key coming from the credential store below. Thinking defaults are a deployment + # choice. + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json new file mode 100644 index 0000000000..95e169cabb --- /dev/null +++ b/packages/bundle/base/package.json @@ -0,0 +1,111 @@ +{ + "name": "@deepseek-ai/dsh-base", + "description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@cordisjs/plugin-hmr": "workspace:*", + "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "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-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", + "@deepseek-ai/dsh-settings-local": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-spill-local": "workspace:^", + "@deepseek-ai/dsh-spill-policy": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bundle/base/src/index.ts b/packages/bundle/base/src/index.ts new file mode 100644 index 0000000000..ca7d8f9526 --- /dev/null +++ b/packages/bundle/base/src/index.ts @@ -0,0 +1,9 @@ +/** + * @deepseek-ai/dsh-base — the shared dsh core as a profile bundle. The + * package's substance is `cordis.patch.yml`, declared by the `dsh.bundle.patch` + * manifest field and resolved by the profile composer through that field; + * this module carries no runtime API. + * @module @deepseek-ai/dsh-base + */ + +export {} diff --git a/packages/bundle/base/src/invariant.ts b/packages/bundle/base/src/invariant.ts new file mode 100644 index 0000000000..65365fb193 --- /dev/null +++ b/packages/bundle/base/src/invariant.ts @@ -0,0 +1,28 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-base`. + * @module @deepseek-ai/dsh-base/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-base' + +/** Cordis companion plugin name. */ +export const name = 'base-bundle-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +// No runtime invariant: the package is a static patch-list carrier (a YAML +// document of loader rows owned by other packages); it mounts no service, +// emits no events, and owns no mutable relation to check. Each inserted row's +// own package carries that row's invariants. +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)) diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts new file mode 100644 index 0000000000..24ee2a1ba3 --- /dev/null +++ b/packages/bundle/base/tests/base.spec.ts @@ -0,0 +1,25 @@ +/** + * The bundle's substance is its patch file: the `dsh.bundle.patch` manifest + * field must name a real, parseable patch list. + */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@cordisjs/plugin-include' + +describe('dsh-base bundle', () => { + it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }) + expect(Array.isArray(parsed)).toBe(true) + // The base layer is one insert list over the empty profile root. + const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? []) + expect(rows.length).toBeGreaterThan(50) + expect(rows.some(row => row.id === 'agent-loop')).toBe(true) + }) +}) diff --git a/packages/bundle/base/tsconfig.json b/packages/bundle/base/tsconfig.json new file mode 100644 index 0000000000..e1c893a8fc --- /dev/null +++ b/packages/bundle/base/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml new file mode 100644 index 0000000000..08e4a5a5b5 --- /dev/null +++ b/packages/bundle/headless/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/bundle/headless/README.md +README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca +README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md new file mode 100644 index 0000000000..d08fb08e2a --- /dev/null +++ b/packages/bundle/headless/README.md @@ -0,0 +1,18 @@ +# `@deepseek-ai/dsh-headless` + +English | [中文](README.zh.md) + +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row. + +## Model Experience + +None, as the runner submits the task as an ordinary user message over the shared composition; prompts and tools belong to the base/web bundles. + +#### KV Cache effect + +None; the runner adds nothing to the request prefix. + +## Known Limitations and Deferred Work + +- **One turn only** — the runner anchors on the first message-triggered turn and exits at its end; queued follow-ups and multi-turn tasks are out of scope. +- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the seam. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md new file mode 100644 index 0000000000..99a64ef04c --- /dev/null +++ b/packages/bundle/headless/README.zh.md @@ -0,0 +1,18 @@ +# `@deepseek-ai/dsh-headless` + +[English](README.md) | 中文 + +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败。 + +## 模型体验 + +无。runner 把任务作为普通用户消息经共享组合提交;提示词与工具归 base/web 组合包所有。 + +#### KV Cache 影响 + +无;runner 不向请求前缀添加任何内容。 + +## 已知限制与延期工作 + +- **只运行一个轮次**:runner 锚定第一个由消息触发的轮次,并在其结束时退出;排队的后续消息与多轮任务不在范围内。 +- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时大声失败,直到宿主提供该 seam。 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml new file mode 100644 index 0000000000..5801a20863 --- /dev/null +++ b/packages/bundle/headless/cordis.patch.yml @@ -0,0 +1,21 @@ +# The dsh-headless bundle patch: one-shot task mode over dsh-base + +# dsh-web-app. The web composition stays mounted (the session is observable +# in a browser while it runs); this layer silences the URL line and the +# GUI-orientation surface context (this user is not in the GUI), moves the +# webserver to an OS-assigned port so parallel headless runs never collide, +# and mounts the one-shot runner. The launcher patches the runner's `task`. + +- id: webserver + config: + host: 127.0.0.1 + port: 0 + +- id: web-runtime + config: + mode: production + printUrl: false + surfaceContext: false + +- insert: + - id: headless-runner + name: '@deepseek-ai/dsh-headless' diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json new file mode 100644 index 0000000000..2b28423168 --- /dev/null +++ b/packages/bundle/headless/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-headless", + "description": "The dsh one-shot bundle: a patch layer over dsh-base + dsh-web-app plus the runner plugin driving one task turn through the in-process API carrier", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-host-apiproxy": "^0.0.1", + "@deepseek-ai/dsh-host-webserver": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts new file mode 100644 index 0000000000..572f11b487 --- /dev/null +++ b/packages/bundle/headless/src/index.ts @@ -0,0 +1,173 @@ +/** + * @deepseek-ai/dsh-headless — the one-shot headless bundle: the bundle patch + * (`cordis.patch.yml`) rides over dsh-base + dsh-web-app (the headless + * session is web-observable while it runs — same composition), and this + * runner plugin drives one task through the in-process API carrier + * (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire + * chain — serialization, zod, SSE framing — really runs), prints the final + * assistant text at agent quiescence, and exits (completed → 0, else 1). The + * task text arrives as launcher-patched config + * (`dsh --profile headless "task"`). + * @module @deepseek-ai/dsh-headless + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +// Empty type imports carry the httpServer and agent/status Context merges used below. +import type {} from '@deepseek-ai/dsh-host-webserver' +import type {} from '@deepseek-ai/dsh-agent' +// Empty type import carries the loader Context merge for the settlement await. +import type {} from '@cordisjs/plugin-loader' +import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Stable Cordis plugin name. */ +export const name = 'headless-runner' + +/** Services required before the one-shot turn can start. */ +export const inject = ['apiProxy', 'httpServer'] + +/** Plugin config: the task, patched in by the launcher. */ +export interface Config { + /** The prompt text for the single turn. */ + task: string +} + +export const Config: z = z.object({ + task: z.string().required(), +}) + +/** Outcome of one headless run: aggregated final text plus the last turn-end reason kind. */ +interface TurnOutcome { + text: string + reason: string +} + +/** + * The process-facing effects of one run, injectable for tests: output + * streams and the exit request (the launcher wires it to its bounded + * shutdown controller). + */ +export interface HeadlessIo { + stdout: { write(chunk: string): unknown } + stderr: { write(chunk: string): unknown } + /** Request process exit with `code` after the tree disposes. */ + exit(code: number): void +} + +/** Host seam: the launcher provides the exit wiring before the tree mounts. */ +declare module 'cordis' { + interface Context { + /** Process-facing effects for the one-shot headless runner. */ + headlessIo?: HeadlessIo + } +} + +/** Unwrap an RpcResponse or fail loud: business errors print and exit 1. */ +async function unwrap(response: RpcResponse, io: HeadlessIo): Promise { + if (response.result.ok) return response.result.value + const { code, message } = response.result.error + io.stderr.write(`dsh: ${code}: ${message}\n`) + io.exit(1) + // Exit is asynchronous (bounded tree disposal); park this turn forever so + // no further request rides a session that is already being torn down. + return new Promise(() => {}) +} + +/** + * Consume mux frames until the agent reaches idle, per the one-shot CLI + * idle-to-idle contract: the stream opens immediately before the prompt, and + * its first observed turn/start begins the task. Text is the last committed + * assistant message of the whole interval (steering or injected work may run + * further turns before quiescence), and the outcome reason is the final + * turn/end's kind. Idleness is signalled out of band by the caller's + * `agent/status` subscription; the stream itself carries no status frame. + * @param frames - the mux stream opened before the prompt. + * @param sessionId - the headless session. + * @param idle - resolves when the agent reaches quiescence. + * @param io - process-facing effects for stream diagnostics. + * @returns the aggregated outcome. + */ +async function consumeUntilIdle( + frames: AsyncIterable>, + sessionId: SessionId, + idle: Promise, + io: HeadlessIo, +): Promise { + let started = false + let text = '' + let reason: string = 'error' + void (async () => { + try { + for await (const frame of frames) { + const payload = frame.payload + if (payload.type === 'stream/error') return + if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue + const event = payload.event + if (event.type === 'turn/start') { + started = true + continue + } + if (!started) continue + if (event.type === 'assistant/message') { + const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') + if (joined !== '') text = joined + } + if (event.type === 'turn/end') reason = event.data.reason.kind + } + } catch (error: unknown) { + io.stderr.write(`dsh: event stream failed: ${String(error)}\n`) + } + })() + await idle + return { text, reason } +} + +/** + * Run one headless task to quiescence and request exit (completed → 0, else 1). + * @param ctx - plugin context carrying apiProxy, httpServer, and the launcher's headlessIo. + * @param config - validated {@link Config}. + */ +export function apply(ctx: Context, config: Config): void { + const io = ctx.headlessIo + if (io === undefined) { + throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts') + } + // Fire-and-forget by design: the run outlives plugin activation, and every + // failure path inside ends in io.exit, not a rejection. + void (async () => { + // The Loader mounts sibling rows concurrently and this plugin's inject + // gate covers only apiProxy/httpServer; prompting before the agent loop, + // adapters, and tools settle would fail the turn on a half-mounted tree. + // The old launcher ran strictly after settled boot — preserve that. + // A tree disposed mid-settlement (early SIGTERM) has nothing to run. + await ctx.get('loader')?.await() + if (ctx.get('httpServer') === undefined) return + // The headless session is web-observable while it runs (same composition). + io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`) + const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) + const created = await unwrap(await api.sessions.create({}), io) + // Open the stream before prompting so no frame is lost. The quiescence + // anchor below is an in-process ctx subscription, so a remote-carrier + // port of this runner must replace it with a wire-visible idle signal. + const abort = new AbortController() + const frames = api.events.mux({}, abort.signal) + const idle = new Promise((resolve) => { + ctx.on('agent/status', ({ agent, status }) => { + if (agent.id === created.sessionId && status === 'idle') resolve() + }) + }) + const done = consumeUntilIdle(frames, created.sessionId, idle, io) + await unwrap(await api.sessions.prompt({ + sessionId: created.sessionId, + mode: 'queue', + content: [{ type: 'text', text: config.task }], + }), io) + const outcome = await done + io.stdout.write(outcome.text + '\n') + abort.abort() + io.exit(outcome.reason === 'completed' ? 0 : 1) + })() +} diff --git a/packages/bundle/headless/src/invariant.ts b/packages/bundle/headless/src/invariant.ts new file mode 100644 index 0000000000..91e4925aa1 --- /dev/null +++ b/packages/bundle/headless/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-headless`. + * @module @deepseek-ai/dsh-headless/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-headless' + +/** Cordis companion plugin name. */ +export const name = 'headless-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the runner is a one-shot driver over the API carrier + * whose observable contract (final text on stdout, exit code by turn-end + * reason) is process-level and owned by the launcher e2e; it registers + * nothing and holds no mutable relation to audit inside the tree. + */ +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)) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts new file mode 100644 index 0000000000..6ff96619ff --- /dev/null +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -0,0 +1,218 @@ +/** + * One-shot runner behavior over a scripted in-process API: idle-to-idle + * aggregation (last text of the whole interval), exit-code mapping by the + * final turn-end reason, stream-error and RPC-error paths, and the + * launcher-owned `ctx.headlessIo` requirement. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { apply, Config, type HeadlessIo } from '../src/index.ts' + +interface ScriptedEvent { type: string; seq?: number; time?: number; sessionId?: string; data: Record } + +let nextSeq = 0 +/** Stamp the envelope fields the wire schema requires. */ +function stamped(event: ScriptedEvent): ScriptedEvent { + nextSeq += 1 + return { seq: nextSeq, time: nextSeq, ...event } +} + +interface RpcShapedRequest { rpcId: string } + +/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */ +function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown { + return { + sessions: { + create: (request: RpcShapedRequest) => + Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), + prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true + // A code from the closed wire union: the carrier schema rejects invented codes. + ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } + : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }), + }, + events: { + mux: async function* () { + for (const event of events) { + if (event.type === 'stream/error') { + yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } } + continue + } + const { sessionId = 'S1', ...rest } = event + yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } } + } + }, + }, + } +} + +/** + * Mount the runner against a scripted API, emit the idle transition after the + * scripted frames drain, and wait for its exit request. + */ +async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> { + const ctx = new Context() + let out = '' + let err = '' + const exited = new Promise((resolve) => { + const io: HeadlessIo = { + stdout: { write: (chunk: string) => { out += chunk; return true } }, + stderr: { write: (chunk: string) => { err += chunk; return true } }, + exit: resolve, + } + ctx.provide('headlessIo', io) + }) + ctx.provide('apiProxy', scriptedApi(events, options) as never) + ctx.provide('httpServer', { port: 12345 } as never) + apply(ctx, { task: 'do the thing' }) + // Quiescence is out of band: give the scripted stream a beat to drain, then + // flip the agent idle exactly as the loop would. Foreign agents and + // non-idle transitions must not settle the run. + await new Promise(resolve => setTimeout(resolve, 10)) + ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) + ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + const code = await exited + await ctx.fiber.dispose() + return { code, out, err } +} + +const startupTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 0, trigger: { kind: 'startup' } } } +const messageTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } } +const text = (turn: number, value: string): ScriptedEvent => ({ + type: 'assistant/message', + data: { turn, message: { content: [{ type: 'text', text: value }] } }, +}) +const end = (turn: number, reason: string): ScriptedEvent => ({ type: 'turn/end', data: { turn, reason: { kind: reason } } }) + +describe('headless runner', () => { + it('aggregates to quiescence: last text wins across turns, final turn-end reason maps to exit 0', async () => { + const { code, out, err } = await run([ + // Frames before the first turn/start are outside the task interval. + { type: 'assistant/message', data: { turn: 0, message: { content: [{ type: 'text', text: 'pre-task noise' }] } } }, + startupTurn, + // Off-session, non-text, and text-empty frames never affect the aggregate. + { type: 'assistant/message', sessionId: 'OTHER', data: { turn: 1, message: { content: [{ type: 'text', text: 'other session' }] } } }, + { type: 'assistant/message', data: { turn: 1, message: { content: [{ type: 'tool_call', text: 'ignored' }] } } }, + text(0, 'draft'), + end(0, 'completed'), + messageTurn, + text(1, 'final answer'), + end(1, 'completed'), + ]) + expect(code).toBe(0) + expect(out).toBe('final answer\n') + expect(err).toContain('observing at http://127.0.0.1:12345') + }) + + it('exits 1 when the final turn ends for any other reason', async () => { + const { code } = await run([messageTurn, end(1, 'aborted')]) + expect(code).toBe(1) + }) + + it('exits 1 when no turn ever starts (idle without work)', async () => { + const { code, out } = await run([]) + expect(code).toBe(1) + expect(out).toBe('\n') + }) + + it('keeps the error outcome after a stream error ends the frame consumer early', async () => { + const { code } = await run([messageTurn, { type: 'stream/error', data: {} }, end(1, 'completed')]) + // The consumer stopped at the stream error; the completed turn-end after + // it is never observed, so the reason stays 'error'. + expect(code).toBe(1) + }) + + it('prints an RPC business error and exits 1 without waiting for idle', async () => { + const ctx = new Context() + let err = '' + const exited = new Promise((resolve) => { + ctx.provide('headlessIo', { + stdout: { write: () => true }, + stderr: { write: (chunk: string) => { err += chunk; return true } }, + exit: resolve, + } satisfies HeadlessIo) + }) + ctx.provide('apiProxy', scriptedApi([messageTurn, end(1, 'completed')], { promptFails: true }) as never) + ctx.provide('httpServer', { port: 1 } as never) + apply(ctx, { task: 't' }) + expect(await exited).toBe(1) + expect(err).toContain('agent-busy') + await ctx.fiber.dispose() + }) + + it('reports the stream-failed diagnostic when the event channel dies, still settling at idle', async () => { + const ctx = new Context() + let err = '' + const exited = new Promise((resolve) => { + ctx.provide('headlessIo', { + stdout: { write: () => true }, + stderr: { write: (chunk: string) => { err += chunk; return true } }, + exit: resolve, + } satisfies HeadlessIo) + }) + ctx.provide('apiProxy', { + sessions: { + create: (request: RpcShapedRequest) => + Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), + prompt: (request: RpcShapedRequest) => + Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }), + }, + events: { + // Synchronous throw: the SSE response never forms, so the client-side + // iterable rejects — the runner's own catch path, not a carrier frame. + mux: () => { throw new Error('channel exploded') }, + }, + } as never) + ctx.provide('httpServer', { port: 1 } as never) + apply(ctx, { task: 't' }) + await new Promise(resolve => setTimeout(resolve, 10)) + ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + expect(await exited).toBe(1) + expect(err).toContain('event stream failed') + await ctx.fiber.dispose() + }) + + it('waits for Loader settlement and abandons the run when the tree died during it', async () => { + const ctx = new Context() + let err = '' + let exited = false + ctx.provide('headlessIo', { + stdout: { write: () => true }, + stderr: { write: (chunk: string) => { err += chunk; return true } }, + exit: () => { exited = true }, + } satisfies HeadlessIo) + ctx.provide('apiProxy', scriptedApi([]) as never) + // The webserver is provided by a child fiber whose disposal (early + // SIGTERM during the boot window) removes the service; settlement + // resolves only afterwards, and the runner must abandon rather than + // crash on the torn-down port read. + const webserverFiber = ctx.plugin((childCtx: Context) => { + childCtx.provide('httpServer', { port: 1 } as never) + }) + await webserverFiber + let release: () => void + const settlement = new Promise((resolve) => { release = resolve }) + ctx.provide('loader', { await: () => settlement } as never) + apply(ctx, { task: 't' }) + await webserverFiber.dispose() + release!() + await new Promise(resolve => setTimeout(resolve, 10)) + expect(err).toBe('') + expect(exited).toBe(false) + await ctx.fiber.dispose() + }) + + it('fails loud without the launcher-owned headlessIo seam', () => { + const ctx = new Context() + ctx.provide('apiProxy', scriptedApi([]) as never) + ctx.provide('httpServer', { port: 1 } as never) + expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo') + }) + + it('validates config: the task is required', () => { + expect(() => new Config({ } as never)).toThrow() + expect(new Config({ task: 'x' })).toEqual({ task: 'x' }) + }) +}) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json new file mode 100644 index 0000000000..7894985500 --- /dev/null +++ b/packages/bundle/headless/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../host/apiproxy" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml new file mode 100644 index 0000000000..b48d8c59f3 --- /dev/null +++ b/packages/bundle/web-app/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/bundle/web-app/README.md +README.md: 95cdbc9694b44539742e5b871157eefa7cb4c290 +README.zh.md: b8d6e9d80bac82a7798cc07d3a34c01d219f2174 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md new file mode 100644 index 0000000000..95cdbc9694 --- /dev/null +++ b/packages/bundle/web-app/README.md @@ -0,0 +1,26 @@ +# `@deepseek-ai/dsh-web-app` + +English | [中文](README.zh.md) + +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses`/`printUrl` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top and silences the URL line. + +## Model Experience + +### Web-surface prompt section and bash runtime variables + +#### What the model sees + +The `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. + +#### Token effect + +One prompt paragraph per session plus two managed-environment variable lines; constant per process. + +#### KV Cache effect + +The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns. + +## Known Limitations and Deferred Work + +- **The frontend dist must be built** — `require.resolve` of the dist fails loud at activation with a build hint; there is no source-serving fallback. +- **`lanAddresses` is a boot-time snapshot** — interface changes after boot are not re-advertised; the printed LAN URL always matches the configured trust fence. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md new file mode 100644 index 0000000000..b8d6e9d80b --- /dev/null +++ b/packages/bundle/web-app/README.zh.md @@ -0,0 +1,26 @@ +# `@deepseek-ai/dsh-web-app` + +[English](README.md) | 中文 + +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses`/`printUrl` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层并关闭 URL 行。 + +## 模型体验 + +### Web 表层提示词段落与 bash 运行时变量 + +#### 模型看到的内容 + +全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新契约,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。 + +#### Token 影响 + +每个会话一段提示词,外加两行受管环境变量;每个进程内保持恒定。 + +#### KV Cache 影响 + +该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。 + +## 已知限制与延期工作 + +- **前端 dist 必须已构建**:对 dist 的 `require.resolve` 在激活时大声失败并给出构建提示;没有从源码直接服务的回退路径。 +- **`lanAddresses` 是启动期快照**:启动后的网卡变化不会重新公告;打印的 LAN URL 始终与配置的信任栅栏一致。 diff --git a/apps/cli/config/web.cordis.yml b/packages/bundle/web-app/cordis.patch.yml similarity index 84% rename from apps/cli/config/web.cordis.yml rename to packages/bundle/web-app/cordis.patch.yml index 62731fd51e..c662016b8a 100644 --- a/apps/cli/config/web.cordis.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -1,12 +1,11 @@ -# `dsh web` — the browser surface, as a patch list over `base.cordis.yml`. -# The launcher includes the base and applies this file, then any `--config` -# overlay, then AppCLIEntry's profile-json and CLI-flag patches, as sibling patch -# lists at ONE include level: patches never cross an include boundary, so -# stacking overlays as nested includes would silently stop reaching base rows. +# The dsh-web-app bundle patch: the browser surface over the dsh-base layer. +# Applied after dsh-base's insert; rows here override base rows by id, with +# the profile's own cordis.patch.yml and any --patch overlays still to come. # -# A patch replaces the targeted row's whole `config`, so each row below restates -# every key it owns. `--dev` appends the dsh-client-hmr row in code -# (AppCLIEntry). +# A patch replaces the targeted row's whole `config`, so each row below +# restates every key it owns. The `dsh web` launcher alias turns --host/--port/ +# --dev/--workspace-root/--trusted-host into further patches over these rows +# (`--dev` inserts the dsh-client-hmr row). # ── surface-specific values the base deliberately omits ───────────────────── @@ -91,15 +90,26 @@ # ── layer 2: transport/service ────────────────────────────────────────────── - # Plain route-registration carrier. distIndex is an assembly fact, not user - # config — AppCLIEntry resolves the frontend dist and patches it in; host and - # port arrive as CLI-flag patches over these defaults. + # Plain route-registration carrier; host and port arrive as `dsh web` + # flag patches over these defaults. The dist is served by the web-runtime + # row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' config: host: 127.0.0.1 port: 3080 + # Web glue owned by this bundle: resolves the built frontend dist (an + # assembly fact of dsh-web-app, never user config), mounts the + # frontend-static fallback owner, registers the web-surface prompt + # section and bash runtime variables, and prints the URL line. `dsh web` + # patches mode/lanAddresses over these defaults. + - id: web-runtime + name: '@deepseek-ai/dsh-web-app' + config: + mode: production + printUrl: true + # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── # Dual-face: node half scans this very tree for dshClient rows, composes diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json new file mode 100644 index 0000000000..e1e2a750e5 --- /dev/null +++ b/packages/bundle/web-app/package.json @@ -0,0 +1,87 @@ +{ + "name": "@deepseek-ai/dsh-web-app", + "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", + "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-model": "workspace:^", + "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-permission": "workspace:^", + "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-question": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-skill": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-frontend": "workspace:^", + "@deepseek-ai/dsh-frontend-static": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-bash-env": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts new file mode 100644 index 0000000000..b817642be8 --- /dev/null +++ b/packages/bundle/web-app/src/index.ts @@ -0,0 +1,157 @@ +/** + * @deepseek-ai/dsh-web-app — the browser-surface bundle's runtime glue plugin + * plus the bundle patch (`cordis.patch.yml`, declared by the `dsh.bundle.patch` + * manifest field). The plugin owns what used to be launcher code: it resolves + * the built frontend dist (workspace knowledge of this bundle, never user + * config), mounts the `frontend-static` fallback owner over it, registers the + * web-surface prompt section and the bash-visible web runtime variables, and + * prints the URL line when configured to. Flag-derived values (`mode`, + * `lanAddresses`, `printUrl`) arrive as launcher patches over this row. + * @module @deepseek-ai/dsh-web-app + */ + +import { createRequire } from 'node:module' +import type { Context } from 'cordis' +import z from 'schemastery' +import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' +import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-bash-env' + +/** Stable Cordis plugin name. */ +export const name = 'web-app' + +/** Services required before the web runtime can mount. */ +export const inject = ['httpServer'] + +/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ +export type WebMode = 'production' | 'development' + +/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +export interface Config { + /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ + mode: WebMode + /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + printUrl: boolean + /** + * Register the model-visible surface context (the `app:web-surface` prompt + * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot + * layer turns it off: its user is not interacting through the GUI, so the + * orientation text would be false. + */ + surfaceContext: boolean + /** + * LAN IPv4 addresses sampled once by the launcher when the effective bind + * is all-interfaces — the exact snapshot the /api trust fence was + * configured with, so the printed LAN URL can never name an address the + * fence rejects. Empty on a loopback bind. + */ + lanAddresses: string[] +} + +export const Config: z = z.object({ + mode: z.union([z.const('production'), z.const('development')]).default('production'), + printUrl: z.boolean().default(true), + surfaceContext: z.boolean().default(true), + lanAddresses: z.array(String).default([]), +}) + +/** Environment variable naming the canonical local URL of this Web GUI. */ +const DSH_WEB_URL = 'DSH_WEB_URL' as const +/** Environment variable naming the Web runtime mode. */ +const DSH_WEB_MODE = 'DSH_WEB_MODE' as const + +// Display-only mirror of the webserver schema's loopback host: the address the +// local URL always prints. Not a source of truth — the schema is. +const LOOPBACK_HOST = '127.0.0.1' + +/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ +function webSurfacePrompt(webUrl: string, mode: WebMode): string { + const updateContract = mode === 'development' + ? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. ' + + 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. ' + + 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. ' + : 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. ' + + 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. ' + return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` + + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + + 'The browser provides no implicit DOM, route, or screenshot context. ' + + updateContract + + 'Starting another server does not update this GUI. ' + + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' + + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.' +} + +/** Resolve the canonical loopback URL from the active Web server. */ +function localWebUrl(ctx: Context): string { + const port = ctx.get('httpServer')?.port + if (port === undefined) throw new Error('web-app: httpServer service missing while resolving Web runtime') + return `http://${LOOPBACK_HOST}:${String(port)}` +} + +/** Dist location is workspace knowledge of this bundle: resolved through the frontend package exports, not configured. */ +function resolveDistIndex(): string { + const require = createRequire(import.meta.url) + try { + return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') + } catch { + /* v8 ignore next 2 -- reachable only on a checkout without a built dist; the test tree builds it */ + throw new Error('web-app: frontend dist not built; run pnpm run build from the repository root first') + } +} + +/** Test seam: hosts with no built frontend dist substitute the resolver; production never touches this. */ +export const internals: { resolveDistIndex: () => string } = { resolveDistIndex } + +/** + * Mount the Web runtime: dist serving, surface prompt, bash runtime + * variables, and the URL line. + * @param ctx - plugin context carrying the httpServer service. + * @param config - validated {@link Config}. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) + if (config.surfaceContext) { + ctx.inject(['systemPrompt'], (promptCtx) => { + promptCtx.systemPrompt.section({ + name: 'app:web-surface', + order: -98, + text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode), + }) + }) + ctx.inject(['bashEnv'], (runtimeCtx) => { + runtimeCtx.bashEnv.register({ + name: 'web-runtime', + variables: { + [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, + [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' }, + }, + resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }), + }) + }) + } + if (config.printUrl) { + // The URL line is a readiness signal: supervisors (and the keyless CLI + // smoke) RPC as soon as they observe it, so it must not print while + // sibling rows (the /api route owner) are still mounting. Await Loader + // settlement first; a hand-built tree without a Loader prints at once. + const printUrl = (): void => { + // The launcher's boot-time LAN snapshot, not a fresh sample: the printed + // LAN URL must name an address the /api trust fence was configured with. + const lanCandidate = config.lanAddresses[0] + const port = ctx.httpServer.port + console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) + } + const loader = ctx.get('loader') + if (loader === undefined) printUrl() + else { + void loader.await().then(() => { + // The tree can be disposed while settlement was in flight (early + // SIGTERM); a URL line for a dead server would only mislead, and + // reading the torn-down port would turn a clean shutdown into a crash. + if (ctx.get('httpServer') !== undefined) printUrl() + }) + } + } +} diff --git a/packages/bundle/web-app/src/invariant.ts b/packages/bundle/web-app/src/invariant.ts new file mode 100644 index 0000000000..a91d7cf7d1 --- /dev/null +++ b/packages/bundle/web-app/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web-app`. + * @module @deepseek-ai/dsh-web-app/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-app' + +/** Cordis companion plugin name. */ +export const name = 'web-app-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: every contribution (frontend-static child plugin, + * prompt section, bashEnv registration) is registry-disposed with the fiber, + * and each owning registry's package carries that relation's invariant; the + * package holds no mutable state of its own to audit. + */ +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)) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts new file mode 100644 index 0000000000..a4e300b08b --- /dev/null +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -0,0 +1,197 @@ +/** + * Web runtime glue behavior: dist resolution through the bundle's own seam, + * the frontend-static child claiming the fallback seat, the web-surface + * prompt section and bash runtime variables, and URL-line printing with the + * launcher's LAN snapshot. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' +import { apply, Config, internals } from '../src/index.ts' + +let dist: string | undefined + +afterEach(() => { + vi.restoreAllMocks() + internals.resolveDistIndex = originalResolve + if (dist !== undefined) rmSync(dist, { recursive: true, force: true }) + dist = undefined +}) + +const originalResolve = internals.resolveDistIndex + +/** Stage a dist fixture and point the bundle's resolver at it. */ +function stageDist(): string { + dist = mkdtempSync(join(tmpdir(), 'dsh-web-app-')) + mkdirSync(join(dist, 'dist')) + const index = join(dist, 'dist', 'index.html') + writeFileSync(index, 'shell') + internals.resolveDistIndex = () => index + return index +} + +/** A fake httpServer capturing the fallback seat and index taps. */ +function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { + let fallback: unknown + const server = { + port: 4567, + registerFallback: (handler: unknown) => { + fallback = handler + return () => { fallback = undefined } + }, + applyIndexTaps: (html: string) => html, + } as unknown as HttpServerService + return { server, seat: () => fallback } +} + +interface BashContribution { + name: string + variables: Record + resolve: () => Record +} + +describe('web-app runtime glue', () => { + it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { + stageDist() + const ctx = new Context() + const { server, seat } = fakeHttpServer() + ctx.provide('httpServer', server) + const contributions: BashContribution[] = [] + ctx.provide('bashEnv', { + register: (contribution: BashContribution) => { + contributions.push(contribution) + return () => {} + }, + } as never) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) + await ctx.plugin(SystemPrompt, { persona: '' }) + // Settle the injected registrations. + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(seat()).toBeDefined() // frontend-static claimed the fallback + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') + const assembly = await ctx.systemPrompt.assemble() + const section = assembly.sections.find(entry => entry.name === 'app:web-surface') + expect(section?.text).toContain('http://127.0.0.1:4567') + expect(section?.text).toContain('--dev') + const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime') + expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' }) + await ctx.fiber.dispose() + }) + + it('stays quiet in production mode with printUrl off and reports the production update contract', async () => { + stageDist() + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer().server) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await ctx.plugin(SystemPrompt, { persona: '' }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) + .toContain('without `--dev`') + await ctx.fiber.dispose() + }) + + it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => { + stageDist() + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer().server) + const contributions: BashContribution[] = [] + ctx.provide('bashEnv', { + register: (contribution: BashContribution) => { + contributions.push(contribution) + return () => {} + }, + } as never) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await ctx.plugin(SystemPrompt, { persona: '' }) + await new Promise(resolve => setTimeout(resolve, 0)) + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) + expect(contributions).toEqual([]) + await ctx.fiber.dispose() + }) + + it('prints the loopback-only URL line when no LAN snapshot exists', async () => { + stageDist() + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer().server) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + await ctx.fiber.dispose() + }) + + it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { + stageDist() + // Settlement path: the line waits for loader.await() so supervisors can + // RPC immediately after observing it. + const settled = new Context() + settled.provide('httpServer', fakeHttpServer().server) + let release: () => void + const settlement = new Promise((resolve) => { release = resolve }) + settled.provide('loader', { await: () => settlement } as never) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + release!() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + await settled.fiber.dispose() + + // Torn-down path: settlement resolves after the webserver is gone — no + // line, no crash. + log.mockClear() + const torn = new Context() + const child = torn.plugin((childCtx: Context) => { + childCtx.provide('httpServer', fakeHttpServer().server) + }) + await child + let releaseTorn: () => void + const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) + torn.provide('loader', { await: () => tornSettlement } as never) + apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await child.dispose() // the httpServer service goes away + releaseTorn!() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await torn.fiber.dispose() + }) + + it('fails loud when the prompt section resolves against a portless webserver', async () => { + stageDist() + const ctx = new Context() + // A webserver whose bound port is gone (torn down mid-request): the + // section must throw, never render a URL with an undefined port. + const { server } = fakeHttpServer() + Object.defineProperty(server, 'port', { get: () => undefined }) + ctx.provide('httpServer', server) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await ctx.plugin(SystemPrompt, { persona: '' }) + await new Promise(resolve => setTimeout(resolve, 0)) + await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') + await ctx.fiber.dispose() + }) + + it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => { + // The production resolver (not the test seam). A built checkout resolves + // the frontend package's index.html; a dist-less one (the CI coverage + // lane runs before any build) must fail with the build hint, never a + // silent fallback. + try { + expect(originalResolve()).toMatch(/dist[/\\]index\.html$/) + } catch (error) { + expect((error as Error).message).toContain('frontend dist not built') + } + }) +}) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json new file mode 100644 index 0000000000..6aadb534cb --- /dev/null +++ b/packages/bundle/web-app/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../host/frontend-static" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../bash/bash-env" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 46e0b9d252..df7374d7c7 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. -2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. 5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index ea7a1305b3..daf43fa018 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/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/repository-plugin/README.md -README.md: 2555876734ddcab7b0bc0f25780010bef0c97a01 -README.zh.md: 41341f7d7ed92ac071dde274a0328784bc2888d4 +README.md: 33cd763d7dbe21b72f9e604b7b2e313081cf656f +README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index 2555876734..33cd763d7d 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -30,7 +30,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: ## Standalone app configuration -The shipped `dsh` base used by raw-config, Web, and headless modes contains an empty `repository-plugins` row. A Web or headless user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`); a raw-config deployment patches the same row in its explicit overlay: +The shipped `dsh-base` bundle every profile starts from contains an empty `repository-plugins` row. A user enables exact GitHub generations by replacing that row's config in a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml`, or the home-level `$DSH_HOME/cordis.patch.yml` shared by every profile; a `--patch` overlay patches the same row for one run: ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ The shipped `dsh` base used by raw-config, Web, and headless modes contains an e 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. -Web watches `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 reads the file only at startup, and raw-config mode reads only its explicit overlay. 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). +Long-lived surfaces watch both `cordis.patch.yml` layers 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)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. 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 diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index 41341f7d7e..903dfbe601 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -30,7 +30,7 @@ ## 独立应用配置 -随附 `dsh` 中供原始配置、Web 与无头模式使用的基础配置包含一个空 `repository-plugins` 配置项。Web 或无头用户可在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,以启用精确指定的 GitHub generation;原始配置部署则在显式 overlay 中 patch 同一配置项: +每个 profile 都以之为起点的随附 `dsh-base` 组合包包含一个空 `repository-plugins` 配置项。用户可在用户 patch 层中替换该配置项的配置来启用精确指定的 GitHub generation:写入 `$DSH_HOME/profiles//cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml`;`--patch` overlay 则只为单次运行 patch 同一配置项: ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ 每个源都必须采用 `github:owner/repository#`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 -Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头模式只在启动时读取该文件,原始配置模式只读取其显式 overlay。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 +长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 ## 准备阶段 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 32ac294f8b..8a75107eea 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -396,9 +396,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'registerUpgrade(route: WebUpgradeRoute): () => void', jsDoc: '/**\n * Register an exact-path HTTP upgrade route. Duplicate paths throw because\n * one socket can have only one protocol owner.\n * @param route - pathname and handler owning negotiation plus socket use.\n * @returns the disposer removing the route.\n */', }, + { + signature: 'registerFallback(handler: WebRoute[\'handler\']): () => void', + jsDoc: '/**\n * Claim the fallback seat: the handler answering every request no named\n * route matches (the SPA dist server in the shipped Web composition). One\n * owner only — a second registration throws, because two fallbacks cannot\n * compose.\n * @param handler - owns the full response lifecycle of unmatched requests.\n * @returns the disposer releasing the seat.\n */', + }, { signature: 'tapIndex(transform: (html: string) => string): () => void', - jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */', + jsDoc: '/**\n * Register an index.html transform, applied by the fallback owner to every\n * index response ({@link applyIndexTaps}) in registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */', + }, + { + signature: 'applyIndexTaps(html: string): string', + jsDoc: '/**\n * Run an index.html body through the registered taps in registration order\n * — called by the fallback owner on every index response it renders.\n * @param html - the raw index.html body.\n * @returns the transformed body.\n */', }, ], }, diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 6575a13867..9087d22f51 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-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/credentials/credentials-local/README.md -README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86 -README.zh.md: a279336f59ca9525ebeb918dd23fb0b7682443d5 +README.md: 7d541d42efc942310e9a4066a9edadedb608ef0b +README.zh.md: 6a3d697551c8607bbab59a81c84eb725bf6191eb diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 02b883958f..7d541d42ef 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)), so reaching the value takes a deliberate read of a path the agent was not given. That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index a279336f59..6a3d697551 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -32,7 +32,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。 diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 178db5dcef..1aaacd7ecb 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/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/README.md -README.md: 7cd331f113eeec6c0a56f0ebc60554d9647aee75 -README.zh.md: 07b0e1569e17b9f0465a43f77fa2dbddcb1bae91 +README.md: 269a27f51c842f13bc11c175916b7be22db72bd2 +README.zh.md: 559bf785eb45d59a30f676b98c14143c69d57edd diff --git a/packages/host/README.md b/packages/host/README.md index 7cd331f113..269a27f51c 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -2,12 +2,13 @@ English | [中文](README.zh.md) -The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. +The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) serving [`apps/web`](../../apps/web/). All **product** packages. | Package | Role | ctx key | |---|---|---| | [`apiproxy/`](apiproxy/README.md) | Shared host API gateway and wire contract | `ctx.apiProxy` | | [`webserver/`](webserver/README.md) | HTTP route carrier | `ctx.httpServer` | +| [`frontend-static/`](frontend-static/README.md) | SPA dist server on the webserver fallback seat | consumes `ctx.httpServer` | | [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam | `ctx.directoryPicker` | | [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` | diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 07b0e1569e..559bf785eb 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -2,12 +2,13 @@ [English](README.md) | 中文 -dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),由它提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。 +dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/README.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| | [`apiproxy/`](apiproxy/README.md) | 共享宿主 API 网关和协议契约 | `ctx.apiProxy` | | [`webserver/`](webserver/README.md) | HTTP 路由载体 | `ctx.httpServer` | +| [`frontend-static/`](frontend-static/README.md) | 占据 webserver 回退席位的 SPA dist 服务器 | 消费 `ctx.httpServer` | | [`directory-picker/`](directory-picker/README.md) | workspace 目录选择 seam | `ctx.directoryPicker` | | [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` | diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 8ee0d81334..38c79f4617 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/apiproxy/README.md -README.md: 00b9ea075af7eb55041d48ceb3a1363e9de90397 -README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9 +README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 +README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 00b9ea075a..0963476a76 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e62e3fa99c..e3634c5f92 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 ## 契约层(`/api`) 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 9d0b8c7de8..7922592d01 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -7,7 +7,7 @@ * joining the backend's own teardown before the disposer settles. */ -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -43,21 +43,15 @@ afterEach(async () => { fakeBin = undefined }) -/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ +/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-')) - const dist = join(root, 'dist') - mkdirSync(dist) - const distIndex = join(dist, 'index.html') - await writeFile(distIndex, 'shell') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-host-webserver'", ' config:', ` host: '${bindHost}'`, ' port: 0', - ' portConflict: increment', - ` distIndex: '${distIndex}'`, `- name: '${AUTO}'`, '', ].join('\n')) diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml new file mode 100644 index 0000000000..07d337775e --- /dev/null +++ b/packages/host/frontend-static/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/host/frontend-static/README.md +README.md: c3a831abb1060b59e1802d38d5407a29d24e3bb3 +README.zh.md: d4dc71763280a3c88c73de50f63f2615570c7182 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md new file mode 100644 index 0000000000..c3a831abb1 --- /dev/null +++ b/packages/host/frontend-static/README.md @@ -0,0 +1,19 @@ +# `@deepseek-ai/dsh-frontend-static` + +English | [中文](README.zh.md) + +SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response runs through the webserver's registered index taps (`applyIndexTaps`), which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it. + +The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404. + +## Model Experience + +None, as the package serves browser assets; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md new file mode 100644 index 0000000000..d4dc717632 --- /dev/null +++ b/packages/host/frontend-static/README.zh.md @@ -0,0 +1,19 @@ +# `@deepseek-ai/dsh-frontend-static` + +[English](README.md) | 中文 + +Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名 route 时返回 405。每个 index 响应都会经过 webserver 已注册的 index 转换(`applyIndexTaps`),启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。 + +回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。 + +## 模型体验 + +无。该包只服务浏览器资产;其中没有任何内容会进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **初始 MIME 表很精简**:vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json new file mode 100644 index 0000000000..ac690cee24 --- /dev/null +++ b/packages/host/frontend-static/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-frontend-static", + "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "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/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-host-webserver": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts new file mode 100644 index 0000000000..4d5032c2d2 --- /dev/null +++ b/packages/host/frontend-static/src/index.ts @@ -0,0 +1,109 @@ +/** + * @deepseek-ai/dsh-frontend-static — SPA dist server over the webserver + * fallback seat: serves the built frontend directory with the semantics the + * Web shell locked at step1 — traversal outside the dist root is 403, any + * miss falls back to index.html with HTTP 200 (SPA routing), unknown + * extensions ship as octet-stream, non-GET/HEAD is 405. Every index response + * runs through the webserver's registered index taps (boot-manifest + * injection). The dist location is workspace knowledge of the composing + * application, so `distIndex` is typically supplied through a `!!js` + * expression, never hardcoded by a deployment. + * @module @deepseek-ai/dsh-frontend-static + */ + +import type { ServerResponse } from 'node:http' +import { readFile } from 'node:fs/promises' +import { dirname, extname, join, normalize, resolve, sep } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-host-webserver' + +/** Stable Cordis plugin name. */ +export const name = 'frontend-static' + +/** Service required before the fallback seat can be claimed. */ +export const inject = ['httpServer'] + +/** Plugin config: the dist anchor. */ +export interface Config { + /** Absolute path of index.html inside the dist root. */ + distIndex: string +} + +export const Config: z = z.object({ + distIndex: z.string().required(), +}) + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.json': 'application/json', + '.map': 'application/json', +} + +/** + * Serve one GET/HEAD static request from the dist root. + * @param pathname - decoded URL pathname of the request. + * @param res - the node:http response to write. + * @param distRoot - absolute dist root directory (resolved by the caller). + * @param distIndex - absolute path of index.html inside distRoot. + * @param renderIndex - produces the index.html body (index-tap injection) for + * `/` and every SPA fallback. + */ +export async function serveStatic( + pathname: string, res: ServerResponse, distRoot: string, distIndex: string, + renderIndex: () => Promise, +): Promise { + const target = resolve(normalize(join(distRoot, pathname))) + // Traversal rejection: the target must be distRoot itself (`/`) or stay under + // it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/' + // suffix would reject every legitimate subpath as traversal. + if (target !== distRoot && !target.startsWith(distRoot + sep)) { + res.writeHead(403) + res.end() + return + } + const serveIndex = async (): Promise => { + const body = await renderIndex() + res.writeHead(200, { 'content-type': MIME['.html'] }) + res.end(body) + } + if (target === distRoot || target === distIndex) { + await serveIndex() + return + } + try { + const body = await readFile(target) + res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' }) + res.end(body) + } catch { + // Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing). + await serveIndex() + } +} + +/** + * Claim the webserver fallback seat and serve the dist. + * @param ctx - plugin context carrying the httpServer service. + * @param config - validated {@link Config}. + */ +export function apply(ctx: Context, config: Config): void { + const distIndex = config.distIndex + const distRoot = dirname(distIndex) + const renderIndex = async (): Promise => + ctx.httpServer.applyIndexTaps(await readFile(distIndex, 'utf8')) + ctx.effect(() => ctx.httpServer.registerFallback(async (req, res) => { + // Non-GET/HEAD without a matching named route is 405 (fallback-only + // semantics: named routes own their method handling). + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405) + res.end() + return + } + /* v8 ignore next -- node:http always sets url on server requests */ + const rawPath = new URL(req.url ?? '/', 'http://x').pathname + await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex) + }), 'frontend-static: fallback seat') +} diff --git a/packages/host/frontend-static/src/invariant.ts b/packages/host/frontend-static/src/invariant.ts new file mode 100644 index 0000000000..551daccbc8 --- /dev/null +++ b/packages/host/frontend-static/src/invariant.ts @@ -0,0 +1,34 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-frontend-static`. + * @module @deepseek-ai/dsh-frontend-static/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static' + +/** Cordis companion plugin name. */ +export const name = 'frontend-static-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the only owned relation is the single fallback seat, + * which cannot be probed from the teardown stream — `internal/plugin` fires + * before the disposing fiber's effects run, so the legitimate owner still + * holds the seat at notification time and any claim probe would + * false-positive on every correct disposal (unlike the webserver companion, + * whose reserved-path probes never collide with a live registration). The + * seat's register/release symmetry is covered by the package's + * real-composition HMR-safety test instead. + */ +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)) diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts new file mode 100644 index 0000000000..e35e54bb05 --- /dev/null +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -0,0 +1,127 @@ +/** + * REAL-composition coverage: a test-only cordis.yml booted through the + * vendored Loader mounts the webserver and frontend-static rows, and every + * assertion observes the served HTTP surface — asset serving, MIME fallback, + * SPA index fallback with index taps, traversal rejection, 405 on non-GET/ + * HEAD, and seat release on fiber disposal (HMR safety). + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import HttpServer from '@deepseek-ai/dsh-host-webserver' +import * as FrontendStatic from '../src/index.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +/** Write a dist fixture and a two-row cordis.yml, then boot it through the real Loader. */ +async function loadComposition(): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-frontend-static-')) + const dist = join(root, 'dist') + await mkdir(dist) + const distIndex = join(dist, 'index.html') + await writeFile(distIndex, 'shell') + await writeFile(join(dist, 'app.js'), 'export {}') + await writeFile(join(dist, 'blob.bin'), 'BLOB') + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-host-webserver'", + ' config:', + " host: '127.0.0.1'", + ' port: 0', + '- id: frontend', + " name: '@deepseek-ai/dsh-frontend-static'", + ' config:', + ` distIndex: '${distIndex}'`, + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-host-webserver', HttpServer], + ['@deepseek-ai/dsh-frontend-static', FrontendStatic], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + +/** GET (by default) one path against the running server; returns status, content-type, and a body prefix. */ +async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; type: string | null; body: string }> { + const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init) + return { + status: response.status, + type: response.headers.get('content-type'), + body: (await response.text()).slice(0, 80), + } +} + +describe('real Loader composition', () => { + it('serves the dist with SPA fallback, taps, traversal rejection, and method gating', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const unloaded = [...loaded.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + const server = loaded.httpServer + const port = server.port + + // Real asset with its MIME type; a live rebuild is served on the next read. + expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' }) + await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') + expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) + + // Unknown extension ships as octet-stream. + expect(await request(port, '/blob.bin')).toMatchObject({ status: 200, type: 'application/octet-stream', body: 'BLOB' }) + + // `/`, the index path, and any miss all render index.html (SPA routing) + // through the registered index taps. + const untap = server.tapIndex(html => html.replace('', '')) + for (const path of ['/', '/index.html', '/no/such/route']) { + const got = await request(port, path) + expect(got.status).toBe(200) + expect(got.body).toContain('__T__') + expect(got.body).toContain('shell') + } + untap() + expect((await request(port, '/')).body).not.toContain('__T__') + + // Traversal outside the dist root is 403; non-GET/HEAD is 405. + expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) + expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) + + // HMR safety: disposing the frontend row releases the fallback seat (the + // unclaimed webserver answers 404) and the seat is claimable again. + const frontendEntry = [...loaded.loader.entries()].find(e => e.options.id === 'frontend') + expect(frontendEntry).toBeDefined() + await frontendEntry!.fiber?.dispose() + expect((await request(port, '/no/such/route')).status).toBe(404) + expect(() => server.registerFallback(() => {})).not.toThrow() + }) +}) diff --git a/packages/host/frontend-static/tsconfig.json b/packages/host/frontend-static/tsconfig.json new file mode 100644 index 0000000000..bda9b5bb40 --- /dev/null +++ b/packages/host/frontend-static/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../webserver" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 8b53e55af5..56fd0e7694 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: 196f350d87c5322cd3e9cda6e40587d35acd08c4 -README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977 +README.md: b6dccf2f81c9e2f0b9f53264eafe724edb560f07 +README.zh.md: dbfe420013ed67c48e47048341f020864aeef16a diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 196f350d87..b6dccf2f81 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. -The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `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. +The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). 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 and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP 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. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, 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. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. 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. @@ -21,5 +21,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. -- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. - **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 0ae0470eab..dbfe420013 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 +Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 -该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 route。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 +该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 @@ -21,5 +21,4 @@ Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配 ## 已知限制与延期工作 - **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。 -- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 - **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 6b46b8704d..a536f9e1f5 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,21 +1,19 @@ /** * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http * server plus the `httpServer` service (HTTP and upgrade route registries, - * index transform taps, and static dist fallback). Knows no harness concepts; - * feature plugins own every registered protocol. Web shape only — Electron - * loads dist over file:// and carries fetch over an IPC bridge. This package - * never prints: the URL line belongs to the shell. + * index transform taps, and the single fallback seat for everything no route + * claims). Knows no harness concepts and serves no files; the composing + * application's frontend plugin owns dist serving through the fallback seam. + * Web shape only — Electron loads dist over file:// and carries fetch over an + * IPC bridge. This package never prints: the URL line belongs to the shell. */ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse, Server } from 'node:http' -import { readFile } from 'node:fs/promises' import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' -import { dirname } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import { serveStatic } from './static.ts' declare module 'cordis' { interface Context { @@ -43,28 +41,26 @@ export interface WebUpgradeRoute { handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise } -/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +/** Gateway config: the listen address. */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number - /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ - distIndex: string } /** * The web-shape HTTP carrier service. Activation listens immediately (route * registration order carries no request-facing semantics: named routes are - * composed to be disjoint, and the static dist fallback answers anything not - * yet claimed during the boot window). A listen failure throws out of init — - * a FAILED fiber the boot's fail-loud sweep reports. + * composed to be disjoint, and the fallback seat answers anything not yet + * claimed during the boot window — 404 until its owner registers). A listen + * failure throws out of init — a FAILED fiber the boot's fail-loud sweep + * reports. */ export class HttpServerService extends Service { static Config: z = z.object({ host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(), port: z.natural().max(65535).required(), - distIndex: z.string().required(), }) private readonly exact = new Map() @@ -72,15 +68,12 @@ export class HttpServerService extends Service { private readonly upgrades = new Map() private readonly upgradedSockets = new Set() private readonly indexTaps: ((html: string) => string)[] = [] - private readonly distRoot: string - private readonly distIndex: string + private fallback: WebRoute['handler'] | undefined private server!: Server private listenedPort!: number constructor(ctx: Context, private config: Config) { super(ctx, 'httpServer') - this.distIndex = config.distIndex - this.distRoot = dirname(config.distIndex) } /** The listening port (the OS-assigned value when config.port is 0). */ @@ -123,8 +116,24 @@ export class HttpServerService extends Service { } /** - * Register an index.html transform, applied to every index response in - * registration order. + * Claim the fallback seat: the handler answering every request no named + * route matches (the SPA dist server in the shipped Web composition). One + * owner only — a second registration throws, because two fallbacks cannot + * compose. + * @param handler - owns the full response lifecycle of unmatched requests. + * @returns the disposer releasing the seat. + */ + registerFallback(handler: WebRoute['handler']): () => void { + if (this.fallback !== undefined) { + throw new Error('webserver: fallback already registered') + } + this.fallback = handler + return () => { this.fallback = undefined } + } + + /** + * Register an index.html transform, applied by the fallback owner to every + * index response ({@link applyIndexTaps}) in registration order. * @param transform - pure html-to-html function. * @returns the disposer removing the transform. */ @@ -147,14 +156,13 @@ export class HttpServerService extends Service { await route.handler(req, res) return } - // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405, - // traversal 403, miss falls back to index.html 200 (SPA routing). - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) + const fallback = this.fallback + if (fallback === undefined) { + res.writeHead(404) res.end() return } - await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex()) + await fallback(req, res) } // Last-resort guard: handle() rejecting would otherwise be an unhandled // rejection killing the process on one malformed request (bad %-escape, @@ -243,11 +251,16 @@ export class HttpServerService extends Service { return best } - /** Index body: dist index.html through the registered taps in order. */ - private async renderIndex(): Promise { - let html = await readFile(this.distIndex, 'utf8') - for (const transform of this.indexTaps) html = transform(html) - return html + /** + * Run an index.html body through the registered taps in registration order + * — called by the fallback owner on every index response it renders. + * @param html - the raw index.html body. + * @returns the transformed body. + */ + applyIndexTaps(html: string): string { + let out = html + for (const transform of this.indexTaps) out = transform(out) + return out } } diff --git a/packages/host/webserver/src/static.ts b/packages/host/webserver/src/static.ts deleted file mode 100644 index a672f4e5c2..0000000000 --- a/packages/host/webserver/src/static.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Static file serving for the web shell: the starter MIME table and the - * request handler with the semantics locked by the step1 acceptance list — - * traversal outside the dist root is 403, any miss falls back to index.html - * with HTTP 200 (SPA routing), unknown extensions ship as octet-stream. - */ - -import type { ServerResponse } from 'node:http' -import { extname, join, normalize, resolve, sep } from 'node:path' -import { readFile } from 'node:fs/promises' - -const MIME: Record = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.svg': 'image/svg+xml', - '.json': 'application/json', - '.map': 'application/json', -} - -/** - * Serve one GET/HEAD static request from the dist root. - * @param pathname - decoded URL pathname of the request. - * @param res - the node:http response to write. - * @param distRoot - absolute dist root directory (resolved by the caller). - * @param distIndex - absolute path of index.html inside distRoot. - * @param renderIndex - when set, produces the index.html body (boot-manifest - * injection) for `/` and every SPA fallback; undefined serves the file verbatim. - */ -export async function serveStatic( - pathname: string, res: ServerResponse, distRoot: string, distIndex: string, - renderIndex?: () => Promise, -): Promise { - const target = resolve(normalize(join(distRoot, pathname))) - // Traversal rejection: the target must be distRoot itself (`/`) or stay under - // it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/' - // suffix would reject every legitimate subpath as traversal. - if (target !== distRoot && !target.startsWith(distRoot + sep)) { - res.writeHead(403) - res.end() - return - } - const serveIndex = async (): Promise => { - const body = renderIndex === undefined ? await readFile(distIndex) : await renderIndex() - res.writeHead(200, { 'content-type': MIME['.html'] }) - res.end(body) - } - if (target === distRoot || target === distIndex) { - await serveIndex() - return - } - try { - const body = await readFile(target) - res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' }) - res.end(body) - } catch { - // Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing). - await serveIndex() - } -} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 19a252d53a..d91284c87b 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -2,11 +2,10 @@ * REAL-composition coverage: a test-only cordis.yml booted through the * vendored Loader mounts the webserver row, and every assertion observes the * user-visible HTTP surface of the running server (routing precedence, index - * taps, static-fallback semantics, per-request error containment, teardown). + * taps, fallback-seat semantics, per-request error containment, teardown). */ import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { mkdir } from 'node:fs/promises' import { once } from 'node:events' import { connect } from 'node:net' import { tmpdir } from 'node:os' @@ -28,21 +27,15 @@ afterEach(async () => { root = undefined }) -/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */ +/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */ async function loadComposition(port = 0): Promise { root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-')) - const dist = join(root, 'dist') - await mkdir(dist) - const distIndex = join(dist, 'index.html') - await writeFile(distIndex, 'shell') - await writeFile(join(dist, 'app.js'), 'export {}') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-host-webserver'", ' config:', " host: '127.0.0.1'", ` port: ${String(port)}`, - ` distIndex: '${distIndex}'`, '', ].join('\n')) @@ -96,7 +89,7 @@ describe('real Loader composition', () => { // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. - it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => { + it('serves registered routes, index taps, and the fallback-seat semantics', { timeout: 60_000 }, async () => { const loaded = await loadComposition() const unloaded = [...loaded.loader.entries()] .filter(entry => entry.fiber === undefined && !entry.disabled) @@ -120,21 +113,24 @@ describe('real Loader composition', () => { expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' }) expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' }) - // Index taps apply in registration order on `/` and on the SPA fallback; - // the disposer removes the transform. + // Fallback seat: 404 while unclaimed; the owner answers everything no + // named route matches; index taps are the owner's to apply; the seat + // admits exactly one owner and the disposer releases it. + expect((await request(port, '/no/such/route')).status).toBe(404) const untap = server.tapIndex(html => html.replace('', '')) - expect((await request(port, '/')).body).toContain('__T__') + expect(server.applyIndexTaps('')).toContain('__T__') + const releaseFallback = server.registerFallback((req, res) => { + // Decode like a real static server would — a malformed %-escape throws + // here, probing the webserver's per-request error containment. + decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname) + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(server.applyIndexTaps('shell')) + }) + expect(() => server.registerFallback(() => {})).toThrow(/fallback already registered/) expect((await request(port, '/no/such/route')).body).toContain('__T__') untap() - expect((await request(port, '/')).body).not.toContain('__T__') - - // Static fallback semantics: real asset served, traversal 403, non-GET/ - // HEAD without a matching route 405. - expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' }) - await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') - expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) - expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) - expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) + expect((await request(port, '/no/such/route')).body).not.toContain('__T__') + expect((await request(port, '/no/such/route')).body).toContain('shell') // Per-request error containment: a malformed %-escape answers 400 and the // server keeps serving afterwards (no process-level failure path). @@ -148,9 +144,14 @@ describe('real Loader composition', () => { const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } }) expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' }) disposeOnce() - expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback + expect((await request(port, '/once')).body).toContain('shell') // back to the fallback owner expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow() + // Releasing the seat restores the unclaimed 404 and registrability. + releaseFallback() + expect((await request(port, '/no/such/route')).status).toBe(404) + expect(() => server.registerFallback(() => {})).not.toThrow() + // Upgrade routes match exact pathnames, reject duplicate ownership, and // become registrable again after disposal. The accepted socket stays open // so the teardown assertion also covers upgraded-connection ownership. diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 036f80647f..c83879c63a 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -33,10 +33,11 @@ const breakdownSchema = z.object({ * * Envelope figures are last-wins per `request/header`; the message figure * rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy - * projection uses — so it equals `measure().surfaceTokens` at every event - * boundary and compaction shrinks it by its logged shadow price, the way it - * shrinks the next request. The state is a fixed handful of numbers, so the - * persisted checkpoint stays O(1) over the session's life. + * projection uses — so fully metered logs equal `measure().surfaceTokens` at + * every event boundary and compaction shrinks the figure by its logged shadow + * price. A replacement without a claim preserves the previous total. The + * state is a fixed handful of numbers, so the persisted checkpoint stays + * O(1) over the session's life. */ export const contextBreakdownProjectionDefinition: ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = { diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index e4dfacc254..2848025b19 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -3,9 +3,10 @@ * surface `measure()` serves and compaction plans against. The projection * units deliberately do NOT share this fold — their state must stay O(1) * for the persisted checkpoint, so they ride `surface-projection.ts`'s - * shadow-price protocol instead. The two stay in agreement by construction: - * both price through `estimate.ts`, and every logged shadow price is derived - * from THIS fold's nodes by the replace producer. + * shadow-price protocol instead. Fully metered logs stay in agreement by + * construction: both price through `estimate.ts`, and every logged shadow + * price is derived from THIS fold's nodes by the replace producer. A + * projection replacement without a claim deliberately folds with zero delta. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts index dcc8181370..9c42d5248e 100644 --- a/packages/llm/token-meter/src/surface-projection.ts +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -10,7 +10,9 @@ * heuristic price of the exact replaced range, so the fold keeps a running * total plus at most one pending claim and never retains per-node prices. * The counts are exact by construction: producers derive them from the same - * fixed estimator this module prices appends with. + * fixed estimator this module prices appends with. A replacement without an + * armed claim folds with zero delta because bounded state cannot reconstruct + * the replaced range; this preserves replay at the cost of possible drift. * * @module @deepseek-ai/dsh-token-meter/surface-projection */ @@ -47,16 +49,19 @@ export interface SurfaceTokensFold { * Fold one committed event onto a running surface-token total. * * A shadow-price event arms a claim; any other event expires it, and a - * surface `replace` must consume a claim naming its exact range — the + * surface `replace` consumes the claim naming its exact range — the * producers append the metering event and the replacement synchronously * adjacent, so a surviving claim always prices the very next event. + * A replace with no claim folds with zero delta because the bounded state + * cannot reconstruct the replaced range. An armed claim for another range + * still fails because the adjacent events contradict each other. * @param claim - the claim armed by the immediately preceding event, if any. * @param event - the next committed session event. * @returns the signed token delta and the claim state after this event. - * @throws when a replacement arrives without a claim for its exact range — - * every in-repo replace producer meters its replacement, so an unpriced - * replacement is a shadow-price contract violation and must fail loud - * rather than let the total drift. + * @throws when a replacement arrives with an armed claim for a different + * range — the metering event was adjacent, so this is a live producer's + * shadow-price contract violation, not historical data, and must fail + * loud rather than let the total drift. */ export function foldSurfaceProjection( claim: ShadowPriceClaim | undefined, @@ -74,10 +79,15 @@ export function foldSurfaceProjection( const tokens = message === null ? 0 : estimateMessage(message) const op = event.surfaceOp if (op === 'append') return { deltaTokens: tokens, claim: undefined } - if (claim === undefined || claim.start !== op.start || claim.end !== op.end) { + // Sessions recorded before the shadow-price protocol log replacements with + // no adjacent metering event; the bounded state cannot reconstruct the + // replaced range's price, so fold those neutrally — historical replay + // degrades to drift instead of failing. + if (claim === undefined) return { deltaTokens: 0, claim: undefined } + if (claim.start !== op.start || claim.end !== op.end) { throw new Error( `token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price` - + (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`), + + ` (armed claim covers ${claim.start}-${claim.end})`, ) } return { deltaTokens: tokens - claim.tokens, claim: undefined } diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 0d5db509b5..a7fc9debf0 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -155,9 +155,10 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { * `projectedTokens` — the sample plus the surface's signed movement since it * was taken — so occupancy answers for the next request rather than the last * one. The total rides {@link foldSurfaceProjection}, so the state stays O(1) - * and a replacement shrinks it by its logged shadow price. A usage sample is - * stamped BEFORE the same event joins the surface, so an `assistant/message` - * anchors against the surface its own request saw. + * and a replacement shrinks it by its logged shadow price. A replacement + * without a claim preserves the previous total. A usage sample is stamped + * BEFORE the same event joins the surface, so an `assistant/message` anchors + * against the surface its own request saw. */ export const contextPressureProjectionDefinition: ProjectionDefinition<'contextPressure', ContextPressureState> = { diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index b7e4850fd4..20cb2cc819 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -180,7 +180,7 @@ describe('contextBreakdown session projection', () => { expect(agree()).toBeLessThan(grown) }) - it('fails loud on a replacement without an adjacent matching shadow price', () => { + it('folds a replacement without a claim at zero and fails on a mismatched claim', () => { const definition = contextBreakdownProjectionDefinition const replace = (start: number, end: number): SessionEvent => ({ type: 'user/message', @@ -206,15 +206,17 @@ describe('contextBreakdown session projection', () => { let state = definition.init() state = definition.apply(state, append(1)) state = definition.apply(state, append(3)) - // No metering event at all. - expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim for a different range does not price this replacement. + // No metering event: the replacement contributes zero instead of throwing. + expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) + // An adjacent claim for another range contradicts the replacement. const mismatched = definition.apply(state, meter(1, 1, 8)) expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim expires after one intervening event instead of lingering. + // A claim expires after one intervening event, so replacement delta is zero. let expired = definition.apply(state, meter(1, 3, 8)) expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) - expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price') + expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) // The armed claim prices exactly the next event's matching replacement. const armed = definition.apply(state, meter(1, 3, 8)) expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens) diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 07261576eb..0307b96f46 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -419,6 +419,25 @@ describe('contextPressure session projection', () => { expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!) }) + it('folds a replacement without a claim at zero', async () => { + const { ctx, session } = await harness() + const question = appendUser(session, 'a question from an unmetered log') + startStep(session, 1, 1) + usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + const before = pressure(ctx, session) + + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary without a preceding claim' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: question }, + sourceEventSeqs: [question], + }) + + expect(pressure(ctx, session)).toEqual(before) + }) + it('clamps a projection that heuristic error drove below zero', async () => { const { ctx, session } = await harness() recordContext(session, 'large', 128_000) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index e0e04a4fa1..005b8e2157 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -686,7 +686,9 @@ class FaceAnalyzer { const records: ExportRecord[] = [] for (const [subpath, target] of targets) { if (target.includes('*') || subpath === './package.json' - || subpath === './typert' || subpath === './client/typert' || target.endsWith('.json')) continue + || subpath === './typert' || subpath === './client/typert' + // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API. + || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue const sourcePath = sourcePathForExport(registration.root, target) const sourceFile = this.sourceFiles.get(realPath(sourcePath)) if (sourceFile === undefined) { diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 09d621691f..398ec6e923 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: fbdd4c1332a1cc52f15a8ce28264ea16d47fc552 -README.zh.md: b67fb126ea477acf2e79f5bc1d695a5fc9ca8c82 +README.md: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b +README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index fbdd4c1332..cdd78047b6 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -12,10 +12,11 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `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 | -| `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 | +| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — 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; a missing file also throws, because the caller named it | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR | +| `watchUserPatches(ctx, options)` | Register the named patch file 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 user layer) and returns an async disposer | +| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `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), 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 | @@ -29,14 +30,16 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. -## Personal config +## Profiles -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 Web and headless modes ([`apps/cli`](../../../apps/cli/README.md)); raw config mode and the demo bins boot their named trees without this layer. Two optional files: +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). + +User-level machine-local preferences also live in the Harness home: - **`.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 Web settings 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. +- **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): 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 composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. -Web keeps `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. +Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag 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. ## Model Experience @@ -51,4 +54,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. -- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. +- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b67fb126ea..ee2b07884e 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -12,10 +12,11 @@ | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `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 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | -| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | +| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 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(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | @@ -29,14 +30,16 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 -## 个人配置 +## Profile -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 Web 与 headless 模式([`apps/cli`](../../../apps/cli/README.md))使用;原始配置模式与 demo bin 会在不加该层的情况下启动指定的配置树。这里有两个可选文件: +profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。 + +用户级的机器本地偏好同样位于 Harness home 中: - **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +- **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 -Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 ## 模型体验 @@ -51,4 +54,4 @@ Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 -- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 +- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 2e5a133f00..e14b249f5c 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,19 +1,19 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the - * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to + * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot */ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' 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 { dshHomePath } 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' @@ -25,6 +25,27 @@ declare module 'cordis' { } } +export { + composeEntries, + DEFAULT_PROFILE_BUNDLES, + healProfilesModuleFallback, + initProfile, + loadProfile, + PROFILE_PATCH_FILENAME, + PROFILE_TEMPLATES, + PROFILES_DIR, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + writeProfileManifest, + type DshBundleManifest, + type DshManifestSection, + type DshProfileManifest, + type Profile, + type ProfileLayer, + type ProfileManifest, +} from './profile.ts' + /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. @@ -65,49 +86,100 @@ 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 -// from what the include mounts. Personal patches share it so they may +// from what the include mounts. User patch layers share it so they may // reference `process.env`. -const personalPatchesSchema = entryListSchema +const userPatchesSchema = entryListSchema + +/** Options for live user patch-layer reconciliation. */ +export interface UserPatchWatchOptions { + /** Diagnostic prefix used by {@link loadOptionalPatches}. */ + binName: string + /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */ + filename: string + /** + * Compose the full patch list for a fresh user-layer generation — + * the same composition the app booted with, so a reload can interleave the + * new user patches between app-owned layers (bundle layers below, + * overlay/flag patches above). Identity when omitted: the user layer + * is the whole patch list. + */ + compose?: (userPatches: PatchOptions[]) => PatchOptions[] +} /** - * Load the optional personal overlay patches (`config.yaml` under the Harness - * home). The file is a top-level YAML array of loader patch entries - * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides - * and `insert` lists, with `!!js` expressions allowed. A missing file means - * "no personal overlay"; an unreadable, unparsable, or non-array file throws — - * a present personal config that cannot apply is a misconfiguration and must - * fail loud at boot, never be silently skipped. + * Watch the user patch layer 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, file, 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 watchUserPatches( + ctx: Context, + options: UserPatchWatchOptions, +): Promise<() => Promise> { + const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options + const hmr = ctx.get('hmr') + if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`) + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`) + 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 user-layer reload. + const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config + const userPatches = loadOptionalPatches(binName, filename) ?? [] + const patches = compose(userPatches) + 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; + // the HMR effect registration then fails with INACTIVE_EFFECT. That is the + // app exiting exactly as asked, not a watch failure, so return a no-op + // disposer instead of crashing. + if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} + throw error + } +} + +/** + * Load an optional patch-list file: a top-level YAML array of loader patch + * entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config + * overrides and `insert` lists, with `!!js` expressions allowed. A missing + * file means "no layer"; an unreadable, unparsable, or non-array file throws — + * a present patch file that cannot apply is a misconfiguration and must fail + * loud at boot, never be silently skipped. * @param binName - the diagnostic prefix on the thrown error. - * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`). + * @param file - absolute path of the patch file. * @returns the parsed patches, or `undefined` when the file does not exist. */ -export function loadPersonalPatches( - binName: string, dir: string = resolveDshHome(), -): PatchOptions[] | undefined { - const file = join(dir, PERSONAL_CONFIG_FILENAME) +export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined { let content: string try { content = readFileSync(file, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined - throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`) + throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`) } - return parsePatchList(binName, file, content, 'personal patches') + return parsePatchList(binName, file, content, 'patches') } /** - * Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a - * `--config ` overlay applied over the shared base. Same file format as - * {@link loadPersonalPatches}, but a missing file throws, because the caller - * named this file — its absence is a misconfiguration, not "no overlay". + * Load a required overlay patch list: a bundle's `cordis.patch.yml` or a + * `--patch ` overlay. Same file format as {@link loadOptionalPatches}, + * but a missing file throws, because the caller named this file — its absence + * is a misconfiguration, not "no overlay". * @param binName - the diagnostic prefix on the thrown error. * @param file - absolute path of the overlay file. * @returns the parsed patch list. @@ -121,7 +193,6 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } return parsePatchList(binName, file, content, 'overlay') } - /** * Parse one loader patch list: a top-level YAML array of * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and @@ -132,7 +203,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. - * @param label - what to call this list in errors (`personal patches`, `overlay`). + * @param label - what to call this list in errors (`patches`, `overlay`). * @returns the parsed patch list. */ function parsePatchList( @@ -140,7 +211,7 @@ function parsePatchList( ): PatchOptions[] { let parsed: unknown try { - parsed = yaml.load(content, { schema: personalPatchesSchema }) + parsed = yaml.load(content, { schema: userPatchesSchema }) } catch (error) { throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) } @@ -159,7 +230,7 @@ function parsePatchList( export interface ConfigDumpLayer { /** Source name shown in provenance comments (a file basename or path). */ label: string - /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */ + /** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */ patches: PatchOptions[] } @@ -290,70 +361,11 @@ 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; - // the HMR effect registration then fails with INACTIVE_EFFECT. That is the - // app exiting exactly as asked, not a watch failure, so 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. + * Mount and remember the exact root Include entry used by app boot and user patch-layer 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. + * @param patches - initial app and user 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. @@ -599,7 +611,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree - * (see {@link loadPersonalPatches}); an empty list mounts none. + * (see {@link loadOptionalPatches}); an empty list mounts none. * @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. diff --git a/packages/ui/app-boot/src/profile.ts b/packages/ui/app-boot/src/profile.ts new file mode 100644 index 0000000000..c353f18bef --- /dev/null +++ b/packages/ui/app-boot/src/profile.ts @@ -0,0 +1,388 @@ +/** + * Profile discovery, initialization, and patch-layer composition for the + * `dsh --profile` launcher family. + * + * A profile is a directory under `$DSH_HOME/profiles/` holding a + * `package.json` (out-of-tree plugin dependencies plus the profile manifest + * `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` + * (the user's own patch layer, applied after every bundle layer). Bundles are + * npm packages whose manifest declares + * `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the tree is + * composed by applying each bundle's patch list in `dsh.profile.bundles` order over + * an empty entry list, then the profile's own patches, then any launcher + * layers (`--patch` files and flag-derived patches). + * + * Module resolution is two-anchor by construction: a bundle name resolves + * first from the dsh installation (the launcher's own package), then from the + * profile directory. The Loader's `baseUrl` is the profile directory, whose + * `node_modules` pnpm manages for out-of-tree plugins, while the maintained + * flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per + * package the installation's app and bundles depend on) makes every in-box + * plugin Node-resolvable from any profile through the ordinary parent-walk. + * @module @deepseek-ai/dsh-app-boot/profile + */ + +import { createRequire } from 'node:module' +import { + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { loadOverlayPatches } from './index.ts' + +/** Directory under the Harness home holding every profile. */ +export const PROFILES_DIR = 'profiles' + +/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */ +export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' + +/** The bundle half of the `dsh` manifest section: what a bundle package exports. */ +export interface DshBundleManifest { + /** The patch layer this bundle exports, relative to its package root. */ + patch: string +} + +/** The profile half of the `dsh` manifest section: what a profile directory composes. */ +export interface DshProfileManifest { + /** Ordered bundle layer list (package names). */ + bundles?: string[] +} + +/** + * The `dsh`-owned manifest section of a package.json. The nested key names + * the manifest kind: a bundle package declares `bundle`, a profile directory + * declares `profile`; nothing declares both. + */ +export interface DshManifestSection { + /** Present on bundle packages only. */ + bundle?: DshBundleManifest + /** Present on profile manifests only. */ + profile?: DshProfileManifest +} + +/** The slice of package.json both profiles and bundles use. */ +export interface ProfileManifest { + name?: string + dependencies?: Record + peerDependencies?: Record + dsh?: DshManifestSection +} + +/** One resolved bundle layer of a profile. */ +export interface ProfileLayer { + /** The bundle's package name, as listed in `dsh.profile.bundles`. */ + packageName: string + /** Absolute directory of the resolved bundle package. */ + packageDir: string + /** Absolute path of the bundle's patch file. */ + patchPath: string + /** The parsed patch list. */ + patches: PatchOptions[] +} + +/** A loaded profile: resolved bundle layers plus the user's own patch layer. */ +export interface Profile { + /** The profile name (its directory basename). */ + name: string + /** Absolute profile directory. */ + dir: string + /** Bundle layers in `dsh.profile.bundles` order. */ + layers: ProfileLayer[] + /** Absolute path of the profile's own patch file. */ + patchPath: string + /** The profile's own patches; empty when the file is absent. */ + patches: PatchOptions[] +} + +/** + * Resolve a profile's directory under the Harness home. + * @param name - the profile name (`dsh --profile `). + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @returns the absolute profile directory (which may not exist yet). + */ +export function resolveProfileDir(name: string, home: string = resolveDshHome()): string { + if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..' + // The launcher-maintained flat module fallback lives at this sibling path. + || name === 'node_modules') { + throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`) + } + return join(home, PROFILES_DIR, name) +} + +/** The shipped profile templates auto-initialized on first use, by name. */ +export const PROFILE_TEMPLATES: Record = { + web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], + headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'], +} + +/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */ +export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base'] + +const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: +# a top-level YAML array of loader patch entries (id-targeted config +# overrides, disables, and insert lists; \`!!js\` expressions allowed). +[] +` + +// The hoisted linker gives out-of-tree plugins a flat node_modules whose +// missing peers (cordis and friends) fall through to the healed +// profiles/node_modules installation fallback, so every plugin shares the +// installation's single cordis instance instead of a duplicate. pnpm ≥10 +// reads its settings from pnpm-workspace.yaml, not .npmrc. +const PROFILE_PNPM_WORKSPACE = `packages: + - . + +nodeLinker: hoisted +autoInstallPeers: false +` + +/** + * Initialize a profile directory: manifest, empty user patch layer, and the + * pnpm settings out-of-tree plugins need. Existing files are never touched, + * so re-running is a no-op on an initialized profile. + * @param dir - the profile directory from {@link resolveProfileDir}. + * @param bundles - the initial `dsh.profile.bundles` layer list. + */ +export function initProfile(dir: string, bundles: readonly string[]): void { + mkdirSync(dir, { recursive: true }) + const manifestPath = join(dir, 'package.json') + if (!existsSync(manifestPath)) { + const manifest: ProfileManifest & { private: boolean } = { + name: `dsh-profile-${basename(dir)}`, + private: true, + dependencies: {}, + dsh: { profile: { bundles: [...bundles] } }, + } + writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n') + } + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE) + const workspacePath = join(dir, 'pnpm-workspace.yaml') + if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE) +} + +/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */ +function ensureSymlink(link: string, target: string): void { + let stat + try { + stat = lstatSync(link) + } catch { + // Missing link (first run) — created below. Any other lstat failure on a + // path we just created the parent of would resurface on symlinkSync. + stat = undefined + } + if (stat !== undefined) { + if (!stat.isSymbolicLink()) { + throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) + } + if (readlinkSync(link) === target) return + rmSync(link) + } + try { + symlinkSync(target, link, 'junction') + } catch (error) { + // Concurrent launches heal the same fallback; losing the race to a + // process writing the identical link is success, anything else is not. + // The window between the lstat miss above and this write cannot be + // staged deterministically from the public surface. + /* v8 ignore next 4 */ + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' + || !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) { + throw error + } + } +} + +/** + * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one + * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS + * over `dependencies` from the app manifest), each resolved from its own + * real location. Node's parent-directory walk from any profile finds this + * directory after the profile's own `node_modules`, so every in-box plugin + * resolves without pnpm ever managing it — the exact "bundles come from the + * installation" contract. The closure (not just direct dependencies) is + * required for out-of-tree plugins: their peer dependencies name seam + * packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only + * through its implementation packages. Symlinked packages resolve their own + * dependencies from their real directories (Node's default + * symlink-following), so each package needs only its one flat link. + * Idempotent: correct links are kept and moved installations are + * re-pointed; a stale link to a vanished package stays until its name is + * reused (dangling links are invisible to resolution). + * @param installAnchor - absolute path of the dsh app's package.json. + * @param home - the Harness home; defaults to {@link resolveDshHome}. + */ +export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void { + const profilesDir = join(home, PROFILES_DIR) + const modulesDir = join(profilesDir, 'node_modules') + mkdirSync(modulesDir, { recursive: true }) + const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest + const links = new Map() + /* v8 ignore next -- a real app manifest always declares its name */ + if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor)) + // BFS over the resolvable dependency graph; the visited set is the link + // map itself (first resolution wins, matching Node's own nearest-wins). + const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }] + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + // Peer dependencies participate: seam packages (dsh-subprocess, + // dsh-compact, ...) are peers of their implementations, never plain + // dependencies, yet out-of-tree plugins import them directly. + /* v8 ignore next -- a real app manifest always declares dependencies */ + for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { + if (links.has(dep)) continue + const dir = packageDirFromAnchor(next.anchor, dep) + // A declared-but-uninstalled dependency cannot be a loader-visible + // plugin; skip it rather than fail the whole boot. + if (dir === undefined) continue + links.set(dep, dir) + const manifestPath = join(dir, 'package.json') + queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) + } + } + for (const [packageName, target] of links) { + const link = join(modulesDir, packageName) + mkdirSync(dirname(link), { recursive: true }) + ensureSymlink(link, target) + } +} + +/** + * Read a profile's manifest. + * @param binName - the diagnostic prefix on the thrown error. + * @param dir - the profile directory. + * @returns the parsed manifest. + */ +export function readProfileManifest(binName: string, dir: string): ProfileManifest { + const path = join(dir, 'package.json') + let raw: string + try { + raw = readFileSync(path, 'utf8') + } catch (error) { + throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`) + } + // File boundary: the shape check below validates what the parse type asserts. + const parsed = JSON.parse(raw) as ProfileManifest | null + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`) + } + return parsed +} + +/** + * Write a profile's manifest back (2-space JSON, trailing newline). + * @param dir - the profile directory. + * @param manifest - the manifest value to persist. + */ +export function writeProfileManifest(dir: string, manifest: ProfileManifest): void { + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') +} + +/** + * Resolve a package's root directory from one anchor without depending on the + * package exporting `./package.json` (`require.resolve` would need that): + * probe the require resolution paths for a directory holding the named + * manifest. This is Node's own node_modules lookup order, so the result + * matches what the Loader would import from the same anchor, and + * `existsSync` follows the symlinks pnpm's isolated layout uses. + */ +function packageDirFromAnchor(anchor: string, packageName: string): string | undefined { + // resolve.paths returns null only for builtins, which no bundle name is. + /* v8 ignore next */ + for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) { + const candidate = join(searchPath, packageName) + if (existsSync(join(candidate, 'package.json'))) return candidate + } + return undefined +} + +/** + * Resolve one bundle package's directory: installation anchor first, then the + * profile directory. The installation-first order is the contract that + * `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from + * the same installation as the running dsh, never from a profile-local copy. + * Resolution does not require the package to export `./package.json`. + * @param binName - the diagnostic prefix on the thrown error. + * @param packageName - the bundle's package name from `dsh.profile.bundles`. + * @param installAnchor - absolute path of a file inside the dsh app package (its package.json). + * @param profileDir - the profile directory (second anchor). + * @returns the bundle package's absolute directory. + */ +export function resolveBundleDir( + binName: string, packageName: string, installAnchor: string, profileDir: string, +): string { + for (const anchor of [installAnchor, join(profileDir, 'package.json')]) { + const dir = packageDirFromAnchor(anchor, packageName) + if (dir !== undefined) return dir + } + throw new Error( + `${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; ` + + `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`, + ) +} + +/** + * Load a profile: resolve every `dsh.profile.bundles` entry to its patch + * layer and parse the profile's own patch file. A listed bundle without a + * `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer + * is a misconfiguration, not "no patches". + * @param binName - the diagnostic prefix on thrown errors. + * @param name - the profile name. + * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor). + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a + * bundles-only consumer (`--dump-default-config`, a recovery diagnostic) + * cannot fail on a broken user layer. + * @returns the loaded profile (empty `patches` when the user layer is skipped). + */ +export function loadProfile( + binName: string, name: string, installAnchor: string, home: string = resolveDshHome(), + options: { userLayer?: boolean } = {}, +): Profile { + const dir = resolveProfileDir(name, home) + if (!existsSync(join(dir, 'package.json'))) { + const template = PROFILE_TEMPLATES[name] + if (template === undefined) { + throw new Error( + `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add '`, + ) + } + initProfile(dir, template) + } + const manifest = readProfileManifest(binName, dir) + // A hand-written profile manifest may omit the dsh section entirely. + const bundles = manifest.dsh?.profile?.bundles ?? [] + const layers = bundles.map((packageName): ProfileLayer => { + const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) + const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest + const declared = bundleManifest.dsh?.bundle?.patch + if (declared === undefined) { + throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) + } + const patchPath = join(packageDir, declared) + return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + }) + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + const patches = options.userLayer !== false && existsSync(patchPath) + ? loadOverlayPatches(binName, patchPath) + : [] + return { name, dir, layers, patchPath, patches } +} + +/** + * Compose patch layers into the effective entry list over an empty root — + * the same single `applyEntryPatches` call the boot include makes, so flag + * derivation and config dumps see exactly what mounts. + * @param layers - patch lists in application order. + * @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them). + * @returns the composed entry list. + */ +export function composeEntries( + layers: readonly PatchOptions[][], warn: (message: string) => void = () => {}, +): EntryOptions[] { + return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => { + let index = 0 + warn(message.replace(/%C/g, () => JSON.stringify(args[index++]))) + }) +} diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 1e88954f69..45eab9ea7d 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -341,11 +341,11 @@ describe('include refresh with overlay patches', () => { describe('include patches layered over one base', () => { it('lets a later patch configure or disable a row an earlier patch inserted', async () => { - // The surface/`--config`/personal composition: `dsh` includes one shared - // base and applies each source as its own patch list at the SAME include + // The bundle/user-layer/`--patch` composition: `dsh` includes one root + // and applies each source as its own patch list at the SAME include // level, because patches never cross an include boundary. A later layer // must therefore be able to reach a row an earlier layer inserted, or - // surface-only rows would be invisible to the user's personal config. + // bundle-only rows would be invisible to the user's patch layer. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') @@ -355,30 +355,30 @@ describe('include patches layered over one base', () => { ' config:', ' path: ./base.yml', ' patches:', - // Layer 1 (a surface overlay): patch a base row and add two of its own. + // Layer 1 (a bundle layer): patch a base row and add two of its own. ' - id: shared', ' config:', - ' value: surface', + ' value: bundle', ' - insert:', - ' - id: surface-kept', + ' - id: bundle-kept', ' name: ./noop.mjs', ' config:', - ' value: surface-default', - ' - id: surface-dropped', + ' value: bundle-default', + ' - id: bundle-dropped', ' name: ./noop.mjs', // Layer 2 (the user): reconfigure one inserted row and disable the other. - ' - id: surface-kept', + ' - id: bundle-kept', ' config:', - ' value: personal', - ' - id: surface-dropped', + ' value: user', + ' - id: bundle-dropped', ' disabled: true', '', ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) try { - expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' }) - expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' }) - const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped') + expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' }) + expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' }) + const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped') expect(dropped?.options.disabled).toBe(true) expect(dropped?.fiber).toBeUndefined() } finally { diff --git a/packages/ui/app-boot/tests/profile.spec.ts b/packages/ui/app-boot/tests/profile.spec.ts new file mode 100644 index 0000000000..f0bd6f5da7 --- /dev/null +++ b/packages/ui/app-boot/tests/profile.spec.ts @@ -0,0 +1,245 @@ +/** + * Profile machinery of `dsh-app-boot`: directory resolution and init, + * manifest round-trips, two-anchor bundle resolution, patch-layer loading, + * empty-root composition, and the installation module-fallback healing. + */ + +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + composeEntries, + healProfilesModuleFallback, + initProfile, + loadProfile, + PROFILE_PATCH_FILENAME, + PROFILE_TEMPLATES, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + writeProfileManifest, +} from '../src/index.ts' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-')) + +/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ +function stageInstallation(bundles: Record }>): string { + const root = tmp() + const appDir = join(root, 'app') + mkdirSync(join(appDir, 'node_modules'), { recursive: true }) + const appDeps: Record = {} + for (const [name, spec] of Object.entries(bundles)) { + appDeps[name] = '0.0.0' + const dir = join(appDir, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name, + version: '0.0.0', + dependencies: spec.deps ?? {}, + ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } }, + })) + if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch) + } + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps })) + return join(appDir, 'package.json') +} + +describe('resolveProfileDir', () => { + it('joins the home and rejects traversal-shaped names', () => { + const home = tmp() + expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui')) + for (const bad of ['', '.', '..', 'a/b', 'a\\b']) { + expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name') + } + }) +}) + +describe('initProfile', () => { + it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => { + const home = tmp() + const dir = resolveProfileDir('tui', home) + initProfile(dir, ['@deepseek-ai/dsh-base']) + const manifest = readProfileManifest('t', dir) + expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]') + expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted') + // Re-init keeps user edits. + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n') + initProfile(dir, ['other']) + expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x') + }) +}) + +describe('manifest round-trip', () => { + it('writes and reads back, and fails loud on a broken manifest', () => { + const dir = tmp() + writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } }) + expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a']) + writeFileSync(join(dir, 'package.json'), '[]') + expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object') + expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest') + }) +}) + +describe('resolveBundleDir', () => { + it('prefers the installation anchor, falls back to the profile, and fails loud', () => { + const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } }) + const profileDir = tmp() + mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), '{}') + writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' })) + expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box') + expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only') + expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle') + }) + + it('resolves a package whose exports map omits ./package.json', () => { + // Common on npm: an exports map without "./package.json" makes + // require.resolve('/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED; + // resolution must fall through to the paths probe instead of misreporting + // the installed package as missing. + const anchor = stageInstallation({}) + const profileDir = tmp() + writeFileSync(join(profileDir, 'package.json'), '{}') + const dir = join(profileDir, 'node_modules', 'sealed-bundle') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'sealed-bundle', + version: '0.0.0', + exports: { '.': './index.js' }, + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(dir, 'index.js'), '') + writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n') + expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir) + }) +}) + +describe('loadProfile', () => { + it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' }, + 'bundle-b': { patch: '- id: a\n config:\n v: 2\n' }, + }) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['bundle-a', 'bundle-b']) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n') + const profile = loadProfile('t', 'demo', anchor, home) + expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b']) + expect(profile.patches).toHaveLength(1) + const entries = composeEntries([ + ...profile.layers.map(layer => layer.patches), + profile.patches, + ]) + expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }]) + // A hand-made profile without the user layer file or dsh section: empty layers, no throw. + rmSync(join(dir, PROFILE_PATCH_FILENAME)) + expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([]) + writeProfileManifest(dir, { name: 'bare' }) + const bare = loadProfile('t', 'demo', anchor, home) + expect(bare.layers).toEqual([]) + }) + + it('auto-initializes only shipped templates and fails loud otherwise', () => { + const anchor = stageInstallation({}) + const home = tmp() + expect(() => loadProfile('t', 'custom', anchor, home)) + .toThrow('profile "custom" does not exist') + // The web template auto-initializes on first load. Bundle resolution + // cannot be asserted to fail here: the source-plane test runner resolves + // @deepseek-ai/* through tsconfig paths regardless of the staged anchor. + expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base') + try { + loadProfile('t', 'web', anchor, home) + } catch { + // Resolution failure is the plain-Node outcome for this empty anchor. + } + expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles) + .toEqual([...PROFILE_TEMPLATES.web ?? []]) + }) + + it('fails loud when a listed bundle declares no dsh.bundle', () => { + const anchor = stageInstallation({ 'not-a-bundle': {} }) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['not-a-bundle']) + expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle') + }) +}) + +describe('composeEntries', () => { + it('applies layers over an empty root and reports skipped patches', () => { + const warnings: string[] = [] + const entries = composeEntries([ + [{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }], + [{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }], + ], message => warnings.push(message)) + expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }]) + expect(warnings.join('\n')).toContain('"missing"') + // Default warn sink: skipped patches are silently dropped (boot repeats them). + expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([]) + }) +}) + +describe('healProfilesModuleFallback', () => { + it('links the app and bundle dependency surface flat under profiles/node_modules', () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } }, + 'plain-lib': {}, + }) + // An app dependency that is declared but not installed: skipped, not fatal. + const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record } + appManifest.dependencies['never-installed'] = '0.0.0' + writeFileSync(anchor, JSON.stringify(appManifest)) + // dep-of-a lives in the installation's node_modules too. + const modules = join(anchor, '..', 'node_modules') + mkdirSync(join(modules, 'dep-of-a'), { recursive: true }) + writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' })) + const home = tmp() + healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + // App deps, the bundle's own deps, and the bundle itself are linked; the + // plain library is linked as an app dep (harmless), the app itself too. + for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) { + expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true) + } + // Idempotent, and a moved target is re-pointed. + healProfilesModuleFallback(anchor, home) + const before = readlinkSync(join(fallback, 'dep-of-a')) + expect(before).toContain('dep-of-a') + }) + + it('throws when a fallback entry is a real directory', () => { + const anchor = stageInstallation({}) + const home = tmp() + mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true }) + expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink') + }) + + it('replaces a wrong symlink', () => { + const anchor = stageInstallation({}) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules') + mkdirSync(fallback, { recursive: true }) + symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction') + healProfilesModuleFallback(anchor, home) + expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') + }) + + it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => { + // The EEXIST arm: a second process wrote the link between our lstat miss + // and symlinkSync. Simulated by pre-creating the correct link and calling + // the internal path through a stale-lstat shim is not possible from + // outside, so probe the observable contract: healing twice concurrently + // is a no-op, and a foreign REAL directory still fails loud. + const anchor = stageInstallation({}) + const home = tmp() + healProfilesModuleFallback(anchor, home) + healProfilesModuleFallback(anchor, home) // second healer sees the correct link + const fallback = join(home, 'profiles', 'node_modules') + expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) + }) +}) diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/user-patches.spec.ts similarity index 63% rename from packages/ui/app-boot/tests/personal-config.spec.ts rename to packages/ui/app-boot/tests/user-patches.spec.ts index 7c92d53e56..333385ee50 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/user-patches.spec.ts @@ -1,7 +1,7 @@ /** - * Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`) - * `config.yaml` overlay loader and `boot()` applying the personal overlay over - * a real Loader tree. + * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader + * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over + * a real Loader tree, kept live through transactional HMR. */ import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' @@ -15,14 +15,14 @@ import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' import { boot, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, - watchPersonalPatches, + loadOptionalPatches, + PROFILE_PATCH_FILENAME, + watchUserPatches, } from '../src/index.ts' const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) async function eventually(test: () => boolean, message: string): Promise { const deadline = Date.now() + 10_000 @@ -34,20 +34,20 @@ async function eventually(test: () => boolean, message: string): Promise { const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) -describe('loadPersonalPatches', () => { +describe('loadOptionalPatches', () => { afterEach(() => { delete process.env.DSH_HOME }) - it('returns undefined when no personal patches file exists', () => { - expect(loadPersonalPatches(NAME, tmp())).toBeUndefined() + it('returns undefined when no user patch file exists', () => { + expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined() }) it('parses a patch list and preserves !!js expressions as loader expression nodes', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [ + '- id: agent-loop', + " name: '@deepseek-ai/dsh-agent-loop'", ' config:', ' model: !!js process.env.DSH_SPEC_MODEL', '- insert:', @@ -55,51 +55,44 @@ describe('loadPersonalPatches', () => { " name: '@deepseek-ai/dsh-llm-pi-ai'", '', ].join('\n')) - const patches = loadPersonalPatches(NAME, dir) + const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)) expect(patches).toHaveLength(2) expect(patches?.[0]).toMatchObject({ - id: 'tui-agent', + id: 'agent-loop', config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } }, }) expect(patches?.[1]?.insert).toHaveLength(1) }) - it('defaults its directory to the Harness home ($DSH_HOME)', () => { + it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n') - process.env.DSH_HOME = dir - expect(loadPersonalPatches(NAME)).toHaveLength(1) - }) - - it('fails loud on an unreadable file (a present personal config is never skipped)', () => { - const dir = tmp() - mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to read personal patches `)) + mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to read patches `)) }) it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to parse patches `)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config:\n a: !!js\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to parse patches `)) }) it('fails loud when the file is not a top-level array or an entry is not an object', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n') - expect(() => loadPersonalPatches(NAME, dir)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) .toThrow('must be a top-level YAML array of loader patch entries') - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(`${NAME}: personal patches entry 1 in`) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(`${NAME}: patches entry 1 in`) }) }) -describe('boot with personal patches', () => { +describe('boot with user patches', () => { function writeTree(dir: string): string { writeFileSync(join(dir, 'noop.mjs'), [ 'export const name = "noop"', @@ -118,41 +111,41 @@ describe('boot with personal patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() - const personal = tmp() - writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [ + const userDir = tmp() + writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [ '- id: noop', ' name: ./noop.mjs', ' config:', - ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC', + ' value: !!js process.env.DSH_APP_BOOT_USER_SPEC', '- insert:', - ' - id: personal-extra', + ' - id: user-extra', ' name: ./noop.mjs', '', ].join('\n')) - process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value' - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal)) + process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value' + const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME))) try { const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'personal-value' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true) + expect(noop?.fiber?.config).toEqual({ value: 'user-value' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true) } finally { await ctx.fiber.dispose() - delete process.env['DSH_APP_BOOT_PERSONAL_SPEC'] + delete process.env['DSH_APP_BOOT_USER_SPEC'] } }) - it('mounts no patch layer for an absent or empty personal overlay', async () => { + it('mounts no patch layer for an absent or empty user layer', async () => { const dir = tmp() - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp())) + const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))) try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' }) } finally { await ctx.fiber.dispose() } const empty = tmp() - writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n') - const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty)) + writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n') + const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME))) try { expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' }) } finally { @@ -162,8 +155,8 @@ describe('boot with personal patches', () => { 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 userDir = tmp() + const filename = join(userDir, PROFILE_PATCH_FILENAME) const basePatches = [{ id: 'noop', config: { value: 'generated' } }] const ctx = await boot(NAME, writeTree(dir), basePatches) await ctx.plugin(Timer) @@ -172,14 +165,14 @@ describe('boot with personal patches', () => { ctx.on('hmr/config-update-failed', (failedFilename, error) => { failures.push({ filename: failedFilename, error }) }) - const dispose = await watchPersonalPatches(ctx, { + const dispose = await watchUserPatches(ctx, { binName: NAME, - dir: personal, - compose: personalPatches => [...basePatches, ...personalPatches], + filename, + compose: userPatches => [...basePatches, ...userPatches], }) 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') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied') writeFileSync(filename, '- id: noop\n config:\n fail: true\n') await eventually(() => failures.length === 1, 'failed candidate was not broadcast') @@ -199,17 +192,17 @@ describe('boot with personal patches', () => { await settleChokidarChangeThrottle() unlinkSync(filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch 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 + // Default compose: the user layer 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 }) + const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename }) 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') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied') } finally { await disposeDefault() } @@ -222,7 +215,7 @@ describe('boot with personal patches', () => { 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 expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service') await withoutHmr.fiber.dispose() const withoutInclude = new Context() @@ -230,7 +223,7 @@ describe('boot with personal patches', () => { 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 expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry') await withoutInclude.fiber.dispose() }) @@ -245,7 +238,7 @@ describe('boot with personal patches', () => { 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() }) + const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) await expect(dispose()).resolves.toBeUndefined() } finally { await ctx.fiber.dispose() @@ -254,14 +247,14 @@ describe('boot with personal patches', () => { it('propagates registration failures other than mid-teardown', async () => { const dir = tmp() - const personal = tmp() + const filename = join(tmp(), PROFILE_PATCH_FILENAME) 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') + const dispose = await watchUserPatches(ctx, { binName: NAME, filename }) + // Same user-layer path registered twice: HMR refuses; not a teardown race. + await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered') await dispose() } finally { await ctx.fiber.dispose() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e50fa70b2a..021482461d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,351 +140,45 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:* version: link:../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../packages/core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../packages/core/agent-loop '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot - '@deepseek-ai/dsh-bash-env': + '@deepseek-ai/dsh-base': specifier: workspace:^ - version: link:../../packages/bash/bash-env - '@deepseek-ai/dsh-bash-local': + version: link:../../packages/bundle/base + '@deepseek-ai/dsh-headless': specifier: workspace:^ - version: link:../../packages/bash/bash-local - '@deepseek-ai/dsh-bash-sandbox': - specifier: workspace:^ - version: link:../../packages/bash/bash-sandbox - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../packages/client/connection - '@deepseek-ai/dsh-client-hmr': - specifier: workspace:^ - version: link:../../packages/client/hmr - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../packages/client/locale - '@deepseek-ai/dsh-client-modules': - specifier: workspace:^ - version: link:../../packages/client/modules - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../packages/client/runtime - '@deepseek-ai/dsh-client-ui-command': - specifier: workspace:^ - version: link:../../packages/client/ui-command - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../../packages/client/ui-conversation - '@deepseek-ai/dsh-client-ui-deliverables': - specifier: workspace:^ - version: link:../../packages/client/ui-deliverables - '@deepseek-ai/dsh-client-ui-goal': - specifier: workspace:^ - version: link:../../packages/client/ui-goal - '@deepseek-ai/dsh-client-ui-layout': - specifier: workspace:^ - version: link:../../packages/client/ui-layout - '@deepseek-ai/dsh-client-ui-model': - specifier: workspace:^ - version: link:../../packages/client/ui-model - '@deepseek-ai/dsh-client-ui-models': - specifier: workspace:^ - version: link:../../packages/client/ui-models - '@deepseek-ai/dsh-client-ui-permission': - specifier: workspace:^ - version: link:../../packages/client/ui-permission - '@deepseek-ai/dsh-client-ui-plan': - specifier: workspace:^ - version: link:../../packages/client/ui-plan - '@deepseek-ai/dsh-client-ui-question': - specifier: workspace:^ - version: link:../../packages/client/ui-question - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../../packages/client/ui-settings - '@deepseek-ai/dsh-client-ui-settings-general': - specifier: workspace:^ - version: link:../../packages/client/ui-settings-general - '@deepseek-ai/dsh-client-ui-sidebar': - specifier: workspace:^ - version: link:../../packages/client/ui-sidebar - '@deepseek-ai/dsh-client-ui-skill': - specifier: workspace:^ - version: link:../../packages/client/ui-skill - '@deepseek-ai/dsh-client-ui-slash': - specifier: workspace:^ - version: link:../../packages/client/ui-slash - '@deepseek-ai/dsh-client-ui-subagent': - specifier: workspace:^ - version: link:../../packages/client/ui-subagent - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../../packages/client/ui-theme - '@deepseek-ai/dsh-client-ui-trajectory': - specifier: workspace:^ - version: link:../../packages/client/ui-trajectory - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../packages/client/ui-workspace - '@deepseek-ai/dsh-code-runtime-worker': - specifier: workspace:^ - version: link:../../packages/code-runtime/code-runtime-worker - '@deepseek-ai/dsh-command-compact': - specifier: workspace:^ - version: link:../../packages/compact/command-compact - '@deepseek-ai/dsh-command-goal': - specifier: workspace:^ - version: link:../../packages/goal/command-goal - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../packages/ui/commands - '@deepseek-ai/dsh-compact-basic': - specifier: workspace:^ - version: link:../../packages/compact/compact-basic - '@deepseek-ai/dsh-compact-tool-result-prune': - specifier: workspace:^ - version: link:../../packages/compact/compact-tool-result-prune - '@deepseek-ai/dsh-credentials-local': - specifier: workspace:^ - version: link:../../packages/credentials/credentials-local - '@deepseek-ai/dsh-frontend': - specifier: workspace:^ - version: link:../web - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../packages/fs/fs-local - '@deepseek-ai/dsh-fs-policy': - specifier: workspace:^ - version: link:../../packages/fs/fs-policy - '@deepseek-ai/dsh-fs-sandbox': - specifier: workspace:^ - version: link:../../packages/fs/fs-sandbox - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../packages/goal/goal - '@deepseek-ai/dsh-goal-session': - specifier: workspace:^ - version: link:../../packages/goal/goal-session - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-auto': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-auto - '@deepseek-ai/dsh-host-directory-picker-browse': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-browse - '@deepseek-ai/dsh-host-directory-picker-native': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-native - '@deepseek-ai/dsh-host-webserver': - specifier: workspace:^ - version: link:../../packages/host/webserver - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../packages/llm/llm - '@deepseek-ai/dsh-llm-deepseek': - specifier: workspace:^ - version: link:../../packages/llm/llm-deepseek - '@deepseek-ai/dsh-llm-pi-ai': - specifier: workspace:^ - version: link:../../packages/llm/llm-pi-ai - '@deepseek-ai/dsh-llm-retry': - specifier: workspace:^ - version: link:../../packages/llm/llm-retry + version: link:../../packages/bundle/headless '@deepseek-ai/dsh-mcp-client': specifier: workspace:^ version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../packages/ui/permission - '@deepseek-ai/dsh-plan-mode': - specifier: workspace:^ - version: link:../../packages/plan/plan-mode '@deepseek-ai/dsh-pty': specifier: workspace:^ version: link:../../packages/pty/pty '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local - '@deepseek-ai/dsh-pwsh-local': + '@deepseek-ai/dsh-session-reference': specifier: workspace:^ - version: link:../../packages/bash/pwsh-local - '@deepseek-ai/dsh-repeat-tool-guard': + version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ - version: link:../../packages/guard/repeat-tool-guard - '@deepseek-ai/dsh-repository-plugin': + version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ - version: link:../../packages/cordis/repository-plugin - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-policy - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../packages/core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../packages/core/session - '@deepseek-ai/dsh-session-checkpoint-policy': - specifier: workspace:^ - version: link:../../packages/session-persistence/session-checkpoint-policy - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../packages/session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../packages/session-projection/session-projection - '@deepseek-ai/dsh-session-projection-cache': - specifier: workspace:^ - version: link:../../packages/session-projection/session-projection-cache - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../packages/session-query/session-query - '@deepseek-ai/dsh-session-query-sqlite': - specifier: workspace:^ - version: link:../../packages/session-query/session-query-sqlite - '@deepseek-ai/dsh-session-telemetry-otel': - specifier: workspace:^ - version: link:../../packages/telemetry/session-telemetry-otel - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../packages/session-title/session-title - '@deepseek-ai/dsh-session-title-first-message-llm': - specifier: workspace:^ - version: link:../../packages/session-title/session-title-first-message-llm - '@deepseek-ai/dsh-settings-local': - specifier: workspace:^ - version: link:../../packages/settings/settings-local - '@deepseek-ai/dsh-skill': - specifier: workspace:^ - version: link:../../packages/skill/skill - '@deepseek-ai/dsh-skill-local': - specifier: workspace:^ - version: link:../../packages/skill/skill-local - '@deepseek-ai/dsh-spill-local': - specifier: workspace:^ - version: link:../../packages/spill/spill-local - '@deepseek-ai/dsh-spill-policy': - specifier: workspace:^ - version: link:../../packages/spill/spill-policy - '@deepseek-ai/dsh-storage': - specifier: workspace:^ - version: link:../../packages/storage/storage - '@deepseek-ai/dsh-storage-domain': - specifier: workspace:^ - version: link:../../packages/storage/storage-domain - '@deepseek-ai/dsh-storage-json': - specifier: workspace:^ - version: link:../../packages/storage/storage-json - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../packages/subagent/subagent - '@deepseek-ai/dsh-subagent-fork': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-fork - '@deepseek-ai/dsh-subagent-spawn': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-spawn - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../packages/subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../packages/core/system-prompt - '@deepseek-ai/dsh-tasks-local': - specifier: workspace:^ - version: link:../../packages/tasks/tasks-local - '@deepseek-ai/dsh-timeout-policy': - specifier: workspace:^ - version: link:../../packages/timeout/timeout-policy - '@deepseek-ai/dsh-token-meter': - specifier: workspace:^ - version: link:../../packages/llm/token-meter - '@deepseek-ai/dsh-tool-bash': - specifier: workspace:^ - version: link:../../packages/bash/tool-bash + version: link:../../packages/ui/tool-ask-user '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis - '@deepseek-ai/dsh-tool-fs': + '@deepseek-ai/dsh-web-app': specifier: workspace:^ - version: link:../../packages/fs/tool-fs - '@deepseek-ai/dsh-tool-fs-search': - specifier: workspace:^ - version: link:../../packages/fs/tool-fs-search - '@deepseek-ai/dsh-tool-goal': - specifier: workspace:^ - version: link:../../packages/goal/tool-goal - '@deepseek-ai/dsh-tool-pwsh': - specifier: workspace:^ - version: link:../../packages/bash/tool-pwsh - '@deepseek-ai/dsh-tool-ralph': - specifier: workspace:^ - version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../packages/skill/tool-skill - '@deepseek-ai/dsh-tool-str-replace-editor': - specifier: workspace:^ - version: link:../../packages/fs/tool-str-replace-editor - '@deepseek-ai/dsh-tool-subagent': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent - '@deepseek-ai/dsh-tool-subagent-control': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-subagent-report': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-tasks': - specifier: workspace:^ - version: link:../../packages/tasks/tool-tasks - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../packages/todo/tool-todo - '@deepseek-ai/dsh-tool-web': - specifier: workspace:^ - version: link:../../packages/web/tool-web - '@deepseek-ai/dsh-tool-workflow': - specifier: workspace:^ - version: link:../../packages/workflow/tool-workflow - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../packages/core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../packages/ui/user-approval - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../packages/ui/user-interaction - '@deepseek-ai/dsh-web': - specifier: workspace:^ - version: link:../../packages/web/web - '@deepseek-ai/dsh-web-search-deepseek': - specifier: workspace:^ - version: link:../../packages/web/web-search-deepseek - '@deepseek-ai/dsh-workflow-workerthread': - specifier: workspace:^ - version: link:../../packages/workflow/workflow-workerthread - '@deepseek-ai/dsh-workspace': - specifier: workspace:^ - version: link:../../packages/workspace/workspace - '@deepseek-ai/dsh-workspace-context': - specifier: workspace:^ - version: link:../../packages/context/workspace-context + version: link:../../packages/bundle/web-app commander: specifier: ^15.0.0 version: 15.0.0 @@ -498,6 +192,24 @@ importers: specifier: ^0.1.4 version: 0.1.4 devDependencies: + '@deepseek-ai/dsh-frontend-static': + specifier: workspace:^ + version: link:../../packages/host/frontend-static + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../packages/host/webserver + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../packages/support/loader-smoke + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../packages/core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../packages/core/tools '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -1133,6 +845,378 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bundle/base: + dependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:* + version: link:../../../vendor/hmr + '@cordisjs/plugin-timer': + specifier: workspace:* + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../bash/bash-sandbox + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../compact/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../goal/goal-session + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../llm/llm-pi-ai + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../plan/plan-mode + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:^ + version: link:../../guard/repeat-tool-guard + '@deepseek-ai/dsh-repository-plugin': + specifier: workspace:^ + version: link:../../cordis/repository-plugin + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../session-persistence/session-checkpoint-policy + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../session-query/session-query-sqlite + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:^ + version: link:../../telemetry/session-telemetry-otel + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + '@deepseek-ai/dsh-session-title-first-message-llm': + specifier: workspace:^ + version: link:../../session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../settings/settings-local + '@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-spill-local': + specifier: workspace:^ + version: link:../../spill/spill-local + '@deepseek-ai/dsh-spill-policy': + specifier: workspace:^ + version: link:../../spill/spill-policy + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../../subagent/subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../timeout/timeout-policy + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../fs/tool-str-replace-editor + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-subagent-report': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-report + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../web/web + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../web/web-search-deepseek + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/bundle/headless: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/bundle/web-app: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-hmr': + specifier: workspace:^ + version: link:../../client/hmr + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../../client/ui-command + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../../client/ui-deliverables + '@deepseek-ai/dsh-client-ui-goal': + specifier: workspace:^ + version: link:../../client/ui-goal + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-model': + specifier: workspace:^ + version: link:../../client/ui-model + '@deepseek-ai/dsh-client-ui-models': + specifier: workspace:^ + version: link:../../client/ui-models + '@deepseek-ai/dsh-client-ui-permission': + specifier: workspace:^ + version: link:../../client/ui-permission + '@deepseek-ai/dsh-client-ui-plan': + specifier: workspace:^ + version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-question': + specifier: workspace:^ + version: link:../../client/ui-question + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-general': + specifier: workspace:^ + version: link:../../client/ui-settings-general + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../../client/ui-sidebar + '@deepseek-ai/dsh-client-ui-skill': + specifier: workspace:^ + version: link:../../client/ui-skill + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../../client/ui-slash + '@deepseek-ai/dsh-client-ui-subagent': + specifier: workspace:^ + version: link:../../client/ui-subagent + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../../client/ui-theme + '@deepseek-ai/dsh-client-ui-trajectory': + specifier: workspace:^ + version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime-worker + '@deepseek-ai/dsh-frontend': + specifier: workspace:^ + version: link:../../../apps/web + '@deepseek-ai/dsh-frontend-static': + specifier: workspace:^ + version: link:../../host/frontend-static + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-auto': + specifier: workspace:^ + version: link:../../host/directory-picker-auto + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../../host/directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session-projection/session-projection-cache + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/connection: dependencies: '@deepseek-ai/dsh-commands': @@ -3748,6 +3832,25 @@ importers: specifier: ^4.19.2 version: 4.22.4 + packages/host/frontend-static: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/host/webserver: dependencies: schemastery: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6bd613c2ea..e0b9344cdf 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -102,6 +102,10 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { + // Profile bundles publish their dsh.bundle.patch layer beside the lib. + '@deepseek-ai/dsh-base': ['cordis.patch.yml'], + '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], + '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs index 64fbe0e72d..43a23ab250 100644 --- a/scripts/demo-cordis.mjs +++ b/scripts/demo-cordis.mjs @@ -6,7 +6,7 @@ import { spawn } from 'node:child_process' const SURFACES = new Map([ // The browser surface with the cordis toolset layered on: `dsh web --config` // applies this overlay over the shipped web composition; it owns port 3081. - ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--config', 'examples/web-cordis/cordis.yml']], + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--patch', 'examples/web-cordis/cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], ]) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1dd7973fd5..dd00068fe4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -598,7 +598,8 @@ function parseExampleCordis(rel: string): ExamplePlugin[] { if (current?.name) plugins.push({ id: current.id, name: current.name }) } for (const line of text.split('\n')) { - const id = /^-\s+id:\s+(.+?)\s*$/.exec(line) + // Top-level rows (`- id:`) and bundle-patch insert rows (` - id:`). + const id = /^\s*-\s+id:\s+(.+?)\s*$/.exec(line) if (id?.[1] !== undefined) { flush() current = { id: stripYamlScalar(id[1]) } @@ -620,9 +621,9 @@ const APP_EXAMPLES = [ id: 'dsh_base', rel: 'apps/cli/composition.md', title: 'DSH Base Composition', - label: 'apps/cli/config/base.cordis.yml', - config: 'apps/cli/config/base.cordis.yml', - summary: 'The raw CLI applies one required caller-selected patch list over this shared base; Web and headless apply their own shipped overlays.', + label: 'packages/bundle/base/cordis.patch.yml', + config: 'packages/bundle/base/cordis.patch.yml', + summary: 'The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user\'s profile layer patch over it.', }, { id: 'headless', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index d19b42bc8e..f0eb64548e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -392,7 +392,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/scripts/install.sh b/scripts/install.sh index b70652451e..5c9d892f73 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -46,7 +46,7 @@ # DSH_MASTER master clone directory (default: $DSH_SOURCE/master) # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) -# DSH_HOME Harness home holding the personal config (default: ~/.dsh) +# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 0aa189024d..b9d67f4bf6 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Configured runtime\n\nRaw `dsh` requires a patch-list configuration applied over the shipped base:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nThe [CLI contract](apps/cli/README.md#raw-config) describes the base, overlay semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### 自定义运行时\n\n原始 `dsh` 要求传入一份 patch 列表配置,并将其叠加在随附 base 之上:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nbase、overlay 语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#raw-config)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index bdb020a6a0..4c4d83ead6 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -149,11 +149,33 @@ function validateExampleResolution(): string[] { } function validateAppResolution(): string[] { - const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} + const violations: string[] = [] + // App overlays (and any config left under apps/cli/config) resolve from the + // dsh app's own dependency surface — the profile module fallback mirrors it. + const appDependencies = { + ...readManifest('apps/cli/package.json').dependencies, + // The fallback also links every bundle's own dependencies (healProfilesModuleFallback). + ...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root }) + .flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))), + } const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') }) .map(file => `apps/cli/config/${file}`)) - const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file)) - return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') + const appReferences = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file)) + violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest')) + // Each bundle's patch rows must resolve from that bundle's own dependencies: + // per-layer resolution anchors on the bundle package directory. + for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) { + const bundleDir = manifestPath.replace(/\/package\.json$/, '') + const manifest = readManifest(manifestPath) + const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`)) + violations.push(...missingPluginDependencies( + // A bundle may mount its own package (the web-app runtime row). + references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), + manifest.dependencies ?? {}, + manifestPath, + )) + } + return violations } /** diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index cb7ffb7460..53491b89fb 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -87,6 +87,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, + 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' }, + 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' }, + 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 94e08da920..e821e662f5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -93,6 +93,7 @@ "./packages/spill/*/src/invariant.ts", "./packages/timeout/*/src/invariant.ts", "./packages/todo/*/src/invariant.ts", + "./packages/bundle/*/src/invariant.ts", "./packages/cordis/*/src/invariant.ts", "./packages/sandbox/*/src/invariant.ts", "./packages/hooks/*/src/invariant.ts", @@ -192,6 +193,7 @@ "./packages/spill/*/src", "./packages/timeout/*/src", "./packages/todo/*/src", + "./packages/bundle/*/src", "./packages/cordis/*/src", "./packages/sandbox/*/src", "./packages/hooks/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 33251ea600..acde088c8e 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -189,6 +189,9 @@ { "path": "./packages/support/agent-loop-testkit" }, { "path": "./packages/acp/acp" }, { "path": "./packages/examples/acp-demo" }, + { "path": "./packages/bundle/base" }, + { "path": "./packages/bundle/headless" }, + { "path": "./packages/bundle/web-app" }, { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, @@ -232,6 +235,7 @@ // client aggregate's webserver reference. { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, + { "path": "./packages/host/frontend-static" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, diff --git a/vendor/README.md b/vendor/README.md index c59a86ccca..9fa97413c2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,8 +41,8 @@ Keep this log exhaustive — every divergence from upstream must be listed. 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`. -13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the raw invalid-provider built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +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 an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays 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`. +13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. ## Sync procedure diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 2484d0152a..00864cd865 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -215,7 +215,7 @@ class Hmr extends Service { // the scan-triggered refresh waits on that apply — a teardown deadlock // that strands boot without a diagnostic. Only events after the scan // matter here; `registerConfig` keeps its own initial scan because a - // personal config present at registration must apply once. + // user patch layer present at registration must apply once. ignoreInitial: true, }) diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 4a9fd6be86..26b9305c52 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -85,10 +85,10 @@ export function applyEntryPatches( data.push(...insert) } // Index what this patch added so a LATER patch in the same list can - // target it. Patch lists compose one layer per source (surface overlay, - // then `--config`, then the user's), and a layer must be able to - // configure or disable a row an earlier layer inserted; without this, - // inserted rows were silently unpatchable. + // target it. Patch lists compose one layer per source (each bundle + // layer, then the user's, then `--patch` overlays), and a layer must be + // able to configure or disable a row an earlier layer inserted; without + // this, inserted rows were silently unpatchable. buildMap(insert) continue } diff --git a/website/docs.ts b/website/docs.ts index 8d42ae3209..1a9b20b5be 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -166,6 +166,14 @@ const develop = pairedPages([ section: { root: '基础', en: 'Basics' }, order: 3, }, + { + source: 'docs/user/develop/basic/publish.md', + route: 'develop/basic/publish.md', + label: { root: '打包与安装插件', en: 'Package and install' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, + order: 4, + }, { source: 'docs/user/develop/framework/index.md', route: 'develop/framework/index.md',