Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md
#	.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md
#	.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md
#	docs/user/develop/basic/index.i18n.yaml
#	docs/user/develop/basic/index.zh.md
#	docs/user/guide/config.i18n.yaml
#	docs/user/guide/config.zh.md
#	docs/user/guide/providers.i18n.yaml
#	docs/user/guide/providers.zh.md
#	packages/bundle/base/README.i18n.yaml
#	packages/bundle/base/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.zh.md
This commit is contained in:
xjt
2026-08-12 12:59:38 +08:00
200 changed files with 3714 additions and 1718 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md
2026-08-11-loader-entry-disabled-interpolation.md: fd760ea0f15f19e5f287aaddc36fb8eeb5f519ba
2026-08-11-loader-entry-disabled-interpolation.zh.md: 15f5a80931c58555dceab359d05e8515334513b2

View File

@@ -0,0 +1,25 @@
# Agent Note: Loader interpolates the entry `disabled` field
Status: implemented
English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md)
## Problem
The Windows platform layer (then a separate `windows.cordis.patch.yml` beside the base patch, since folded into the base rows — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere.
## Decision
The Loader interpolates the entry `disabled` field (`vendor/loader/src/config/entry.ts`): a `!!js` expression evaluates against the loader context at every mount decision. `disabled` is the only interpolated metadata field; `id`, `name`, `group`, and `inject` stay static. The raw node stays in the options, so write-back keeps the `!!js` form. The shipped presets (standard, code, cordis) declare the shell tool rows themselves and gate them by platform — `tool-bash` with `disabled: !!js process.platform === 'win32'` and its `tool-pwsh` twin with the inverted expression — so the preset layer exposes exactly one shell tool per host; the web-app overlay disables the host rows of both tools, letting each session's preset decide. `verify-cordis-config` now allows expressions in `disabled` only.
The mechanism completes the platform-layer fold: the base bundle's `cordis.patch.yml` gates both shell stacks on its own rows — `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'`, and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression. The launcher's separate Windows platform layer (`windows.cordis.patch.yml` plus `apps/cli/src/windows-shell.ts` and its injection into boot, live recomposition, and config dumps) is deleted — the layer existed only because entry metadata was static, and with `disabled` interpolated the condition lives on the row it governs.
## Alternatives considered
**A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition.
**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs — the same principle folds the launcher's separate Windows platform layer into the base rows.
## Consequences
A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The Windows shell swap moved from a launcher-injected patch layer to the base bundle's own rows: win32 mounts the confined pwsh stack, POSIX carries the pwsh rows disabled, and one shared patch file serves both rosters — the [Windows pwsh default](../feature/2026-08-01-windows-pwsh-default.md) note's layer mechanism is superseded. The shell TOOL rows follow the same one-plane rule as every other preset-declared row: the web-app overlay disables the host `tool-bash`/`tool-pwsh` rows and the presets declare both with inverted platform gates, so a preset can drop or replace the shell tool per session on either host. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up.

View File

@@ -0,0 +1,25 @@
# Agent NoteLoader 插值条目 `disabled` 字段
Status: implemented
[English](2026-08-11-loader-entry-disabled-interpolation.md) | 中文
## 问题
Windows 平台层(当时是 base patch 旁独立的 `windows.cordis.patch.yml`,现已折入 base 行——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。
## 决策
Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts``!!js` 表达式在每次挂载决策时基于 loader 上下文求值。`disabled` 是唯一被插值的元数据字段;`id``name``group``inject` 保持静态。原始节点保留在 options 中,写回保持 `!!js` 形式。shipped 预设standard、code、cordis自己声明 shell 工具行并按平台门控——`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,其孪生行 `tool-pwsh` 以取反的表达式——因此预设层每台宿主恰好暴露一个 shell 工具web-app overlay 禁用两个工具的 host 行,由每个会话的预设决定。`verify-cordis-config` 现在只允许 `disabled` 中的表达式。
该机制补全了平台层折叠base bundle 的 `cordis.patch.yml` 在自身行上按平台门控两个 shell 栈——`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载。启动器的独立 Windows 平台层(`windows.cordis.patch.yml` 以及 `apps/cli/src/windows-shell.ts` 及其注入到 boot、live 重组合、config dump 的逻辑)被删除——该层只因条目元数据是静态的而存在,`disabled` 可插值后条件就落在它所治理的行上。
## 备选方案
**行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。
**预设级平台 overlay。** 被否:条件应当属于它所治理的行——同一原则把启动器独立的 Windows 平台层折入 base 行。
## 后果
行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。Windows shell 栈的切换从启动器注入的 patch 层移到 base bundle 自身的行上win32 挂载受限 pwsh 栈POSIX 携带被禁用的 pwsh 行,同一份 patch 文件服务两种阵容——[Windows 默认 pwsh](../feature/2026-08-01-windows-pwsh-default.md) note 的层机制已被取代。shell 工具行遵循与其他预设声明行相同的 one-plane 规则web-app overlay 禁用 host 面的 `tool-bash`/`tool-pwsh` 行,预设以互逆的平台门控声明两者,因此任一宿主的每个会话都可以按预设丢弃或替换 shell 工具。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md
2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7
2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: db2ed9c7758deef921272a976256bff56e5d359e
2026-08-11-preset-authoring-agent-validates-its-own-composition.md: eb21094f0d859a31d5f16d780cada6818a508b36
2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 1db4197673f18979907c46376cbb7102160172b5

View File

@@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora
"Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service.
The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool.
The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: a deployment may configure other roots, and `list()` cannot reveal a user root that holds nothing yet.
That path is now a property of the package rather than of one launcher. `AgentPresets` derives `<dshHome>/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `<dshHome>/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw.
The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment.

View File

@@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂
「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。
指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()``resolve()``Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。
指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案同时把 agent 实际读取或编辑的路径改走 `list()``resolve()`写出该路径对人讲是对的,喂给文件工具是错的:部署可以配置其他根目录,而 `list()` 无法揭示一个尚且为空的用户根。
该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false`AgentPresets` 自行推导 `<dshHome>/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `<dshHome>/skills``apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch。推导出的根追加在全部已配置根之后因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。
禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id`remove()` 拒绝随部署发布的 preset。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e
2026-07-20-dsh-cli-personal-config.zh.md: 1c68a285d7e16a3aa435692b8f60107b2ef8579b
2026-07-20-dsh-cli-personal-config.md: bc2aff322de01bb9c6beebb1679b2ff9909d1fe3
2026-07-20-dsh-cli-personal-config.zh.md: 793832fae754d34a69025cad447df51e7d457236

View File

@@ -6,7 +6,7 @@ English | [中文](2026-07-20-dsh-cli-personal-config.zh.md)
## Problem
A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer.
A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static except the entry `disabled` field (see the [loader `disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md)), so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer.
## Decision

View File

@@ -6,7 +6,7 @@ Status: implemented
## 问题
开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml``.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent智能体必须回到仓库根目录调用示例脚本。Loader 元数据是静态的所以「条件组合使用 overlay」AGENTS.md——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml``.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent智能体必须回到仓库根目录调用示例脚本。Loader 元数据是静态的——条目 `disabled` 字段除外(见 [loader `disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md))——所以「条件组合使用 overlay」AGENTS.md但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
## 决策

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md
2026-07-25-session-list-browsing-and-manual-order.md: 3d2125bdf67a70a1a5bca43c5d5acb09fda178b7
2026-07-25-session-list-browsing-and-manual-order.zh.md: bef586303f24e9d0291a4ae652c089b86b9213e2
2026-07-25-session-list-browsing-and-manual-order.md: 52a0fe0c94106cb4178c57e737b1c9a3f458f803
2026-07-25-session-list-browsing-and-manual-order.zh.md: 29c9c33c4c73d662892fce10cbfb33de18608107

View File

@@ -14,7 +14,7 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a
### Flat rows and viewing state
The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads.
The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md) later added a browser-local recent-update view without changing the Host account's manual-order authority.
### Row interactions
@@ -50,7 +50,7 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine,
## Consequences
- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics.
- Manual order is the sole authority over the Host workspace account: activity never mutates `WorkspaceView.sessionIds`. A later browser-local recent-update view may promote active rows without changing that account; its separate semantics are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md).
- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features.
- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction.
- Wiring session Delete and growing the wire status enum remain future iterations.

View File

@@ -14,7 +14,7 @@ Status: implemented
### 平铺行与浏览态
group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新后仍保持。
group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新后仍保持。[Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)随后加入浏览器本地的最近更新视图,而未改变 Host 记账的手动顺序权威。
### 行交互
@@ -50,7 +50,7 @@ ui-sidebar 缩为列几何壳品牌行、折叠状态机、New Session、Sett
## 后果
- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 约定随之改为手动序措辞
- 手动序是 Host workspace 账本的唯一顺序权威:活动绝不改动 `WorkspaceView.sessionIds`。后续加入的浏览器本地最近更新视图可以把活跃行提到最前,但不会改变该账本;其独立语义见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)
- 壳/区域两事实约定把 workspace 域的后续功能Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包ui-sidebar 不再随 session 列表功能演进。
- 平铺模式不支持重排,也没有在指定 workspace 中创建 session 的入口(需切回分组视图),是拍板接受的范围收窄。
- session Delete 的功能接线与扩展 wire 状态枚举,留待后续迭代。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md
2026-07-25-workspace-ui-product-flow.md: 98e963195126df2ec8291a11b3d9fc7a2baeb0df
2026-07-25-workspace-ui-product-flow.zh.md: a7824b9d0e3665cfac61aca2db0d6b387aaf5139
2026-07-25-workspace-ui-product-flow.md: 76d279bf2101d7487fe4f5231c7cea4809e166f4
2026-07-25-workspace-ui-product-flow.zh.md: dc2672a33826b312fcb5f411c2bc29b6957531e9

View File

@@ -20,6 +20,7 @@ The Host provides the following GUI wiring on the Workspace entity:
| --- | --- |
| `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation |
| `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat |
| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | Moves one Workspace within durable registry order and returns the complete committed order |
| `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped |
| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it |
| `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session |
@@ -49,7 +50,7 @@ On initial entry, the application waits until both the Workspace and Session bas
When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order.
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the current Session's Workspace, then the most recent Workspace, and enters the blank New Session page when no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow.
@@ -67,11 +68,11 @@ Lost RPC responses, Host frames arriving before completions, and completions arr
### Sidebar and ordering
Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups.
Workspace groups follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and `workspace.insertBefore` durably applies user drag order. Session activity does not move Workspace groups.
Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration.
The Host account remains the manual `Workspace.sessionIds` order: a newly attached Session is placed first and activity does not mutate it. The grouped browser can instead select a browser-local recent-update view that promotes a Session when its `updatedAt` advances and remains manually editable. Five Sessions are visible per open Workspace until the user transiently expands the remainder. The durable Workspace reorder and browser-local Session order are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md).
A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows.
The current blank Session appears as a “New session” row without a count, time label, or row menu; other blank Sessions remain hidden and eligible for per-Workspace reuse. Search excludes blank rows.
Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order.
@@ -105,15 +106,15 @@ The Sidebar and conversation empty hero receive standardized actions through slo
- Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer.
- The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId.
- Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd.
- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front.
- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts.
- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered by hydration or Session activity, and explicit Workspace drag order survives reconnect.
- The current blank Session can appear as a single New Session row without exposing other reusable blanks or a Session count.
- The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped.
- Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback.
- Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order.
## Consequences
- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward.
- SessionHeader does not record last-active time, so historical bootstrap can initialize the Host manual order only by `createdAt`; the browser's optional recent-update view begins from Session summaries after hydration.
- Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point.
- Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract.
- Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace.

View File

@@ -20,6 +20,7 @@ Host 在 Workspace entity 上提供以下 GUI 接线:
| --- | --- |
| `workspace.list` | 返回持久有序的 Workspace并过滤未通过 header 校验的 Session id |
| `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 |
| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | 在持久注册表顺序内移动一个 Workspace并返回完整的已提交顺序 |
| `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped |
| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd以可选预分配 id 幂等创建 Session 并 attach |
| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session |
@@ -49,7 +50,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预
完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Hostcomposer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace再将前端 Session 的目标改为该 Workspace即使用户不发送消息显式创建的空 Workspace 也保留。
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用当前 Session 所属 Workspace再使用最近 Workspace;没有真实 Workspace 时进入空白 New Session 页面。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace再将前端 Session 的目标改为该 Workspace即使用户不发送消息显式创建的空 Workspace 也保留。
新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。
@@ -67,11 +68,11 @@ RPC 响应丢失、Host frame 先于 completion 和 completion 先于 Host frame
### Sidebar 与排序
Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位Session 活跃不会移动 Workspace 组。
Workspace 组使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位`workspace.insertBefore` 则持久应用用户拖拽顺序Session 活跃不会移动 Workspace 组。
组内顺序严格遵循 `Workspace.sessionIds`新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动
Host 记账保持手动的 `Workspace.sessionIds` 顺序:新 attach 的 Session 放在首位,活动不会改动该顺序。分组浏览器可以改选浏览器本地的最近更新视图;当 Session 的 `updatedAt` 增大时该视图会把它移到首位,同时仍允许手动调整。每个打开的 Workspace 默认显示五条 Session用户可临时展开其余条目。持久 Workspace 重排序和浏览器本地 Session 顺序见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)
前端 Session Intent 只有在目标是真实 Workspace 时才作为「New session」行显示并临时计入该组 Session 数量;目标是 Workspace Intent 时Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式既不保留 Intent 行,也不对其进行筛选
当前空白 Session 会显示为一条「New session」行但不显示数量、时间标签或行菜单其他空白 Session 保持隐藏,并可由对应 Workspace 复用。搜索会排除空白行
无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added``workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。
@@ -105,15 +106,15 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe
- 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。
- 首发按 Workspace、Session、提示词顺序推进各成功阶段不回滚输入在提示词被接受前不丢失创建重试使用同一 SessionId。
- Workspace list 只读取 header 完成一次可重入 bootstrap已初始化的空注册表重启不重复初始化成员读取同时校验索引与 canonical cwd。
- 初始默认目标只在两份基线 ready 后确定一次Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身
- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数
- 初始默认目标只在两份基线 ready 后确定一次Workspace 组不因 hydration 或 Session 活跃重排,显式 Workspace 拖拽顺序在重连后仍然保持
- 当前空白 Session 可显示为唯一的 New Session 行,同时不暴露其他可复用空白会话,也不显示 Session 数量
- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace而显式的重命名操作会拒绝重复显示名cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。
- 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志并在刷新后保持该状态包级测试固定一元响应基线竞态和失败回滚行为。
- keyless runnable 快照覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。
## Consequences
- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移
- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化 Host 手动顺序;浏览器可选的最近更新视图在 hydration 后从 Session 摘要开始建立
- 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped本期没有手动收编入口。
- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 约定。
- 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md
2026-07-26-code-dispatch-ui-foundation.md: 4115a1898de7d2cce01346c3f005fcd19c325f4c
2026-07-26-code-dispatch-ui-foundation.zh.md: cc64d450b14e7e607675b46ff2dab24387a2b728
2026-07-26-code-dispatch-ui-foundation.md: 94316e774f231a2f2d5e9bcc8d1a30fd4a2ec733
2026-07-26-code-dispatch-ui-foundation.zh.md: 83f0c69a3423772f7afa249f86b611214031dbdf

View File

@@ -16,7 +16,7 @@ Three changes, one per obstacle:
1. **`run_code` gains a required `description` parameter** (bash's exact contract: active voice, 5-10 words, shown in the UI; whitespace-only rejected at execute). `presentCall` now titles the card with the description and moves the program to `rawInput`. The prompt-side cost is a few tokens per call; the return is that every surface — TUI card, ACP title, web row — gets a human-readable label without parsing TypeScript.
2. **`tool/code-dispatch` logs the sub-call's complete model-facing outcome** — `content: ContentBlock[]` + `isError`, the `tool/result` vocabulary — replacing `resultSummary` and deleting the summarize/cwd-normalization machinery outright. A UI renders a sub-call through the identical code path as a native result, including error text and non-text blocks. The event stays log-only (`deriveMessages()` ignores it): nothing about model context changes.
3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata is static, so no conditional row exists; a native boot only registers the service workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands.
3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands.
## Alternatives considered

View File

@@ -16,7 +16,7 @@ Status: implemented
1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token换来的是每个界面——TUI 卡片、ACPAgent Client Protocol标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。
2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**`content: ContentBlock[]``isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件仍仅用于日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。
3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它worker 代码运行时则无条件挂载loader 元数据是静态的因此不存在条件行native 启动只是注册该服务worker 要到每次运行时才 spawn。这是一个明确标注为临时的配置钩子设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。
3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它worker 代码运行时则无条件挂载(本项交付时 loader 元数据是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务worker 要到每次运行时才 spawn。这是一个明确标注为临时的配置钩子设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。
## 曾考虑的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md
2026-08-01-windows-pwsh-default.md: 4e681b32088954d870df86898e26fe2cae669f14
2026-08-01-windows-pwsh-default.zh.md: a9d600f8a8e47db49c3733f33091e667e341c6a7
2026-08-01-windows-pwsh-default.md: c66e289c24d6024b1df53cd60f25c27d46fafc5a
2026-08-01-windows-pwsh-default.zh.md: b2ad45ec96d546d01436a383ed7d8884b978aa31

View File

@@ -12,9 +12,8 @@ The harness's shipped execution profile is bash-first on every platform. Windows
Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged.
- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`. It disables the POSIX-only `bash-sandbox`/`tool-bash` rows and inserts `pwsh-sandbox`/`tool-pwsh`. The later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled the win32 runner chain and superseded this note's original unconfined roster: `sandbox`, `sandbox-policy`, `fs-sandbox`, `permission`/`ui-permission`, and `approval` now stay enabled exactly as on POSIX, while the ACL backend truthfully reports its Everyone and hard-link gaps as partial enforcement.
- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows and disables both pwsh rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud.
- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the executor's dependency chain supplies `dsh-pwsh-local`, and the base bundle lists every row plugin as a dependency by house style.
- **The base patch gates both shell stacks on its own rows** (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note records the mechanism and the platform-layer fold): `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression — one shared patch file, exactly one shell stack per host. The confined pwsh stack runs over the ACL restricted-token runner, and the permission surface stays exactly as on POSIX (the [Windows ACL restricted-token sandbox](2026-08-08-windows-acl-restricted-token-sandbox.md) note owns that roster). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack or an unconfined pwsh executor overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. The separate `windows.cordis.patch.yml` layer and the launcher's `apps/cli/src/windows-shell.ts` injection are deleted; the layer existed only because entry metadata was static.
- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the pwsh rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`, and the executor's dependency chain supplies `dsh-pwsh-local`; the base bundle lists every row plugin as a dependency by house style.
The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior.
@@ -32,13 +31,13 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba
## Consequences
- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled).
- A Windows host running a shipped `dsh` surface gets the confined `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there. On the Web surface the shell TOOL rows come from the session's preset (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note owns the one-plane mechanism): each shipped preset declares `tool-pwsh` gated by `process.platform !== 'win32'` and its `tool-bash` twin by the inverted expression, so the preset layer exposes exactly one shell tool per host.
- Windows commands and fs operations share the sandbox policy, permission switcher, and approval service. The ACL runner confines writes but reports `enforcement: 'partial'`; explicit `danger-full-access` remains the approved bypass rather than the platform default.
- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows.
- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel.
- POSIX hosts mount the bash stack as before; the pwsh rows sit disabled in their composition, because the one shared patch file lists both stacks and each row gates itself.
- A Windows host that prefers the bash stack (e.g. with WSL/Git-Bash on PATH) overrides the shipped rows through its profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel.
## Verification
- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, custom-profile skip, missing-patch failure, cold-start dependency closure, and real composed roster; `packages/bundle/base/tests/base.spec.ts` pins that the Windows layer disables only the bash rows, inserts the confined pwsh rows, and leaves sandbox, permission, fs, and approval ownership untouched.
- Keyless: a win32 `dsh --profile <name> --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged.
- Unit: `apps/cli/tests/windows-shell.spec.ts` composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm and pins the effective per-platform roster — the win32 pwsh roster, the POSIX bash roster, and the base-only profile — plus the preset-level shell-tool gates (`tool-bash`/`tool-pwsh`) and the cold-start resolution closure; `packages/bundle/base/tests/base.spec.ts` pins the four shell rows' symmetric `!!js` platform gates and that no separate platform patch ships.
- Keyless: a `dsh --profile <name> --dump-config` shows both stacks in the one shared patch layer, with each row's own `disabled` expression deciding the roster at mount.
- The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes).

View File

@@ -12,9 +12,8 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机
启动交付 profile`dsh web``dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈POSIX 主机不变。
- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml)。它禁用仅限 POSIX 的 `bash-sandbox`/`tool-bash` 行,并插入 `pwsh-sandbox`/`tool-pwsh`。后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)填充了 win32 runner 链,并取代了本笔记最初的不限权清单:`sandbox``sandbox-policy``fs-sandbox``permission`/`ui-permission``approval` 均与 POSIX 上一样保持启用,而 ACL 后端则如实把 Everyone 与硬链接缺口报告为部分强制执行
- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts``win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间覆盖所有组合路径启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行,并禁用两个 pwsh 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈base bundle 缺 `windows.cordis.patch.yml` 时 fail loud
- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli``dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;执行器的依赖链提供 `dsh-pwsh-local`按仓库惯例base bundle 把每个行插件都列为依赖。
- **base patch 在自身行上按平台门控两个 shell 栈**[loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 记录了该机制与平台层折叠):`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`bash 没有 Windows runner它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。受限 pwsh 栈运行在 ACL 受限令牌 runner 之上,权限面与 POSIX 完全一致([Windows ACL 受限令牌沙箱](2026-08-08-windows-acl-restricted-token-sandbox.md) note 拥有该清单)。覆盖交付默认是组合决策:偏好 bash 栈或不限权 pwsh 执行器的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud——组合配置是唯一的覆盖通道。独立的 `windows.cordis.patch.yml` 层与启动器的 `apps/cli/src/windows-shell.ts` 注入已删除;该层只因条目元数据是静态的而存在
- **启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到 pwsh 行。`apps/cli``dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`,执行器的依赖链提供 `dsh-pwsh-local`按仓库惯例base bundle 把每个行插件都列为依赖
pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。
@@ -32,13 +31,13 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b
## 后果
- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)
- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得受限 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`。在 Web 表面shell 工具行来自会话的预设([loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 拥有 one-plane 机制):每个 shipped 预设声明 `tool-pwsh`(以 `process.platform !== 'win32'` 门控)及其孪生行 `tool-bash`(取反表达式),因此预设层每台宿主恰好暴露一个 shell 工具
- Windows 命令与 fs 操作共用沙箱策略、权限切换器和 approval 服务。ACL runner 限制写入,但报告 `enforcement: 'partial'`;显式的 `danger-full-access` 仍是获准的绕过方式,而非平台默认。
- POSIX 主机不变平台层永不生效bash 栈仍是通用 `cordis.patch.yml` 的行
- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud——组合配置是唯一的覆盖通道。
- POSIX 主机如常挂载 bash 栈pwsh 行以其自身的门控表达式处于禁用状态——同一份共享 patch 文件列出两个栈,每个行自己决定挂载
- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud——组合配置是唯一的覆盖通道。
## 验证
- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过、缺少 patch 时失败、冷启动依赖闭包和真实组合清单;`packages/bundle/base/tests/base.spec.ts` 固定 Windows 层仅禁用 bash 行、插入受限的 pwsh 行并且不改变沙箱、权限、fs 与审批的归属
- Keylesswin32 上的 `dsh --profile <name> --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行POSIX 转储CI Linux不变
- 单元:`apps/cli/tests/windows-shell.spec.ts` 通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app固定每个平台的有效清单——win32 pwsh 清单、POSIX bash 清单与 base-only profile——外加预设级 shell 工具门控(`tool-bash`/`tool-pwsh`)与冷启动解析闭包;`packages/bundle/base/tests/base.spec.ts` 固定四个 shell 行的对称 `!!js` 平台门控,并断言不再交付独立的平台 patch
- Keyless`dsh --profile <name> --dump-config` 在同一份共享 patch 层中显示两个栈,每个行以自己的 `disabled` 表达式在挂载时决定清单
- 真实组合冒烟在 win32 上启动 web profilepwsh 栈挂载成功(即本笔记描述的确切清单)。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
2026-08-11-workspace-sidebar-order-and-folding.md: 3a88a61ca25550f1ad803a79e171ae2a7b8d4820
2026-08-11-workspace-sidebar-order-and-folding.zh.md: e3e710bb9f38bcefcc9eeb50983c866ec5bc2619

View File

@@ -0,0 +1,56 @@
# Agent Note: Workspace Sidebar Order and Folding
Status: implemented
English | [中文](2026-08-11-workspace-sidebar-order-and-folding.zh.md)
## Problem
A Workspace with many Sessions can consume the entire sidebar and push other Workspaces out of reach. A compact list needs a bounded default while preserving an explicit route to every Session. The sidebar also needs an activity-oriented order, but `WorkspaceView.sessionIds` is the durable manual account and must not be rewritten by Session activity.
Workspace groups themselves had no user-controlled durable order. Browser-native drag additionally rejects a drop released outside the list and animates the row back even when the application still has a valid insertion marker. Expanded Workspace sections make header-only hit testing ambiguous because the visual boundary between two groups does not match either header's midpoint.
## Decision
### Workspace order
The Workspace registry owns a durable `workspaceIds` order and exposes `insertBefore(id, beforeId?)` with DOM `insertBefore` semantics. The Host RPC `workspace.insertBefore` returns the complete committed order, and a pure order mutation emits `host/workspace-order-changed` with the same complete order. Unknown source or anchor ids reject as `workspace-not-found`; self-anchored and already-positioned moves do not write.
The client installs a Workspace drag optimistically. Request and frame generations ensure that only the latest unary echo can replace local order and that a newer Host frame outranks an older response; a latest rejected request restores the last complete order accepted from a Host baseline, frame, or current unary echo. Every successful list baseline restores Host order so reconnects adopt durable changes made elsewhere.
### Session folding and view order
Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five. When more Sessions exist, **Show more** reveals the remainder only for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to five. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts.
The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot.
### Drag and compact chrome
Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker.
Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances.
## Alternatives considered
**Write every activity promotion into `Workspace.sessionIds`.** A browser presentation preference would overwrite the shared Host account whenever a user submits a prompt.
**Keep independent Manual and Last updated orders.** Switching modes would replace the visible list with stale positions from the other order, even though choosing Manual only means that later activity stops moving rows.
**Always show every Session in an open Workspace.** One large Workspace would continue to crowd out the rest, and remembering only the whole-group open state would not bound its height.
**Persist the expanded-remainder state.** A Workspace reopened much later could unexpectedly occupy the full sidebar. Only the zero-or-five state represents a stable navigation preference; revealing the remainder is a local inspection.
**Use numeric drop indices or header-only hit testing.** Indices drift when rows change during a drag, while header midpoints disagree with the visible boundary when a Workspace is expanded. Anchor ids and full-section geometry remain stable under both conditions.
**Let the browser reject an outside release.** The application would commit the last valid marker while the browser displays a rejected-drop animation, presenting contradictory feedback.
## Consequences
- Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account.
- Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position.
- Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture.
- The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md).
## Testing
Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions.

View File

@@ -0,0 +1,56 @@
# Agent Note: Workspace 侧边栏顺序与折叠
Status: implemented
[English](2026-08-11-workspace-sidebar-order-and-folding.md) | 中文
## 问题
Session 很多的 Workspace 会占满整个侧边栏,把其他 Workspace 挤出可见范围。紧凑列表需要有界的默认高度,同时仍要提供到达每条 Session 的明确入口。侧边栏还需要面向活动时间的顺序,但 `WorkspaceView.sessionIds` 是持久的手动记账,不能被 Session 活动改写。
Workspace 分组本身没有用户可控的持久顺序。浏览器原生拖拽还会把列表外松手判为拒绝并把行弹回原位即使应用仍持有有效插入标记。Workspace 展开后,若只按组头命中,两个分组之间的视觉边界也不再等于任一组头的中点。
## 决策
### Workspace 顺序
Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `insertBefore` 语义的 `insertBefore(id, beforeId?)`。Host RPC `workspace.insertBefore` 返回完整的已提交顺序;单纯顺序变更通过 `host/workspace-order-changed` 推送同一份完整顺序。未知来源或锚点 id 以 `workspace-not-found` 拒绝;以自身为锚点或移动到当前位置不会写入。
客户端对 Workspace 拖拽进行乐观安装。请求代次与帧代次保证只有最新一元回声可以替换本地顺序,且更新的 Host 帧优先于旧响应;最新请求被拒时会恢复最近一份由 Host 基线、帧或当前一元回声确认的完整顺序。每次成功的列表基线都会恢复 Host 顺序,因此重连会接纳其他位置提交的持久变更。
### Session 折叠与视图顺序
每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条。存在更多 Session 时,**展开其余**只在当前挂载期间显示剩余项;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为五条。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。
组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。
### 拖拽与紧凑界面
Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界因此左侧尖角保持可见列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover``drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。
搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。
## 考虑过的替代方案
**把每次活动置顶写入 `Workspace.sessionIds`。** 浏览器呈现偏好会在用户每次提交提示词时覆盖共享的 Host 记账。
**为手动排序和最近更新分别保留独立顺序。** 切换模式会用另一份顺序中的旧位置替换可见列表,而选择手动排序只表示后续活动不再移动条目。
**打开 Workspace 时始终显示全部 Session。** 大型 Workspace 仍会挤占其他分组;只记忆整个分组的打开状态无法限制其高度。
**持久化展开剩余状态。** 很久以后重新打开 Workspace 时,它可能意外占满侧边栏。只有零条或五条状态属于稳定导航偏好;显示剩余项只是一次本地查看。
**使用数字下标或只按组头命中拖拽。** 拖拽期间行发生变化会使下标漂移Workspace 展开时,组头中点与可见边界不一致。锚点 id 与完整区段几何在两种情况下都保持稳定。
**让浏览器拒绝列表外松手。** 应用会提交最后一个有效标记,而浏览器同时播放拒绝动画,形成相互矛盾的反馈。
## 后果
- Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。
- 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。
- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session关闭分组只重置这项临时手势。
- Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。
## 测试
领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md
2026-07-04-doc-tiers-and-budgets.md: e7b3421d09a1ae5ab9a9373e8040832c1b0d4b97
2026-07-04-doc-tiers-and-budgets.zh.md: 3bc04ae73a4d8c9c005e154a236772fc1845389e
2026-07-04-doc-tiers-and-budgets.md: 3f263864b9b6ee9479d1133b908617f10073dd66
2026-07-04-doc-tiers-and-budgets.zh.md: 63b0b2945e1ff3e3fdf6af3cddb80cf44cf448ee

View File

@@ -12,6 +12,7 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m
- **Structure follows the documentation tree.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: a document owns detail about its subject, summarizes only the purpose, responsibility, and high-level behavior of direct children, and links to deeper owners. [Agent Notes](../../README.md) remain outside this structural contract. Every human-facing document is a tutorial with an ordered outcome or a reference with an explicit lookup scope; a [postmortem](../../../../docs/postmortem/README.md) is an incident-scoped reference whose chronology records evidence. Tutorials introduce concepts in prerequisite order for the reader's starting knowledge.
- **A tier taxonomy with one home per fact.** The standard assigns every Markdown tier one job, forbids restating a fact outside its home tier, and carries the slop checklist used when writing or reviewing any doc.
- **One product onboarding path.** The root README owns the recommended package-run path, the source-run alternative, and compact `dsh plugin --profile` usage. The published user guide starts with tasks inside the running Web UI, then links to distinct tutorials or reference owners for other interfaces, plugin development, and advanced configuration instead of repeating Web startup.
- **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them.
- **Ceilings are an enforcement frontier that ratchets.** A doc at or below its target keeps at least 5% headroom as its ceiling ratchets down; a doc above target keeps a frozen ceiling that prevents growth until it reaches the target (root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600 except `packages/AGENTS.md` ≤ 650 and `docs/AGENTS.md` ≤ 1,250; `packages/README.md` ≤ 600). When the gate goes red, relocate or condense; raise a ceiling only with explicit PR justification.
- **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract.
@@ -20,11 +21,13 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m
- **Skill and review discipline without a gate** — rejected: the accretion above happened while the current-state rule and reviewer attention already existed; a prose rule with no mechanical backstop demonstrably does not hold here, and this repo's own [quality-gates stance](2026-06-11-quality-gates.md) says invariants worth keeping are worth encoding.
- **A broad gate over every doc tier** — rejected: a blanket ceiling punishes exactly the right kind of long doc (a feature matrix or type catalog where every row is a fact) and generates per-file override churn that trains contributors to rubber-stamp raises.
- **Independent onboarding tutorials for each documentation entry point** — rejected: duplicated setup steps drift in command order, first outcome, and product identity. A short README path followed by task-focused guides keeps the transition explicit without maintaining competing tutorials.
- **Housing the standard inside the skill** — rejected: contracts live in docs and workflows in skills; a standard packed into SKILL.md is invisible to an agent that edits docs without invoking the skill, and `docs/AGENTS.md` already loads as subtree instructions for anyone working under `docs/`.
## Consequences
- Adding to a budgeted doc requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI.
- Structural review starts with ownership and document form before sentence-level editing, so lower-level detail moves to its owner instead of being polished in the wrong place.
- Readers reach a running Web UI before encountering headless execution, SDK embedding, custom profiles, or direct settings files; those interfaces remain available from their reference owners.
- Budgeted docs that remain above target cannot grow; reaching the target restores the 5% working headroom.
- Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly.

View File

@@ -12,6 +12,7 @@ Status: implemented
- **结构遵循文档树。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:文档负责承载其主题的详细内容,仅概述直接子项的目的、职责和高层行为,并链接到更深层内容的归属文档。[Agent Note](../../README.md) 仍不受这一结构约定约束。每份面向人的文档要么是按顺序引导读者达成结果的教程tutorial要么是查阅范围明确的参考文档reference[事故复盘postmortem](../../../../docs/postmortem/README.md) 是范围限定于单起事故的参考文档,其时间线记录证据。教程结合读者的起始知识,按前置依赖顺序介绍概念。
- **每项事实只归属一处的层级分类。**文档标准为每种 Markdown 层级分配单一职责,禁止在事实归属层级之外重复陈述,并包含编写或评审任何文档时使用的赘余检查清单。
- **单一产品入门路径。**根 README 负责推荐的包运行路径、从源码运行的备选路径和简要的 `dsh plugin --profile` 用法。已发布的用户指南从运行中的 Web UI 内部任务开始,再链接到其他界面的独立教程或插件开发与进阶配置的参考文档归属处,而不会重复介绍 Web 启动步骤。
- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md``packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md``docs/defensive-patterns.md`。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。
- **上限是只进不退的执行红线。** 达到或低于目标的文档在上限逐步下调时保留至少 5% 的余量;高于目标的文档则维持冻结的上限,在达到目标之前不得增长(根 `AGENTS.md` ≤ 1,600 词;`architecture.md` ≤ 1,800子树 `AGENTS.md` ≤ 600`packages/AGENTS.md` ≤ 650、`docs/AGENTS.md` ≤ 1,250`packages/README.md` ≤ 600。门禁变红时迁移或压缩内容只有在 PRPull Request描述中给出明确理由时才提高上限。
- **精简的工作流 skill技能约定归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载文档放置、审计和门禁失败处理工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 约定之间的分工相同。
@@ -20,11 +21,13 @@ Status: implemented
- **仅靠 skill 和评审纪律,不设门禁**:否决。上述膨胀正是在现行规则和评审注意力已经存在的情况下发生的;一条没有自动化保障的行文规则在此处已被证明无法维持,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.md)认为值得保持的不变式就值得编码。
- **对所有文档层级全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。
- **为每个文档入口维护独立入门教程**:否决。重复的设置步骤会在命令顺序、首个结果和产品定位上产生分歧。简短的 README 路径接上面向任务的指南,可明确衔接两者,且不需要维护相互竞争的教程。
- **将标准放在 skill 内部**:否决。约定归文档,工作流归 skill如果标准被塞进 SKILL.md那些不调用该 skill 而直接编辑文档的 agent智能体就看不到它`docs/AGENTS.md` 已经作为子树指令被任何在 `docs/` 下工作的人加载。
## 后果
- 向受预算约束的文档添加内容需要腾挪空间:将新增内容迁移到其分类体系归属地并留下链接,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。
- 结构评审先检查归属关系和文档形式,再进行句子层面的编辑,使较低层级的细节迁移到其归属文档,而不是在错误的位置加以润色。
- 读者会先进入可运行的 Web UI再遇到 headless 执行、SDK 嵌入、自定义 profile 或直接 settings 文件;这些入口仍可从各自的参考文档归属处访问。
- 仍高于目标的受预算约束文档不得增长;达到目标后,将恢复 5% 的工作余量。
- 词数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。

View File

@@ -92,7 +92,7 @@ Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-c
## Secrets / .env
Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) only under plugin `config`; Loader metadata is static, so conditional composition uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
## Conventions

View File

@@ -1,3 +1,3 @@
# Running benchmarks
To run benchmark tasks with the minimal agent composition, follow [Get started with the Python SDK](docs/user/guide/python-sdk.md). The guide covers installation, running [`minimal.cordis.yml`](examples/jsonrpc-agent/minimal.cordis.yml), and isolating workspaces and session IDs between tasks.
Follow [Get started with the Python SDK](docs/user/guide/python-sdk.md) to install the SDK and run the `jsonrpc-agent` minimal variant. Use separate workspaces and session IDs for independent benchmark tasks.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write README.md
README.md: 9c19dfec19cba6f1364e4f9d5734af49675d68c2
README.zh.md: 31d83ede854e9f0dfbbba1f8ce1094d043f6d829
README.md: 690cde099d93ea2a371b31f441030153b1aca973
README.zh.md: 2a8046011da7c1970d1210f291973db36e379665

View File

@@ -12,64 +12,43 @@ DeepSeek Harness is under internal testing. Features and interfaces may change.
The 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.
## Run from source
## Run
Clone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:
Install Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:
```sh
npx @deepseek-ai/dsh web
```
The command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`
Continue with the [Web UI guide](docs/user/guide/).
### Run from source
To run a repository checkout instead:
```sh
git clone https://github.com/deepseek-harness/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm dsh web
```
## Use DeepSeek Harness
The last command builds the repository and opens the same Web UI path.
### Web UI
## Profiles and plugins
Start the recommended local interface from the repository root:
A profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile <name> <pnpm args>`, which forwards the remaining arguments to pnpm in that profile's directory:
```sh
pnpm dsh web
npx -p @deepseek-ai/dsh dsh plugin --profile web add <package>
npx -p @deepseek-ai/dsh dsh plugin --profile web remove <package>
```
The command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.
`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.
### Profiles
The source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:
```sh
pnpm dsh --profile web # the browser UI
pnpm dsh plugin --profile tui add <package> # install a plugin into a custom profile
pnpm dsh --profile tui # boot it
```
The [CLI reference](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
pnpm dsh --profile headless "summarize this workspace"
```
### Automation and SDKs
From a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:
```sh
pnpm run demo:acp
```
The [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.
## Why DeepSeek Harness
Built-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.
- **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.
- **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).
- **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).
- **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/self-modification/tool-cordis/README.md).
The [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.
## Community
@@ -81,8 +60,6 @@ Start with the [development guide](docs/development.md) and read the [architectu
For agents, follow [AGENTS.md](AGENTS.md).
DeepSeek Harness is currently in internal testing.
## License
[BSD 3-Clause](LICENSE)

View File

@@ -12,64 +12,43 @@ DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化
为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。
## 从源码运行
## 运行
克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行
安装 Node.js ^22.19 或 >= 24 和 pnpm 11然后运行已发布的包
```sh
npx @deepseek-ai/dsh web
```
该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`
下一步请阅读 [Web UI 指南](docs/user/guide/)。
### 从源码运行
如需改为运行仓库 checkout
```sh
git clone https://github.com/deepseek-harness/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm dsh web
```
## 使用 DeepSeek Harness
最后一条命令会构建仓库,并进入相同的 Web UI 路径。
### Web UI
## Profile 与插件
请从仓库根目录启动推荐的本地界面
profile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile <name> <pnpm args>` 管理 profile该命令会在对应 profile 目录中将剩余参数转发给 pnpm
```sh
pnpm dsh web
npx -p @deepseek-ai/dsh dsh plugin --profile web add <package>
npx -p @deepseek-ai/dsh dsh plugin --profile web remove <package>
```
该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务
`add``remove``update``why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile再修改其中的包并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)
### Profile
源码 CLI命令行界面会启动 profile按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:
```sh
pnpm dsh --profile web # the browser UI
pnpm dsh plugin --profile tui add <package> # install a plugin into a custom profile
pnpm dsh --profile tui # boot it
```
profile 布局、层语义与配置输出命令详见 [CLI命令行界面参考](apps/cli/README.md#profiles)。
### Headless
运行一项任务,打印最终答案后退出:
```sh
pnpm dsh --profile headless "summarize this workspace"
```
### 自动化与 SDK
在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACPAgent Client Protocol自动化服务器
```sh
pnpm run demo:acp
```
[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。
## 为什么选择 DeepSeek Harness
内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill技能、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩context compaction以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。
- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop智能体循环即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。
- **运行可重建。** 凡是模型可见的内容都会记录在权威会话流中持久化、恢复fork查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。
- **Code Mode需显式启用。** 它会提供 `run_code` 工具和生成的 TypeScript SDK只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。
- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。
[CLI命令行界面参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。
## 社区
@@ -85,8 +64,6 @@ pnpm run demo:acp
面向 agent遵循 [AGENTS.md](AGENTS.md)。
DeepSeek Harness 目前处于内测阶段。
## 许可证
[BSD 3-Clause](LICENSE)

View File

@@ -60,6 +60,8 @@ flowchart LR
cfg --> plugin_dsh_base_sandbox_policy
plugin_dsh_base_bash_sandbox["bash-sandbox<br/>@deepseek-ai/dsh-bash-sandbox"]
cfg --> plugin_dsh_base_bash_sandbox
plugin_dsh_base_pwsh_sandbox["pwsh-sandbox<br/>@deepseek-ai/dsh-pwsh-sandbox"]
cfg --> plugin_dsh_base_pwsh_sandbox
plugin_dsh_base_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
cfg --> plugin_dsh_base_approval
plugin_dsh_base_permission["permission<br/>@deepseek-ai/dsh-permission"]
@@ -68,6 +70,8 @@ flowchart LR
cfg --> plugin_dsh_base_bash_env
plugin_dsh_base_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
cfg --> plugin_dsh_base_tool_bash
plugin_dsh_base_tool_pwsh["tool-pwsh<br/>@deepseek-ai/dsh-tool-pwsh"]
cfg --> plugin_dsh_base_tool_pwsh
plugin_dsh_base_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"]
cfg --> plugin_dsh_base_tool_tasks
plugin_dsh_base_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
@@ -194,10 +198,12 @@ flowchart LR
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
| `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
| `pwsh-sandbox` | `@deepseek-ai/dsh-pwsh-sandbox` |
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `bash-env` | `@deepseek-ai/dsh-bash-env` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
| `tool-pwsh` | `@deepseek-ai/dsh-tool-pwsh` |
| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |

View File

@@ -45,11 +45,16 @@
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Behind a preset realm those variables
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
# never reached the model's shell at all. Both shell tools consume the host
# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are
# host-plane too.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
# ── filesystem ──────────────────────────────────────────────────────────────

View File

@@ -39,11 +39,16 @@
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Behind a preset realm those variables
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
# never reached the model's shell at all. Both shell tools consume the host
# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are
# host-plane too.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
# ── filesystem ──────────────────────────────────────────────────────────────

View File

@@ -25,13 +25,13 @@ Two planes, and the choice is not about how "agent-related" something feels —
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.
Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. Both roots are configuration rather than fixed locations, though, and no call reports them — `authorable` says only whether a writable one exists — so take the path you actually read or edit from `list()` or `resolve()`, which is also where `copy()` reports what it just created.
Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` which is also where `copy()` reports what it just created.
## The roster service
`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.
Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on:
Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. What this skill relies on:
- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.
- `read(id)` — one preset's composition text, without a file tool or a path.

View File

@@ -38,11 +38,16 @@
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Behind a preset realm those variables
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
# never reached the model's shell at all. Both shell tools consume the host
# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are
# host-plane too.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
# ── filesystem ──────────────────────────────────────────────────────────────

View File

@@ -15,7 +15,6 @@ import {
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
const NAME = 'dsh'
@@ -34,12 +33,6 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re
label: layer.packageName,
patches: layer.patches,
}))
// The win32 shell platform layer rides between bundles and user layers,
// exactly where the boot applies it.
const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME)
if (windowsShellLayer !== undefined) {
layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches })
}
if (!defaultOnly) {
if (existsSync(loaded.patchPath)) {
layers.push({ label: loaded.patchPath, patches: loaded.patches })

View File

@@ -29,17 +29,14 @@ import {
watchUserPatches,
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
/** Harness-home directory holding locally authored agent presets. */
const USER_PRESET_DIR = '.agent-presets'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
const NAME = 'dsh'
@@ -110,8 +107,6 @@ interface ComposedProfile {
profile: Profile
/** Bundle layers concatenated — the part below the user layers on a live reload. */
bundlePatches: PatchOptions[]
/** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */
windowsShellPatches: 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 and the telemetry switch. */
@@ -127,7 +122,6 @@ interface ComposedProfile {
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [
...composed.bundlePatches,
...composed.windowsShellPatches,
...composed.profile.patches,
...composed.homePatches,
...composed.overlays,
@@ -136,10 +130,10 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
/**
* Load `name` and compose its effective patch stack: bundle layers in
* `dsh.profile.bundles` order, the win32 shell platform layer (when the host
* is Windows), 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,
* `dsh.profile.bundles` order (the base bundle gates the shell stacks by
* platform on its own rows), 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 the telemetry switch.
* @param name - the profile name.
* @param patchFiles - `--patch` overlay paths, in argv order.
@@ -153,28 +147,27 @@ function composeProfile(
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
const rows = new Map<string, EntryOptions>()
for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
const composedOverlays = [...overlays]
// Preset roots belong to every dsh composition that mounts the roster.
// The SHIPPED root is the part of the roster only this app can resolve: it
// sits beside this app's own config, in both the source and built layouts.
// The writable root the roster appends is `dsh-agent-presets`' own, so a
// launcher that never reaches this patch still finds a person's presets.
if (rows.has('agent-presets')) {
composedOverlays.push({
id: 'agent-presets',
config: {
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
roots: [
{ path: SHIPPED_PRESET_ROOT, trust: 'system' },
{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
],
roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }],
},
})
}
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows }
return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows }
}
/** Options for {@link runProfile}. */
@@ -246,7 +239,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
// removing the override could never revert the row to the bundle default.
const composeLive = (): PatchOptions[] => structuredClone([
...composed.bundlePatches,
...composed.windowsShellPatches,
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
...composed.overlays,

View File

@@ -1,52 +0,0 @@
/**
* The Windows shell platform layer: on win32 hosts the shipped profile
* compositions swap the POSIX-only bash stack for the sandbox-confined
* PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` +
* `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's
* `windows.cordis.patch.yml`, injected by the launcher between the bundle
* layers and the user layers so a user patch can still override it — the
* only override channel is composition config, like every other roster
* decision. POSIX hosts never receive the layer.
* @module @deepseek-ai/dsh/windows-shell
*/
import { join } from 'node:path'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot'
/** The base bundle whose package carries the Windows shell patch. */
export const BASE_BUNDLE = '@deepseek-ai/dsh-base'
/** The Windows shell patch filename inside the base bundle package. */
export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml'
/** One Windows shell platform layer: its patch file and parsed patches. */
export interface WindowsShellLayer {
/** The patch file path, used as the config-dump provenance label. */
label: string
/** The parsed patch entries, applied after the bundle layers. */
patches: PatchOptions[]
}
/**
* Resolve the Windows shell platform layer for a profile composition.
* @param platform - the host platform (`process.platform` at call sites).
* @param layers - the profile's bundle layers, in application order.
* @param binName - the diagnostic prefix on thrown errors (`dsh`).
* @returns the pwsh layer on win32, else `undefined`. A custom profile that
* mounts no base bundle is skipped (it owns its shell stack); a base
* bundle whose Windows shell patch is missing fails loud in
* {@link loadOverlayPatches} — the shipped package always carries it, so
* a miss is a broken installation.
*/
export function resolveWindowsShellLayer(
platform: NodeJS.Platform,
layers: readonly ProfileLayer[],
binName: string,
): WindowsShellLayer | undefined {
if (platform !== 'win32') return undefined
const base = layers.find(layer => layer.packageName === BASE_BUNDLE)
if (base === undefined) return undefined
const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME)
return { label, patches: loadOverlayPatches(binName, label) }
}

View File

@@ -96,7 +96,11 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
// document overrides.
{
id: 'agent-presets',
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
config: {
default: 'standard',
roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
includeUserRoot: false,
},
},
...extra,
]
@@ -442,6 +446,7 @@ describe('product subagent rows in user presets', () => {
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
{ path: userRoot, trust: 'user' },
],
includeUserRoot: false,
},
}])
}, 120_000)
@@ -624,6 +629,66 @@ describe('a delegated child', () => {
})
})
describe('a launcher that configures no writable root', () => {
// The claim this default exists for, asserted through the real shipped
// bundles rather than a hand-built context: `apps/cli` patches in only the
// system root, and a person's own presets are found anyway because the
// roster derives `<dshHome>/.agent-presets` itself. `$DSH_HOME` is pointed
// at a temp home BEFORE boot — the derived root is resolved when the plugin
// is constructed, and an unpinned run would read the developer's own.
let derivedCtx: Context
let previousHome: string | undefined
beforeAll(async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-'))
previousHome = process.env.DSH_HOME
process.env.DSH_HOME = home
await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true })
await writeFile(
join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'),
'- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n',
)
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml')
await writeFile(settingsFile, '{}\n')
// Only the shipped root, exactly what `composeProfile` supplies; the
// writable one is the roster's own default rather than this patch's job.
derivedCtx = await bootWeb(settingsFile, [{
id: 'agent-presets',
config: {
default: 'standard',
roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
includeUserRoot: true,
},
}])
}, 120_000)
afterAll(async () => {
if (previousHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousHome
await derivedCtx.fiber.dispose()
})
it('discovers and mounts a preset the person authored under the harness home', async () => {
const listed = await derivedCtx.agentPresets.list()
const mine = listed.find(preset => preset.id === 'derived-mine')
expect(mine).toMatchObject({ trust: 'user' })
// Omitted rather than undefined: a healthy row carries no `broken` key.
expect(mine?.broken).toBeUndefined()
expect(derivedCtx.agentPresets.authorable).toBe(true)
const handle = await derivedCtx.agents.create({
sessionId: SessionId('preset-derived-root'),
setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined),
})
try {
expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write')
} finally {
await handle.dispose()
}
})
})
describe('authoring a preset on the shipped composition', () => {
let authorCtx: Context
let userRoot: string
@@ -642,6 +707,7 @@ describe('authoring a preset on the shipped composition', () => {
// nothing is the normal first-run state.
{ path: userRoot, trust: 'user' },
],
includeUserRoot: false,
},
}])
})

View File

@@ -1,73 +1,38 @@
/**
* The shipped shell composition: the base bundle gates both shell stacks by
* platform on its own rows (`disabled: !!js process.platform`), so exactly
* one shell stack mounts per host and no separate platform layer exists —
* the launcher applies nothing beyond the bundle layers. The spec composes
* the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the
* app installation anchor) through the boot's patch algorithm and pins the
* effective per-platform roster, the preset-level gates that keep tool-bash
* out of win32 sessions and tool-pwsh out of POSIX sessions, and the
* cold-start resolution closure for the pwsh rows' bare plugin names.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs'
import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot'
import yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
import {
BASE_BUNDLE,
resolveWindowsShellLayer,
WINDOWS_SHELL_PATCH_FILENAME,
} from '../src/windows-shell.ts'
const WINDOWS_PATCH = `- id: bash-sandbox
disabled: true
- insert:
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
`
/** One fake bundle layer rooted in a temp directory. */
function fakeLayer(packageName: string, dir: string): ProfileLayer {
return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] }
}
/** A base bundle layer whose package carries the Windows shell patch. */
function baseLayerWithPatch(dir: string): ProfileLayer {
writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH)
return fakeLayer(BASE_BUNDLE, dir)
}
describe('resolveWindowsShellLayer', () => {
let base: string
afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) })
const tempBase = (): string => {
base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-'))
return base
/**
* The effective disabled state of one row on one platform: a `!!js` expression
* evaluates with a platform-scoped `process` so both outcomes pin on any host.
*/
function disabledOn(row: { disabled?: unknown }, platform: 'win32' | 'linux'): boolean {
const value = row.disabled
if (value !== null && typeof value === 'object' && '__jsExpr' in value) {
return Boolean(evaluate({ process: { platform } }, (value as { __jsExpr: string }).__jsExpr))
}
return value === true
}
it('never applies on POSIX hosts', () => {
expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
})
it('defaults Windows hosts to the pwsh platform layer', () => {
const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh')
expect(layer).toBeDefined()
expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true)
expect(layer?.patches).toEqual([
{ id: 'bash-sandbox', disabled: true },
{ insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] },
])
})
it('skips custom profiles without a base bundle', () => {
const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase())
expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined()
})
it('fails loud when the base bundle ships no Windows shell patch', () => {
const base = tempBase()
mkdirSync(base, { recursive: true })
// The overlay loader owns the fail-loud contract: the caller named this
// file, so its absence is a misconfiguration, not "no overlay".
expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh'))
.toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/)
})
})
describe('the shipped Windows composition (real bundle layers)', () => {
describe('the shipped shell composition (real bundle layers)', () => {
let home: string
afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
// The app installation anchor, mirroring profile-boot.ts: the bundle layers
@@ -75,65 +40,98 @@ describe('the shipped Windows composition (real bundle layers)', () => {
// suite composes the shipped patch files, not test fixtures.
const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
it('composes the win32 confined roster through the real patch layers', () => {
it('composes the confined pwsh roster on win32 and the bash roster on POSIX from the same rows', () => {
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
const profile = loadProfile('dsh', 'web', anchor, home)
const warnings: string[] = []
const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh')
expect(win32).toBeDefined()
const rows = composeEntries(
[...profile.layers.map(layer => layer.patches), win32!.patches],
profile.layers.map(layer => layer.patches),
message => warnings.push(message),
)
const byId = new Map(rows.map(row => [row.id, row]))
// Only the POSIX bash stack leaves the roster: the permission surface
// (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled
// exactly as on POSIX — the confined pwsh executor is what changes.
for (const id of ['bash-sandbox', 'tool-bash']) {
expect(byId.get(id)?.disabled, `row ${id}`).toBe(true)
// One shared patch set, two rosters: the shell stacks gate themselves.
for (const id of ['bash-sandbox', 'pwsh-sandbox', 'tool-bash', 'tool-pwsh']) {
expect(byId.has(id), `row ${id}`).toBe(true)
}
expect(disabledOn(byId.get('bash-sandbox')!, 'win32'), 'bash-sandbox on win32').toBe(true)
expect(disabledOn(byId.get('bash-sandbox')!, 'linux'), 'bash-sandbox on linux').toBe(false)
expect(disabledOn(byId.get('pwsh-sandbox')!, 'win32'), 'pwsh-sandbox on win32').toBe(false)
expect(disabledOn(byId.get('pwsh-sandbox')!, 'linux'), 'pwsh-sandbox on linux').toBe(true)
// Host shell-tool rows are disabled on every platform; sessions mount
// their own rows instead.
expect(byId.get('tool-bash')?.disabled).toBe(true)
expect(byId.get('tool-pwsh')?.disabled).toBe(true)
// The permission surface never moves: the sandbox/policy rows, the
// permission switcher, fs-sandbox, and the approval service stay enabled
// exactly as on POSIX — the confined pwsh executor is what changes.
for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
}
for (const id of ['pwsh-sandbox', 'tool-pwsh']) {
expect(byId.has(id), `inserted row ${id}`).toBe(true)
}
// The launcher's cold-start module fallback BFS-links the apps/cli
// dependency closure into the profile's node_modules (the pwsh-local
// precedent), so every inserted bare plugin must resolve from there.
// dependency closure into the profile's node_modules, so every bare
// plugin name in the base patch must resolve from there.
const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
}
// The patch touches only base-owned rows plus inserts, so the full web
// profile composes without any no-match warning.
expect(warnings).toEqual([])
})
it('leaves POSIX untouched and base-only profiles compose without warnings', () => {
it('base-only profiles carry both stacks with the same platform gating', () => {
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
const profile = loadProfile('dsh', 'web', anchor, home)
// POSIX: no platform layer, the bash stack stays enabled.
const posixRows = composeEntries(profile.layers.map(layer => layer.patches))
const posixById = new Map(posixRows.map(row => [row.id, row]))
expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true)
expect(posixById.has('pwsh-local')).toBe(false)
expect(posixById.has('pwsh-sandbox')).toBe(false)
// A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the
// patch touches only base-owned rows (bash-sandbox/tool-bash) plus its
// inserts, so the composition produces no no-match warning.
initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
const baseOnly = loadProfile('dsh', 'base-only', anchor, home)
const baseWarnings: string[] = []
const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh')
expect(win32).toBeDefined()
composeEntries(
[...baseOnly.layers.map(layer => layer.patches), win32!.patches],
message => baseWarnings.push(message),
const profile = loadProfile('dsh', 'base-only', anchor, home)
const warnings: string[] = []
const rows = composeEntries(
profile.layers.map(layer => layer.patches),
message => warnings.push(message),
)
expect(baseWarnings).toEqual([])
const byId = new Map(rows.map(row => [row.id, row]))
for (const id of ['bash-sandbox', 'tool-bash', 'pwsh-sandbox', 'tool-pwsh']) {
expect(byId.has(id), `row ${id}`).toBe(true)
}
// No web overlay: the tool rows keep their own gating too.
expect(disabledOn(byId.get('tool-bash')!, 'win32'), 'tool-bash on win32').toBe(true)
expect(disabledOn(byId.get('tool-bash')!, 'linux'), 'tool-bash on linux').toBe(false)
expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false)
expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true)
expect(warnings).toEqual([])
})
})
describe('shipped agent presets gate both shell tools by platform', () => {
const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets')
it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => {
const entries: unknown = yaml.load(
readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'),
{ schema: entryListSchema },
)
if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`)
for (const [id, win32] of [['tool-bash', true], ['tool-pwsh', false]] as const) {
const row = entries.find((entry): entry is Record<string, unknown> => (
typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
))
if (row === undefined) throw new TypeError(`preset ${preset} must mount ${id}`)
expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string })
// A platform-scoped context pins both outcomes on every host.
const expression = (row.disabled as { __jsExpr: string }).__jsExpr
expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(!win32)
}
})
it('minimal mounts no shell tool row at all (its shell is the PTY stack)', () => {
const entries: unknown = yaml.load(
readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'),
{ schema: entryListSchema },
)
if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array')
for (const id of ['tool-bash', 'tool-pwsh']) {
expect(entries.some(entry => (
typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
)), `${id} must be absent from minimal`).toBe(false)
}
})
})

View File

@@ -21,7 +21,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The sidebar renders from the boot graph: every inject layer activated.
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The compact layout dropped group session counts; the fixture workspace
// group row renders immediately with its sessions beneath it.
const fixtureGroup = (await within(tree).findAllByText('fixture'))
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
.find(el => el?.getAttribute('aria-expanded') !== null)
if (fixtureGroup === undefined) throw new Error('fixture Workspace group missing')
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that

View File

@@ -77,8 +77,13 @@ async function nextPaint(page: Page): Promise<void> {
}
async function openSeed(page: Page): Promise<void> {
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// The compact layout dropped group session counts; the seeded baseline is
// the Ungrouped bucket once cold summaries load.
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })

View File

@@ -168,8 +168,10 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWor
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Session-list bootstrap can replace the controlled search state. Wait
// for the seeded baseline before openSeed starts the lazy content query.
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
// for the seeded baseline before openSeed starts the lazy content query
// (the compact layout dropped group session counts; the Ungrouped bucket
// row is the barrier).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
return {
events,
page,
@@ -258,7 +260,10 @@ async function conversationTurns(page: Page): Promise<number> {
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
// persisted first-message marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient

View File

@@ -249,7 +249,10 @@ async function compareTabsWithoutReservation(page: Page): Promise<TabComparison>
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000

View File

@@ -197,8 +197,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
// Browser: the sidebar tree now carries the auto-created workspace group
// with its one session, and the opened session is the selected row.
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// with its one session, and the opened session is the selected row. The
// compact layout dropped group session counts, so the group row itself is
// the barrier.
await expect.poll(
() => page.locator('[role="treeitem"][aria-expanded]').filter({ hasText: 'workspace' }).count(),
{ timeout: 15_000 },
).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Host: the session's durable header cwd is the folder the workspace

View File

@@ -53,7 +53,10 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom
async function ensureSeedOpen(page: Page): Promise<void> {
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
@@ -119,8 +122,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
// the ready projection replaces.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
// the ready projection replaces (the compact layout dropped group session
// counts; the Ungrouped bucket row is the barrier).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterEach(async () => {
@@ -176,9 +180,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// The API baselines can settle before React commits their projection. The
// seeded count is the final user-visible barrier before editing search.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// seeded Ungrouped bucket row is the final user-visible barrier before
// editing search (the compact layout dropped group session counts).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
await search.fill('zzzqx-no-such-session')

View File

@@ -68,8 +68,11 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
// Open the seeded session through content search: the sidebar groups
// sessions by workspace and its row order is world-dependent, while the
// search index covers the seeded log deterministically.
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// search index covers the seeded log deterministically. Search is a
// collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
await search.fill('Run a PowerShell command')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)

View File

@@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// able to change a golden.
{
id: 'agent-presets',
config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
config: {
default: 'standard',
roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }],
includeUserRoot: false,
},
},
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
@@ -445,7 +449,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
] },
...options.agentPresets === undefined
? []
: [{ id: 'agent-presets', config: options.agentPresets }],
// Never the derived harness-home root: a developer's own presets must not
// be able to change a golden, whatever roots a scenario asks for.
: [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]

View File

@@ -349,9 +349,9 @@ async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
* clicking once — startup auto-selection can expand the bucket first, and a
* second click would collapse it again. Hand-rolled polling because
* in the collapsed Ungrouped bucket. Open the bucket, then use its transient
* Show-more control because an open group intentionally renders only five
* rows by default. Hand-rolled polling because
* `expect.poll` is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
@@ -364,6 +364,12 @@ async function expandSeededSessions(page: Page): Promise<void> {
if (await bucket.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
}
const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
if (await bucket.getAttribute('aria-expanded') === 'true'
&& await rows.count() <= SEED_COUNT / 2
&& await showMore.count() > 0) {
await showMore.click()
}
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
if (Date.now() > deadline) {
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)

View File

@@ -5,17 +5,17 @@
- img
- text: New Session
- text: Workspaces
- button "Group by":
- button "Search sessions":
- img
- textbox "Search sessions..."
- button "View options":
- img
- button "Add workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "New Session" [selected]
- button "Settings":
- img

View File

@@ -5,17 +5,17 @@
- img
- text: New Session
- text: Workspaces
- button "Group by":
- button "Search sessions":
- img
- textbox "Search sessions..."
- button "View options":
- img
- button "Add workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "New Session" [selected]
- button "Settings":
- img

View File

@@ -1,7 +1,7 @@
- tree "Sessions":
- treeitem "Ungrouped 3 sessions" [expanded]:
- treeitem "Ungrouped" [expanded]:
- img
- text: Ungrouped 3 sessions
- treeitem "Use the read tool twice (2) now" [selected]
- treeitem "Use the read tool twice (1) now"
- text: Ungrouped
- treeitem "Use the read tool twice 1min"
- treeitem "Use the read tool twice (1) now"
- treeitem "Use the read tool twice (2) now" [selected]

View File

@@ -1,6 +1,6 @@
- tree "Sessions":
- treeitem "workspace 2 sessions" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 2 sessions
- treeitem "1 subagent running Delegate a background task. now"
- text: workspace
- treeitem "New Session" [selected]
- treeitem "1 subagent running Delegate a background task. now"

View File

@@ -1,6 +1,6 @@
- tree "Sessions":
- treeitem "workspace 2 sessions" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 2 sessions
- treeitem "Explain event sourcing in one (1) now" [selected]
- text: workspace
- treeitem "Ask a research subagent to now"
- treeitem "Explain event sourcing in one (1) now" [selected]

View File

@@ -1,5 +1,5 @@
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "Ask a research subagent to now"

View File

@@ -68,6 +68,13 @@ describe('web e2e: startup auto-selection', () => {
it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
const headline = page.getByText('Into the Unknown', { exact: true })
const fish = headline.locator('xpath=preceding-sibling::span[1]/*[name()="svg"]')
const fishHitbox = fish.locator('..')
expect(await fish.evaluate(node => getComputedStyle(node).color))
.toBe(await headline.evaluate(node => getComputedStyle(node).color))
await fishHitbox.hover()
expect(await fish.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none')
await page.evaluate(() => {
const refs = {
root: document.querySelector('div[data-phase="hero"]'),

View File

@@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer'
const NESTED_LABEL = 'example editor'
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
/** The grandchild's own first message; its arrival is what says its history finished loading. */
const NESTED_PROMPT = 'Give one concrete event sourcing example.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
@@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
seq: 1,
time: authoredAt + 1,
data: {
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
content: [{ type: 'text', text: NESTED_PROMPT }],
source: { kind: 'user' },
},
surfaceOp: 'append',
@@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
)
await nestedRow.click()
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
// The offline banner renders from the descriptor alone, so it says nothing
// about the transcript below it. The golden pins that transcript, and
// `captureStableAria` calls two identical polls stable — including two of
// "Loading history…". Wait for the message the golden asserts.
await page.getByText(NESTED_PROMPT).waitFor()
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
const crumbs = await hierarchy.getByRole('button').allTextContents()
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])

View File

@@ -59,7 +59,10 @@ interface RowAnchor {
}
async function openSeed(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
@@ -182,7 +185,9 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
// The compact layout dropped group session counts; the seeded baseline is
// the Ungrouped bucket once cold summaries load.
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterAll(async () => {

View File

@@ -372,21 +372,22 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
// Grouped default: workspace group rows render (the seeded session sits
// under Ungrouped; the created workspaces are empty groups).
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await page.getByRole('button', { name: 'Group by' }).click()
// Grouping and ordering moved into the View options menu.
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'In one list' }).click()
// Flat mode: the section label flips and the seeded session is a
// top-level row with no group headers above it.
await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat')
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view.v4'))).toContain('flat')
// Persisted across reload; then restore grouped for inter-spec hygiene.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
await page.getByRole('button', { name: 'Group by' }).click()
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(tripwire.pageErrors).toEqual([])

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: e7ab120218c6de909bbd799c19b38eef524c7555
config-catalog.zh.md: 6c8c7ae55935a591329bac055def71c08468c3b6
config-catalog.md: 2bb1f315e02c3f4bab379593227f1c381818a9fa
config-catalog.zh.md: 79d4bd0668fcfe7207a08e18df871720f7b55fcd

View File

@@ -135,6 +135,11 @@ export interface Config {
default: string
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
roots: PresetRoot[]
/**
* Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
* configured root. False mounts a roster over `roots` alone.
*/
includeUserRoot: boolean
}
/** One directory scanned for preset subdirectories. */

View File

@@ -137,6 +137,11 @@ export interface Config {
default: string
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
roots: PresetRoot[]
/**
* Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
* configured root. False mounts a roster over `roots` alone.
*/
includeUserRoot: boolean
}
/** One directory scanned for preset subdirectories. */

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cordis-primer.md
cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346
cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875
cordis-primer.md: c57055e9657ebc8a0c3f537825ddcbdda1ced68a
cordis-primer.zh.md: 45cce2abb2117aef44028ab53a9836d24fab91d6

View File

@@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca
## Loader Configuration
`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates only an entry's `config`, after declared injections activate, against that plugin context (`ctx.serviceName`); Include preserves nested row expressions until target activation. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, `isolate`) stays literal, so `disabled: !!js ...` always disables the entry. Use overlays when the environment selects plugins.
`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates an entry's `config` (after declared injections activate, against that plugin context `ctx.serviceName`) and its `disabled` field (at every mount decision, against the loader context); Include preserves nested row expressions until target activation. Other entry metadata stays literal. Use overlays when the environment selects plugins.
## Practical Rules

View File

@@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。
## Loader 配置
`@deepseek-ai/cordis-plugin-include``!!js` 解析为表达式节点。Loader 在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`Include 会保留嵌套行表达式,直到目标行激活。条目元数据`id``name``group``disabled``inject``intercept``isolate`)保持字面值,因此 `disabled: !!js ...` 始终禁用该条目。由环境选择插件时,请使用 overlay。
`@deepseek-ai/cordis-plugin-include``!!js` 解析为表达式节点。Loader 在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`,并在每次挂载决策时基于 loader 上下文插值其 `disabled` 字段Include 会保留嵌套行表达式,直到目标行激活。其余条目元数据保持字面值。由环境选择插件时,请使用 overlay。
## 实践规则

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md
05-config.md: 2357f663135d6fc78a65f9d0952e0bc3f5eefae4
05-config.zh.md: fbd94d179494ad0b6f73baff2ca525c786cc9e33
05-config.md: 17cccce2ec43be65477ce527800ee6a636ee5d96
05-config.zh.md: 87f1cb465d5f3e52a6f8e449cfb29818ba86dcb8

View File

@@ -77,7 +77,7 @@ The loader used in this repo supports a `!!js` tag for config values that must b
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
```
`!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration).
`!!js` works only inside `config` and in an entry's `disabled` field. `disabled: !!js ...` evaluates against the loader context at every mount decision (this repo's extension), so a row can gate itself on platform or environment; the other metadata (`name`, `id`, `inject`, ...) stays static, where an expression is ordinary truthy data. See [loader configuration](../cordis-primer.md#loader-configuration).
Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application.

View File

@@ -77,7 +77,7 @@ ValidationError: invalid config:
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
```
`!!js` **仅在 `config` 内有效**。Cordis 配置项的元数据(`name``id``disabled``inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该 Cordis 配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。
`!!js` 仅在 `config` 与条目 `disabled` 字段内有效。`disabled: !!js ...` 在每次挂载决策时基于 loader 上下文求值(本仓库的扩展),可以按平台或环境门控一行;其余元数据(`name``id``inject` 等)保持静态,其中的表达式是普通真值数据。详见 [loader 配置](../cordis-primer.md#loader-configuration)。
下一章:[组合与 HMR热模块替换](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md
index.md: fb700344e6d07d3864655009d2edac15ee9eede8
index.zh.md: 9759123c42db893e91c2f3660f2f64a6c466b0a9
index.md: a10a0f93fde4f710af2ab14f74b854ee07d7c03f
index.zh.md: 3388d9c1b799ffb7d698027bd1bc6bcdc989aa49

View File

@@ -10,7 +10,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [
## Setup
You need a clone of this repository with dependencies installed the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly.
You need a clone of this repository with dependencies installed; the [development guide](../development.md#setup-tutorial) lists the prerequisites. No API key is needed for this tutorial; every example runs keylessly.
```sh
git clone https://github.com/deepseek-ai/deepseek-harness.git

View File

@@ -10,7 +10,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行
## 准备工作
你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。
你需要克隆本仓库并安装依赖[开发指南](../development.md#setup-tutorial)列出了前置条件。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。
```sh
git clone https://github.com/deepseek-ai/deepseek-harness.git

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
core.md: e52a7619085b2956496be6234f711441902fc259
core.zh.md: fab8a79045272aed9f00b0f87b5c151dbd6021e8
core.md: 2e89bac4c0468c094814aa7137381f4be569fc29
core.zh.md: e1ab879442937420553b09e37697532eb6ad4bd9

View File

@@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
Types: [ScopeKey](scope.md)
Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts)
Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts)
<a id="ctxagents--agentregistry"></a>

View File

@@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
Types: [ScopeKey](scope.md)
Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts)
Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts)
<a id="ctxagents--agentregistry"></a>

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
workspace.md: 7bd5fda31e5d5c29d7446589b9af2679a46cba9a
workspace.zh.md: 288b9468cc9e75613ab89efbe516eb287dae0441
workspace.md: 480279cf4ed2c7a0005ea6a8ca8f58c208219e86
workspace.zh.md: 6d9a9ad5dab1d11ea61fb0274ea3a91eb8929c5a

View File

@@ -194,6 +194,15 @@ list(): Workspace[]
*/
delete(id: WorkspaceId): Promise<boolean>
/**
* Move one workspace within the durable display order, DOM-insertBefore-like.
* With an anchor it lands before that workspace; without one it appends.
* @param id - Workspace to move.
* @param beforeId - Workspace anchor; omitted appends.
* @returns the complete committed workspace order.
*/
insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly WorkspaceId[]>
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
@@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise<Workspace | undefined>
Types: [SessionId](core.md)
Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts)
Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -194,6 +194,15 @@ list(): Workspace[]
*/
delete(id: WorkspaceId): Promise<boolean>
/**
* Move one workspace within the durable display order, DOM-insertBefore-like.
* With an anchor it lands before that workspace; without one it appends.
* @param id - Workspace to move.
* @param beforeId - Workspace anchor; omitted appends.
* @returns the complete committed workspace order.
*/
insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly WorkspaceId[]>
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
@@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise<Workspace | undefined>
Types: [SessionId](core.md)
Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts)
Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md
index.md: e57a42b42690bd92450cc26876c13a1622bb80cc
index.zh.md: c64d3b26ba09d8d4df736bec395a4904bc6912bc
index.md: 71b5bd5ef5d296999420c40d3b8c9cf46c918841
index.zh.md: f774f26e5da144e6f86a371f351bdbca630d0474

View File

@@ -2,7 +2,7 @@
English | [中文](index.zh.md)
This tutorial creates a minimal Harness plugin and loads it into the Web UI. Start from a repository checkout that has completed the [quick start](../../guide/quickstart.md).
This tutorial creates a minimal Harness plugin and loads it into the Web UI. Start from a repository checkout that has completed the [run-from-source path](../../../../README.md#run-from-source).
## Create a local project

View File

@@ -2,7 +2,7 @@
[English](index.md) | 中文
本教程会创建一个最小的 Harness 插件,并将其加载到 Web UI 中。请基于一个已完成[快速开始](../../guide/quickstart.md)的仓库检出副本进行操作
本教程会创建一个最小的 Harness 插件,并将其加载到 Web UI 中。请已完成[从源码运行路径](../../../../README.md#run-from-source)的仓库检出开始
## 创建本地项目

View File

@@ -1,6 +0,0 @@
# 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/guide/config.md
config.md: 1d3ad5ce36d4b360ba5156b6be28a6caae4a23d4
config.zh.md: 74a9c581cc11e3f22bddcf6c925a4a71989d8e02

View File

@@ -1,72 +0,0 @@
# Configuration
English | [中文](config.zh.md)
Harness uses `cordis.yml` to describe which plugins an agent loads and the configuration passed to each one. The file composes capabilities; the generated configuration catalog records the fields and defaults each package actually supports.
## Start from a real configuration
The repository examples are runnable configurations and the most reliable starting points for a new project:
- [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.
A minimal configuration is a list of plugin entries:
```yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
models:
- deepseek-v4-flash
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
## Plugin entries
`name` identifies an npm package or a local module relative to `cordis.yml`; `id` gives the plugin instance a stable identity; and `config` supplies plugin-specific configuration. Set `disabled: true` to skip an entry temporarily.
```yaml
- id: local-tool
name: './src/my-tool.ts'
disabled: false
config:
toolName: my_tool
```
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 patch layers
`dsh --profile <name>` 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/<name>/cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch <path>` overlay. Later layers win per row. App flags are not another patch layer: an ordinary bundle plugin injects `cmdlineArgs` and provides parsed values as its own service, while rows that inject and retain a `!!js` read of that service give the invocation value precedence.
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.
## JavaScript values and environment variables
The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration.
```yaml
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
cwd: !!js process.cwd()
```
The tag is `!!js`, not `!js`.
## Exact configuration reference
The generated [plugin configuration catalog](../../config-catalog.md) lists every current field, type, and default. For composition concepts, continue to the [architecture](../../architecture.md) and [capability seams](../../capability-seams.md). To create a configuration, copy the closest entry from the [examples overview](../../../examples/README.md) and adapt it.

View File

@@ -1,72 +0,0 @@
# 配置文件
[English](config.md) | 中文
harness 使用 `cordis.yml` 描述 agent智能体加载哪些插件以及每个插件的参数。配置文件负责组合能力每个包真正支持的字段和默认值由源码生成的配置目录负责记录。
## 从真实配置开始
仓库中的示例就是可以运行的配置,也是新项目最可靠的起点:
- [`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) 向程序化 ACPAgent Client Protocol客户端提供全新会话。
最小配置由一组插件条目组成:
```yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
models:
- deepseek-v4-flash
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
## 插件条目
`name` 指定 npm 包或相对于 `cordis.yml` 的本地模块,`id` 为插件实例提供稳定标识,`config` 传入插件自己的配置。需要临时跳过某个条目时可设置 `disabled: true`
```yaml
- id: local-tool
name: './src/my-tool.ts'
disabled: false
config:
toolName: my_tool
```
Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务Cordis 会等到这些服务就绪后再应用该插件,因此文件顺序不能保证依赖已就绪。引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。
## CLI 补丁层
`dsh --profile <name>` 按该 profile 的 manifest元数据清单`dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles/<name>/cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch <path>` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch组合包中的普通插件注入 `cmdlineArgs`,再把解析值作为自身服务提供;注入该服务并保留其 `!!js` 读取的行会让本次调用的取值优先。
补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey``baseURL`;因此必须重新写出该行需要保留的全部键。
## JavaScript 值和环境变量
Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。
```yaml
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
cwd: !!js process.cwd()
```
标签是 `!!js`,不是 `!js`
## 精确配置参考
每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/index.md
index.md: a04698e29755d4a08b012f8b61accb79c470dcb0
index.zh.md: 3808d9506fa9cb3a3e455ed478e4f02c18fc09ab
index.md: 80d288b1aba37e7f0863fe5fc8237cbd2a6ab9b5
index.zh.md: addfbc94ff93ed015e52f509a23a3f981e36770b

View File

@@ -1,52 +1,28 @@
# Introduction
# Use the Web UI
English | [中文](index.zh.md)
DeepSeek Harness is a **plugin-based agent development framework** built on the [Cordis](https://github.com/cordiverse/cordis) microkernel. Its central idea is simple: **everything is a plugin**.
Start the Web UI through the [root README](../../../README.md#run); the command prints its URL. This guide begins after that server is running.
## What it is
The invoking directory is the default workspace, so the agent can inspect and modify the project where you started `dsh`.
Harness implements every capability an AI agent needs—including LLM calls, tool execution, session management, and subtask delegation—as a composable plugin. A `cordis.yml` file declares which plugins to load and how to configure them, assembling a complete agent.
## Configure a model
```yaml
# Select the LLM backend
- name: '@deepseek-ai/dsh-llm-deepseek'
Open **Settings → Models**, enter a DeepSeek API key, and save it. The model route becomes usable immediately without restarting the server.
# Compose one configured agent
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
workspaceContext: false
```
The [model configuration guide](./providers.md) covers other providers and custom OpenAI-compatible endpoints.
## Who it is for
## Run a task
### Application users
Start a session and send:
To run an existing agent application, such as a coding assistant or conversational agent:
> Summarize this repository and identify its main packages.
1. Copy an example template.
2. Add an API key.
3. Run it.
The agent can read and edit workspace files, run commands, delegate work, and maintain a plan. The Web UI asks before operations that require approval under the active permission policy.
No code is required. See the [quick start](./quickstart.md).
## Continue
### Plugin developers
To add a custom tool, a new LLM adapter, or another execution backend, write a plugin. Harness provides explicit extension interfaces and a type-safe development experience. See [development](../develop/basic/).
## Core features
- **Configuration only** — `cordis.yml` selects the capability set; changing a model or adding a tool is a configuration edit.
- **Hot replacement (HMR)** — edit plugin code during development without restarting the process.
## Technology
- **Runtime**: Node.js ^22.19 or >= 24
- **Language**: TypeScript (ESM)
- **Framework**: Cordis
- **Package manager**: pnpm workspaces (the repository pins pnpm 11)
- [Configure models](./providers.md)
- [Use the Python SDK](./python-sdk.md)
- [Use other CLI modes](../../../apps/cli/README.md)
- [Develop a plugin](../develop/basic/)

View File

@@ -1,52 +1,28 @@
# 介绍
# 使用 Web UI
[English](index.md) | 中文
DeepSeek Harness 是一个**插件化的 agent智能体开发框架**,基于 [Cordis](https://github.com/cordiverse/cordis) 微内核构建。它的核心理念是:**一切皆插件**
先按照[根 README](../../../README.md#run)启动 Web UI命令会打印其访问地址。本指南从服务器已经运行的状态开始
## 它是什么
调用目录是默认工作区,因此 agent智能体可以检查并修改启动 `dsh` 时所在的项目。
Harness 将 AI人工智能 agent 所需的所有能力——LLM大语言模型调用、工具执行、会话管理、子任务分配——全部构建为可组合的插件。你通过一个 `cordis.yml` 配置文件来声明加载哪些插件、使用什么参数,就能组装出一个完整的 agent。
## 配置模型
```yaml
# Select the LLM backend
- name: '@deepseek-ai/dsh-llm-deepseek'
打开**设置 → 模型**,输入 DeepSeek API 密钥并保存。模型路由会立即可用,不需要重启服务器。
# Compose one configured agent
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
workspaceContext: false
```
[模型配置指南](./providers.md)介绍其他提供方和自定义 OpenAI 兼容端点。
## 适合谁
## 运行任务
### 应用使用者
启动一个会话并发送:
如果你只是想用一个现成的 agent 应用(如编程助手、对话代理),你需要的全部操作就是:
> Summarize this repository and identify its main packages.
1. 复制一个示例模板
2. 填写 API 密钥。
3. 运行。
agent 可以读取和编辑工作区文件、运行命令、委派工作并维护计划。当操作在当前权限策略下需要审批时Web UI 会先询问你
不需要写任何代码。详见 [快速开始](./quickstart.md)。
## 继续使用
### 插件开发者
如果你想为 agent 添加新能力——一个自定义工具、一个新的 LLM 适配器、一个新的执行后端——你需要编写一个插件。Harness 提供了清晰的扩展接口和类型安全的开发体验。详见 [开发](../develop/basic/)
## 核心功能
- **只需要配置** — `cordis.yml` 决定能力集合,换模型、加工具只需改一行
- **HMR热模块替换** — 开发时修改插件代码,无需重启进程
## 技术栈
- **运行时**Node.js ^22.19 或 >= 24
- **语言**TypeScriptESM
- **框架**Cordis
- **包管理**pnpm workspaces仓库固定使用 pnpm 11
- [配置模型](./providers.md)
- [使用 Python SDK](./python-sdk.md)
- [使用其他 CLI 模式](../../../apps/cli/README.md)
- [开发插件](../develop/basic/)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/providers.md
providers.md: 0e7ed11d1b09a8361d75b576a400978ac66d08a7
providers.zh.md: ec68017ab40ea93117c063a819907979316b0c94
providers.md: a3f94f0cc86401c0f9e5b94cfd823bf9f08e6bfc
providers.zh.md: 7d74e0086e62d8a0c2fb39085207125b4b4354e7

View File

@@ -2,151 +2,44 @@
English | [中文](providers.zh.md)
Harness ships with DeepSeek and mounts a generic multi-provider adapter alongside it, for the providers in pi-ai's installed catalog — Anthropic, OpenAI, and the rest — and for any OpenAI-compatible gateway or self-hosted server. You have two entry points: the **Models** page in the web UI, and `$DSH_HOME/settings.yaml`. Both write the same document, and a change takes effect on the next request without a restart.
This guide assumes you started the Web UI through the [root README](../../../README.md#run). Model changes take effect on the next request without restarting the server.
## Where providers come from
## Configure DeepSeek
`cordis.yml` decides which **adapters** are installed; the settings document decides which **providers** run. The shipped composition carries two LLM adapters:
- `llm-deepseek` serves the `deepseek-official` route, the one available out of the box.
- `llm-pi-ai` mounts **dormant**: zero routes and no extra entries in the model picker until an `llm-pi-ai:` settings section supplies provider profiles, at which point those routes register live and drop again when the section empties.
Adding a provider therefore rarely means editing `cordis.yml` — writing settings is enough, and that is exactly what the Models page does.
## Configure from the web UI
Start `pnpm dsh web` and open **Settings → Models**.
Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter the key and save it.
![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png)
**Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready.
Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference.
**Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe.
## Add a catalog provider
That holds for providers that authenticate with an API key. The catalog also carries Bedrock, Vertex, Azure, and Codex, which need AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively: filling in the key field alone will not make them work. Those authenticate through pi-ai's own environment discovery, with credentials prepared the way each one requires.
Choose **Add provider**, select a provider such as Anthropic or OpenAI, enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list.
**Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model.
Providers with native authentication need their native credentials instead. Bedrock, Vertex, Azure, and Codex use AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively; filling only the API-key field does not configure them.
## Add a custom provider
Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model.
![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png)
Every field but the Provider ID stays editable afterwards: **Edit** on the row reopens the same fields, with the display name and the protocol under **Customized settings** beside the base URL. Clearing the display name falls back to the Provider ID. The Provider ID itself is fixed: it names the route in requests, in `agent-default-model`, and in every session already logged, and it is the stem of the credential reference the page can never read back — so renaming a route means declaring a new provider and deleting the old one.
The Provider ID is permanent because requests, saved sessions, model defaults, and credential references use it. To rename a provider, add a new provider and delete the old one. The display name, base URL, protocol, credential, and models remain editable.
**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save.
Under **Model catalog**, choose **Fetch available models** to query the base URL and credential currently shown in the form. Selecting candidates updates the draft; the provider is not stored until you save. Catalog providers use their installed catalog without a network request.
Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it.
## Select a model
## settings.yaml for advanced configuration
Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log.
The document lives at `$DSH_HOME/settings.yaml` (`$DSH_HOME` defaults to `~/.dsh`). The Models page writes this file, and you can edit it directly; neither source outranks the other.
```yaml
llm-deepseek:
reasoningEffort: high
llm-pi-ai:
providers:
# Catalog route: endpoint, protocol, and models come from pi-ai; you supply
# the credential.
openai:
apiKeyEnv: OPENAI_API_KEY
# Also a catalog route, moved to a private proxy, with its catalog narrowed
# to one model and that model's capacity corrected. Every unset field still
# comes from the catalog.
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
baseURL: https://proxy.example.com:8443
reasoning: high
models:
- id: claude-sonnet-4-5
contextWindow: 200000
# Catalog route with one model reshaped in place; the rest of the catalog
# keeps serving (a models list would replace it instead).
deepseek:
apiKeyEnv: DEEPSEEK_API_KEY
modelOverrides:
deepseek-v4-pro:
reasoningEfforts:
off:
high: high
# Hand-declared route: pi-ai ships nothing under this key, so the profile
# supplies the whole provider.
acme-gateway:
displayName: Acme Gateway
apiKeyEnv: ACME_GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.acme.example/v1
# Reasoning dialect for an endpoint whose URL pi-ai cannot recognize.
compat:
thinkingFormat: deepseek
models:
- id: acme-large
name: Acme Large
contextWindow: 65536
maxTokens: 4096
- id: acme-think
name: Acme Think
# key = level offered in the picker, value = what goes on the wire;
# only off may leave the value empty (supported, send nothing).
reasoningEfforts:
off:
high: high
max: ultra
```
A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them.
A profile the adapter could not serve is refused **where it is written**: a hand-declared route needs `api`, `baseURL`, and at least one model, and a profile missing any of them fails naming the offending route and model rather than being stored and quietly disabling the whole namespace. When an already-stored document is broken by an external edit, settings keeps the last good value and warns.
## The model catalog
A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit — but once you declare the list, every model the route should keep serving must appear in it, an entry of nothing but `id` being enough.
Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: it is keyed by catalog model id, takes the same fields a `models` entry does, and leaves the rest of the catalog serving untouched. An override naming a model the catalog does not describe — or set beside a `models` list, or on a custom provider — is refused rather than silently skipped.
The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry.
**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the picker offers no Off and requests carry no off switch — the provider's own default decides. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels.
**Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only.
A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once.
Model ids are not lifecycle configuration. Requesting a model the route does not configure fails with `UNKNOWN_MODEL` before any provider request goes out.
## Credentials
Use `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. Omitting it leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold.
Under `dsh`, references resolve from the inherited environment, the Models page's `$DSH_HOME/.credentials.yaml` store, the invoking directory's `.env`, then `$DSH_HOME/.env`. Without a credential service, a reference reads only the matching environment variable. One credential serves every model on its route.
## Point an agent at the new provider
A configured route appears in the web model picker and can be switched at any time.
Switching there also sets the default: the model you pick becomes the one the next new session starts on, recorded in `settings.yaml` under `agent-default-model`. There is no separate gesture.
```yaml
agent-default-model:
provider: acme-gateway
model: acme-large
reasoningEffort: high # optional
```
After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct entry points and Host-backed entry points read that same service.
If the provider a saved default names is later removed, the composer says **Select model** and refuses input until you pick one, rather than sending to a route nothing serves.
If a saved default names a provider that was deleted, the composer displays **Select model** and blocks input until another model is selected.
## Troubleshooting
- **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable.
- **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries.
- **`UNSUPPORTED_REASONING_EFFORT`** — the request asked the model for a level it does not offer. Pick a level the composer lists for that model, or declare the missing one in the model's `reasoningEfforts`.
- **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present.
- **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead.
- **`MISSING_CREDENTIAL`** — Store the provider key through the Models page or supply the referenced environment variable.
- **`UNKNOWN_MODEL`** — Select a configured model or add the missing model to the custom provider.
- **Fetching available models returns 401** — Check the key. Model discovery calls the OpenAI-compatible `GET /models` endpoint; enter models manually for endpoints that do not provide it.
## Exact field reference
## Advanced configuration
The complete fields, types, and defaults each plugin currently supports live in the generated [plugin configuration catalog](../../config-catalog.md). Each adapter's own semantics belong to its README: [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md). For `cordis.yml` itself, see [Configuration](./config.md).
The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors.

View File

@@ -2,151 +2,44 @@
[English](providers.md) | 中文
harness 出厂自带 DeepSeek同时预装了一个通用的多提供方适配器用来接入 pi-ai 已安装目录中的 Anthropic、OpenAI 等提供方,或任何 OpenAI 兼容的网关与自建服务。你有两个入口Web 界面的**模型**页,以及 `$DSH_HOME/settings.yaml`。两者写的是同一份文档,改完下一次请求生效,不用重启
本指南假定你已按照[根 README](../../../README.md#run)启动 Web UI。模型变更会在下一次请求生效,不需要重启服务器
## 提供方从哪里来
## 配置 DeepSeek
`cordis.yml` 决定装了哪些**适配器**settings 文档决定跑哪些**提供方**。出厂组合里有两个 LLM大语言模型适配器
- `llm-deepseek` 提供 `deepseek-official` 路由,是默认可用的那个。
- `llm-pi-ai` 以**休眠**状态挂载:零路由,模型选择器里也不会多出条目,直到 settings 里的 `llm-pi-ai:` 段落给出提供方 profile路由才注册上来段落清空则一并撤下。
因此新增一个提供方通常不需要改 `cordis.yml`,写 settings 就够了——而模型页做的正是这件事。
## 在 Web 界面里配置
启动 `pnpm dsh web`,打开**设置 → 模型**。
打开**设置 → 模型**。DeepSeek 卡片提供一个 API 密钥字段;输入密钥并保存。
![模型页DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png)
**填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。
密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml`settings 只保留它的凭据引用。
**添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。
## 添加目录提供方
只对以 API 密钥认证的提供方成立。目录里也有 Bedrock、Vertex、Azure、Codex它们分别需要 AWS 凭据与区域、ADC 项目配置、`api-version`、OAuth只填密钥框不会让它们工作——这类提供方靠 pi-ai 自己的环境发现认证,凭据按各自的原生方式准备
选择**添加提供方**,选取 Anthropic 或 OpenAI 等提供方,输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表
**添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方需要填 Provider ID请求里点名它、也作为凭据名的小写标识、API 地址、协议,以及至少一个模型
使用原生认证的提供方需要各自的原生凭据。Bedrock、Vertex、Azure 和 Codex 分别使用 AWS 凭据与区域、ADC 项目、`api-version` 和 OAuth只填写 API 密钥字段无法完成配置
## 添加自定义提供方
对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。
![自定义提供方表单Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png)
Provider ID 外的每个字段之后都还能改:行上的**编辑**会重新打开这些字段,显示名称和协议在「自定义设置」里、紧挨着 API 地址;显示名称清空即退回 Provider ID。Provider ID 本身固定不可改:它在请求里、在 `agent-default-model` 里、在每一条已记录的会话里点名这条路由,同时还是凭据引用的词干,而页面永远读不回凭据值——因此重命名一条路由等于声明一个新提供方再把旧的删掉
Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑
**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘
**模型目录**中选择**获取可用模型**可查询表单当前显示的基础 URL 和凭据。选择候选项只会更新草稿;保存前不会存储提供方。目录提供方使用已安装目录,不发起网络请求
密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`profile 里只记录引用它的变量名。
## 选择模型
## settings.yaml进阶配置
已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。
文档位于 `$DSH_HOME/settings.yaml``$DSH_HOME` 默认是 `~/.dsh`)。模型页写的就是这个文件,你也可以直接编辑它——两个来源没有主次之分
```yaml
llm-deepseek:
reasoningEffort: high
llm-pi-ai:
providers:
# Catalog route: endpoint, protocol, and models come from pi-ai; you supply
# the credential.
openai:
apiKeyEnv: OPENAI_API_KEY
# Also a catalog route, moved to a private proxy, with its catalog narrowed
# to one model and that model's capacity corrected. Every unset field still
# comes from the catalog.
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
baseURL: https://proxy.example.com:8443
reasoning: high
models:
- id: claude-sonnet-4-5
contextWindow: 200000
# Catalog route with one model reshaped in place; the rest of the catalog
# keeps serving (a models list would replace it instead).
deepseek:
apiKeyEnv: DEEPSEEK_API_KEY
modelOverrides:
deepseek-v4-pro:
reasoningEfforts:
off:
high: high
# Hand-declared route: pi-ai ships nothing under this key, so the profile
# supplies the whole provider.
acme-gateway:
displayName: Acme Gateway
apiKeyEnv: ACME_GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.acme.example/v1
# Reasoning dialect for an endpoint whose URL pi-ai cannot recognize.
compat:
thinkingFormat: deepseek
models:
- id: acme-large
name: Acme Large
contextWindow: 65536
maxTokens: 4096
- id: acme-think
name: Acme Think
# key = level offered in the picker, value = what goes on the wire;
# only off may leave the value empty (supported, send nothing).
reasoningEfforts:
off:
high: high
max: ultra
```
settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。
一份服务不了的 profile 会在**写入处**被拒绝:手工声明的路由必须给出 `api``baseURL` 和至少一个模型缺了会带着路由名和模型名报错而不是存下来再让整个命名空间静默失效。已经存好的文档被外部改坏时settings 会保留上一次的好值并告警。
## 模型目录
`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑——但一旦声明了这份列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。
就地重塑目录里的几个模型、保留其余,归 `modelOverrides` 管:它以目录模型 id 为键,接受与 `models` 条目相同的字段,目录的其余部分原样继续服务。覆盖若点名了目录没有描述的模型,或与 `models` 列表并存,或写在自定义提供方上,都会被拒绝,而不是被静默跳过。
可配置的模型字段是 `id``name``contextWindow``maxTokens``reasoningEfforts``compat`。定价与输入模态没有消费方,随内置目录条目走。
**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off选中它时什么也不发送完全不写选择器不提供 Off请求也不携带关闭开关——由提供方自己的默认行为决定。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。
**选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。
两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`262144`defaultMaxTokens`32768。这两个数按定义就是猜测所以它们是路由字段网关服务的模型更小时改一次即可。
模型 id 不是生命周期配置:请求一个该路由没有配置的模型,会在任何网络请求之前以 `UNKNOWN_MODEL` 失败。
## 凭据
使用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件。省略它会让路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key。
`dsh` 下,引用依次从继承环境、模型页的 `$DSH_HOME/.credentials.yaml` 存储、调用目录的 `.env``$DSH_HOME/.env` 解析。未挂载凭据服务时,引用只读取同名环境变量。一份凭据供该路由上的所有模型使用。
## 让 agent智能体用上新提供方
配好的路由会出现在 Web 的模型选择器里,随时可切。
在那里切换同时也就选定了默认值:你选的模型会成为下一个新会话的起点,记录在 `settings.yaml``agent-default-model` 段里。没有另一个单独的手势。
```yaml
agent-default-model:
provider: acme-gateway
model: acme-large
reasoningEffort: high # optional
```
会话跑过一个轮次后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接入口与 Host 支撑的入口都读取同一服务。
如果某个已存默认值指向的提供方后来被删掉了,输入框会显示**选择模型**并拒绝输入,而不是把消息发给一个没人服务的路由。
如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入
## 排错
- **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。
- **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id
- **`UNSUPPORTED_REASONING_EFFORT`** — 请求向模型要了一个它不提供的档位。从输入框为该模型列出的档位里挑一个,或把缺的那个声明进该模型的 `reasoningEfforts`
- **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api``baseURL``models` 是否齐全。
- **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。
- **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。
- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型
- **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型
## 精确字段参考
## 进阶配置
每个插件当前支持的完整字段、类型与默认值见自动生成的[插件配置目录](../../config-catalog.md)。两个适配器各自的语义由它们的 README 负责:[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md)`cordis.yml` 本身的写法见[配置文件](./config.md)
自动生成的[插件配置目录](../../config-catalog.md)列出所有受支持的字段与默认值。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md
python-sdk.md: c6aee27e08b266ae3e54f7817cc5b9689ad8fba4
python-sdk.zh.md: 0b0e37a6fff8ee11d4694163ecb7d22f93bcd550
python-sdk.md: 3ef0e6595b0b5b7dddfe05e659c58556dcc48874
python-sdk.zh.md: a46c79aa0c7cd3b6a286e1f64e01a8a81496c0f0

View File

@@ -2,59 +2,29 @@
English | [中文](python-sdk.zh.md)
This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a configurable system prompt, a two-tool catalog, persistent-shell behavior, and context compaction disabled.
This tutorial is the programmatic alternative to the Web UI. It installs the published Python SDK, runs a checked-in agent composition, and shows how to call the same API from your own program.
## Prerequisites
- Python 3.10 or newer
- Git
- Linux x64, Linux arm64, or macOS arm64
- A DeepSeek-compatible API endpoint and credential
- An isolated workspace that the agent may modify
## Install the SDK
Choose either the public package or a source build. Both install the `deepseek-harness-sdk` distribution and expose the `deepseek_harness` Python module.
### Install from PyPI
Create a virtual environment and install the SDK with its same-version bundled runtime:
Clone the repository for its runnable example, create a virtual environment, and install the SDK with its same-version bundled runtime:
```sh
git clone https://github.com/deepseek-harness/deepseek-harness.git
cd deepseek-harness
python -m venv .venv
. .venv/bin/activate
python -m pip install deepseek-harness-sdk
```
### Build from source
A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment:
```sh
git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness
cd deepseek-harness
python -m pip install uv==0.11.23
corepack enable
pnpm install
case "$(uname -s):$(uname -m)" in
Linux:x86_64) runtime_platform=linux-x64 ;;
Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;;
Darwin:arm64) runtime_platform=macos-arm64 ;;
*) echo "unsupported platform" >&2; exit 1 ;;
esac
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform"
version="$(node -p "require('./package.json').version")"
python scripts/build-python-release.py --package sdk --output-dir dist-python
python scripts/build-python-release.py \
--package runtime \
--platform "$runtime_platform" \
--runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \
--output-dir dist-python
python -m pip install --find-links dist-python "deepseek-harness-sdk==$version"
```
The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so neither installation path needs Node.js after installation.
The installed runtime needs no system Node.js. Repository contributors who need to build the runtime or wheels from source should use the [Python contributor workflows](../../../python/development.md).
## Run the checked-in example
@@ -67,7 +37,7 @@ export DEEPSEEK_API_KEY=sk-your-key-here
# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.'
```
Run one task from the repository checkout:
Run one task against an isolated workspace and session directory:
```sh
python examples/jsonrpc-agent/minimal.py \
@@ -77,11 +47,11 @@ python examples/jsonrpc-agent/minimal.py \
"Inspect the repository and fix the failing tests."
```
The script prints the final assistant response. The session root receives a JSONL session log containing the assembled model request and every tool call.
The script prints the final assistant response. The session directory receives a JSONL log containing the assembled model requests and tool calls.
## Use the SDK in your own program
The example is a thin wrapper around this SDK call:
The checked-in example is a thin wrapper around this SDK call:
```python
from pathlib import Path
@@ -108,9 +78,9 @@ with DeepSeekHarness(
print(result.final_response)
```
`DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions.
`DeepSeekHarness` starts the bundled runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same durable conversation.
## Understand the example configuration
## Understand the example composition
| Property | Value |
|---|---|
@@ -123,7 +93,7 @@ print(result.final_response)
| Filesystem | Bare local backend; absolute editor paths may address any path visible to the runtime process |
| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` |
The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, compaction, and every other model-facing plugin. Sandbox-policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent.
The composition omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, compaction, and every other model-facing plugin. Sandbox-policy facts are logged as runtime user context rather than appended to the system prompt.
## Choose workspace and session IDs
@@ -131,4 +101,4 @@ The configuration omits harness identity, workspace prompt text, skills, one-sho
The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate, so this composition does not support Windows agents.
For the complete SDK lifecycle and result contract, see the [Python SDK reference](../../../python/sdk/README.md). For Cordis composition syntax, see [Configuration](./config.md).
The [`jsonrpc-agent` example reference](../../../examples/jsonrpc-agent/README.md) owns the exact composition. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, runtime selection, and configuration; the [Cordis primer](../../cordis-primer.md) covers composition syntax.

View File

@@ -2,59 +2,29 @@
[English](python-sdk.md) | 中文
本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中包含可配置的系统提示词、双工具目录和持久 shell 行为并关闭上下文压缩context compaction
本教程介绍 Web UI 之外的程序化使用方式:安装已发布的 Python SDK、运行仓库内置的 agent智能体组合并在自己的程序中调用同一套 API
## 前置要求
- Python 3.10 或更高版本
- Git
- Linux x64、Linux arm64 或 macOS arm64
- DeepSeek 兼容的 API 端点与凭据
- agent 可以修改的隔离 workspace
## 安装 SDK
可以选择安装公开包或从源码构建。两种方式都会安装 `deepseek-harness-sdk` 分发包,并提供 `deepseek_harness` Python 模块。
### 从 PyPI 安装
请创建虚拟环境,并安装 SDK 及其同版本内置运行时:
克隆仓库以使用其中的可运行示例,创建虚拟环境,并安装 SDK 及其同版本内置运行时:
```sh
git clone https://github.com/deepseek-harness/deepseek-harness.git
cd deepseek-harness
python -m venv .venv
. .venv/bin/activate
python -m pip install deepseek-harness-sdk
```
### 从源码构建
从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境:
```sh
git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness
cd deepseek-harness
python -m pip install uv==0.11.23
corepack enable
pnpm install
case "$(uname -s):$(uname -m)" in
Linux:x86_64) runtime_platform=linux-x64 ;;
Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;;
Darwin:arm64) runtime_platform=macos-arm64 ;;
*) echo "unsupported platform" >&2; exit 1 ;;
esac
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform"
version="$(node -p "require('./package.json').version")"
python scripts/build-python-release.py --package sdk --output-dir dist-python
python scripts/build-python-release.py \
--package runtime \
--platform "$runtime_platform" \
--runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \
--output-dir dist-python
python -m pip install --find-links dist-python "deepseek-harness-sdk==$version"
```
运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此两种安装方式完成后都不再需要 Node.js。
安装后的运行时不需要系统提供 Node.js。需要从源码构建运行时或 wheel 包的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.md)。
## 运行仓库内置示例
@@ -67,7 +37,7 @@ export DEEPSEEK_API_KEY=sk-your-key-here
# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.'
```
从仓库 checkout 运行一个任务:
针对隔离的 workspace 和会话目录运行一个任务:
```sh
python examples/jsonrpc-agent/minimal.py \
@@ -77,11 +47,11 @@ python examples/jsonrpc-agent/minimal.py \
"Inspect the repository and fix the failing tests."
```
脚本会打印 assistant 的最终回复。会话目录会收到 JSONL 会话日志,其中包含组装后的模型请求与每次工具调用。
脚本会打印 assistant 的最终回复。会话目录会收到 JSONL 日志,其中包含组装后的模型请求与工具调用。
## 在自己的程序中使用 SDK
示例是以下 SDK 调用的轻量包装
仓库内置示例是以下 SDK 调用的轻量包装:
```python
from pathlib import Path
@@ -108,9 +78,9 @@ with DeepSeekHarness(
print(result.final_response)
```
`DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。
`DeepSeekHarness` 会延迟启动内置运行时,并持续复用,直至退出上下文管理器。复用同一个 harness session id 会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。独立任务应使用新的 session id只有下一次调用需要延续同一段持久化对话时才复用原有 id。
## 了解示例配置
## 了解示例组合
| 属性 | 值 |
|---|---|
@@ -123,7 +93,7 @@ print(result.final_response)
| 文件系统 | 裸本地后端;编辑器使用绝对路径,可以访问运行时进程可见的任何路径 |
| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL |
配置省略了 harness 身份、workspace 提示词文本、skill技能、一次性 Bash、任务工具、上下文压缩和其他所有面向模型的插件。沙箱策略事实记录为运行时用户上下文而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。
组合省略了 harness 身份、workspace 提示词文本、skill技能、一次性 Bash、任务工具、上下文压缩和其他所有面向模型的插件。沙箱策略事实记录为运行时用户上下文而不会追加到系统提示词中。
## 选择 workspace 与 session id
@@ -131,4 +101,4 @@ print(result.final_response)
该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该组合不支持 Windows agent。
完整的 SDK 生命周期与结果约定见 [Python SDK 参考](../../../python/sdk/README.md)。Cordis 组合语法见[配置](./config.md)
准确的组合内容归 [`jsonrpc-agent` 示例参考](../../../examples/jsonrpc-agent/README.md)所有。[Python SDK 参考](../../../python/sdk/README.md)介绍生命周期、结果、通知、运行时选择和配置;[Cordis primer](../../cordis-primer.md)介绍组合语法

View File

@@ -1,6 +0,0 @@
# 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/guide/quickstart.md
quickstart.md: e93e5a430f0cb345728581cd6fa3175ffd20b7d1
quickstart.zh.md: 69cde830bb802ef19cc1204685395b957a0e02e3

View File

@@ -1,62 +0,0 @@
# Quick start
English | [中文](quickstart.zh.md)
This guide gets an agent running in five minutes.
## Prerequisites
- [Node.js](https://nodejs.org/) ^22.19 or >= 24
- [pnpm](https://pnpm.io/) 11 through Corepack
- A [DeepSeek Platform](https://platform.deepseek.com/) API key
```sh
node -v
corepack enable
pnpm -v
```
## Step 1: install and configure the API key
```sh
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
```
Create the gitignored repository-root `.env`:
```sh
DEEPSEEK_API_KEY=sk-your-key-here
```
## Step 2: run one Headless task
Run a non-interactive task and print its final answer:
```sh
pnpm dsh --profile headless "summarize the architecture of this workspace"
```
`dsh --profile headless` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty.
## Step 3: use the Web UI
Start the browser interface:
```sh
pnpm dsh web
```
Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`.
## What happened
`dsh --profile headless` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`.
## Next steps
- [Get started with the Python SDK](./python-sdk.md) — install the SDK and run a complete Cordis configuration without the Web UI
- [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways
- [Configuration](./config.md) — understand the `cordis.yml` format
- [Develop a plugin](../develop/basic/) — build your own tool or backend

View File

@@ -1,62 +0,0 @@
# 快速开始
[English](quickstart.md) | 中文
本指南带你在 5 分钟内跑起一个 agent智能体
## 环境准备
- [Node.js](https://nodejs.org/) ^22.19 或 >= 24
- 通过 Corepack 使用 [pnpm](https://pnpm.io/) 11
- [DeepSeek Platform](https://platform.deepseek.com/) API 密钥
```sh
node -v
corepack enable
pnpm -v
```
## 第一步:安装并配置 API 密钥
```sh
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
```
在仓库根目录创建已被 Git 忽略的 `.env`
```sh
DEEPSEEK_API_KEY=sk-your-key-here
```
## 第二步:运行一个 Headless 任务
运行一个非交互式任务并打印最终回答:
```sh
pnpm dsh --profile headless "summarize the architecture of this workspace"
```
`dsh --profile headless` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。
## 第三步:使用 Web UI
启动浏览器界面:
```sh
pnpm dsh web
```
打开 `http://127.0.0.1:3080`。agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`
## 运行原理
`dsh --profile headless` 启动 `headless` profile[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。
## 下一步
- [Python SDK 快速上手](./python-sdk.md) — 安装 SDK并在不使用 Web UI 的情况下运行完整 Cordis 配置
- [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关
- [配置文件](./config.md) — 了解 `cordis.yml` 的格式
- [开发插件](../develop/basic/) — 编写自己的工具或后端

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md
README.md: 9eb37fd29442dc40c7a17cd266c225e6750a6886
README.zh.md: f358d3c8b22017a10dff62ce2dedced2bfd6de6c
README.md: 967f3f499962bf1fd1873fc16ac8fd8075b0df3b
README.zh.md: f84ab95132e820cf0fcf45bff4ae30d9bccb55c1

View File

@@ -35,4 +35,6 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON
- owner-scoped persistent `bash`
- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`
It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK and uses `DSH_MODEL` as its default model; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers setup, session management, and the security boundary.
It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. Bash and absolute editor paths can modify any path available to the runtime process, so run this variant only against a disposable checkout or container. The persistent PTY requires a POSIX terminal environment and is not a Windows agent interface.
[`minimal.py`](minimal.py) runs the composition through the Python SDK and uses `DSH_MODEL` as its default model. The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers installation, execution, workspace selection, and session identity; the [SDK reference](../../python/sdk/README.md) owns runtime lifecycle and result semantics.

View File

@@ -35,4 +35,6 @@
- 所有者作用域内持久化的 `bash`
- 提供 `view``create``str_replace``insert``str_replace_editor`
它组合了内置运行时所需的本地 PTY、裸 `fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置,并把 `DSH_MODEL` 作为默认模型;[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。
它组合了内置运行时所需的本地 PTY、裸 `fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。Bash 和编辑器绝对路径可以修改运行时进程有权访问的任何路径,因此只能针对可丢弃的 checkout 或容器运行该变体。持久 PTY 需要 POSIX 终端环境,因此不适用于 Windows agent 接口。
[`minimal.py`](minimal.py)通过 Python SDK 运行该组合,并把 `DSH_MODEL` 作为默认模型。[Python SDK 教程](../../docs/user/guide/python-sdk.md)介绍安装、运行、workspace 选择与 session 标识;[SDK 参考](../../python/sdk/README.md)归属运行时生命周期与结果语义。

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