浏览代码

fix(agent-presets,web): broken presets are roster rows, not gaps

A hand-damaged preset was silent until the worst moment. An unparsable
composition listed as an ordinary selectable row and failed only at the
next session start — set as default, every new session failed. A
directory whose composition file was deleted vanished from the roster
while still occupying its id: copy answered "delete the existing preset
first" while remove answered "not found", a dead end.

Discovery now owns health: every id-shaped directory is a roster slot,
broken when its composition is missing or unloadable, checked with the
loader's own entryListSchema dialect (!!js included) so health never
rejects what the loader accepts. `broken` rides AgentPreset, the
agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor
refuse broken up front with the discovery-reported reason, while
resolve/read/remove still answer. The section renders marked red cards —
unselectable, uncopyable, deletable, location kept on custom rows — and
both pickers drop broken rows entirely.

The cordis preset's persona now forbids editing the shipped install
(corrupting cordis would disable the mode itself) and points authoring
at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the
copy-first workflow, the one-escalation sandbox reality, and honest
verification. Exercised live: asked to edit the shipped composition the
composed agent refuses citing both rules; asked for real presets (simple
and complex) it lands them under the user root with one approved
escalation each and self-checks with the loader dialect.
Yichen Jiang 1 月之前
父节点
当前提交
c56f23ae1f
共有 36 个文件被更改,包括 720 次插入 和 87 次删除
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml
  2. 33 0
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
  3. 33 0
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md
  4. 2 0
      apps/cli/config/agent-presets/cordis/agent.cordis.yml
  5. 12 3
      apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md
  6. 59 1
      apps/web/tests/agent-preset-authoring.e2e.ts
  7. 93 0
      apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md
  8. 1 1
      docs/config-catalog.md
  9. 4 0
      docs/cordis-catalog/services.md
  10. 2 2
      packages/client/ui-agent-preset/README.i18n.yaml
  11. 2 0
      packages/client/ui-agent-preset/README.md
  12. 2 0
      packages/client/ui-agent-preset/README.zh.md
  13. 29 0
      packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css
  14. 39 19
      packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx
  15. 5 0
      packages/client/ui-agent-preset/src/client/locales.ts
  16. 7 0
      packages/client/ui-agent-preset/src/client/section-store.ts
  17. 15 8
      packages/client/ui-agent-preset/src/client/settings-store.ts
  18. 37 0
      packages/client/ui-agent-preset/tests/section.spec.tsx
  19. 14 0
      packages/client/ui-agent-preset/tests/settings-store.spec.ts
  20. 2 2
      packages/cordis/tool-cordis/src/api-catalog.ts
  21. 2 2
      packages/host/apiproxy/README.i18n.yaml
  22. 1 1
      packages/host/apiproxy/README.md
  23. 1 1
      packages/host/apiproxy/README.zh.md
  24. 1 0
      packages/host/apiproxy/src/api-proxy.ts
  25. 1 0
      packages/host/apiproxy/src/api/agent-presets.schema.ts
  26. 7 0
      packages/host/apiproxy/src/api/agent-presets.ts
  27. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  28. 10 9
      packages/preset/agent-presets/README.md
  29. 10 9
      packages/preset/agent-presets/README.zh.md
  30. 1 10
      packages/preset/agent-presets/src/authoring.ts
  31. 90 5
      packages/preset/agent-presets/src/discovery.ts
  32. 25 3
      packages/preset/agent-presets/src/index.ts
  33. 17 0
      packages/preset/agent-presets/src/types.ts
  34. 30 5
      packages/preset/agent-presets/tests/authoring.spec.ts
  35. 87 3
      packages/preset/agent-presets/tests/discovery.spec.ts
  36. 38 1
      packages/preset/agent-presets/tests/mount.spec.ts

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
+2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462
+2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md

@@ -0,0 +1,33 @@
+# Agent Note: Broken presets are roster rows, not gaps
+
+Status: implemented
+
+English | [中文](2026-08-09-broken-preset-roster-rows.zh.md)
+
+## Problem
+
+With files as the only composition editor, hand-edit damage had two failure shapes and both were silent until the worst moment. A preset whose `agent.cordis.yml` no longer parsed listed as a perfectly ordinary row — selectable, copyable, settable as the default — and failed only when the next session tried to mount it; set as default, every new session failed to start. A directory whose composition file was deleted outright vanished from the roster while still occupying its id on disk: `copy` refused the name with "delete the existing preset first" and `remove` answered "not found" — two contradictory errors with no way out short of hand-deleting the directory.
+
+## Decision
+
+Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page.
+
+Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
+
+## Consequences
+
+- The ghost dead end is gone end to end: the directory lists broken, its delete clears it, and the freed id is immediately claimable (covered by unit, component, and e2e tests).
+- A default that later breaks still fails the session start loudly — the pickers hide broken rows, but nothing rewrites a stored default; `resolveMountable`'s early refusal is the same message every unloadable shape gets, instead of loader-dependent errors.
+- Health runs on every `list()`: one read+parse per preset per roster read, accepted for the same reason unmemoized discovery was — rosters are small and freshness is the contract.
+- Copying broken is refused in the UI only (disabled with reason); the host keeps `copy` shape-agnostic. A broken source yields an equally broken, equally visible copy — no capability is gained, and the host-side refusal would have needed its own error vocabulary for no journey that survives the disabled button.
+
+## Load-bearing details
+
+- **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged.
+- **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line.
+- **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route.
+- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user.
+
+## Alternatives considered
+
+Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md

@@ -0,0 +1,33 @@
+# Agent Note:损坏的 preset 是名单行,不是空缺
+
+Status: implemented
+
+[English](2026-08-09-broken-preset-roster-rows.md) | 中文
+
+## 问题
+
+文件成为唯一的组装编辑器之后,手动编辑造成的损坏有两种形态,且都要拖到最糟的时刻才暴露。`agent.cordis.yml` 解析不了的 preset 在名单上是一张完全正常的行——可选择、可复制、可设为默认——直到下一个会话尝试挂载才失败;一旦被设为默认,所有新会话都无法启动。组装文件被整个删掉的目录则从名单上消失,却仍在磁盘上占着它的 id:`copy` 以「先删除既有 preset」拒绝这个名字,`remove` 却回答「找不到」——两条互相矛盾的错误,除了手动删目录别无出路。
+
+## 决定
+
+发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。
+
+界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
+
+## 后果
+
+- 幽灵死路端到端消除:目录以损坏行列出,删除即清掉,释放的 id 立刻可用(单测、组件测试与 e2e 各自覆盖)。
+- 事后才损坏的默认值仍会在会话启动处大声失败——选择器隐藏损坏行,但没有任何东西改写已存的默认;`resolveMountable` 的前置拒绝让每种不可加载形态得到同一条消息,而不是依赖加载器内部的报错。
+- 健康检查随每次 `list()` 运行:每次读名单对每个 preset 一次读取加解析,接受的理由与不做缓存的发现相同——名单很小,新鲜是契约。
+- 复制损坏 preset 只在 UI 层拒绝(按钮禁用并给出原因);宿主的 `copy` 保持形状无关。损坏来源产出同样损坏、同样可见的副本——没有能力增益,而宿主侧拒绝需要为一条被禁用按钮挡住的路径专门发明错误词汇。
+
+## 关键细节
+
+- **`PRESET_ID` 移到 `types.ts`**,让发现与创作共享同一份包含边界词汇;authoring 原样转发导出。
+- **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。
+- **mount.spec 的两个竞态用例特意不动**:`ensureStanding` 仍可能拿到删除前一刻解析出的 preset(私有路径测试),其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。
+- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实(preset 根目录在会话工作区之外)与诚实的验证方式(agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。
+
+## 曾考虑的替代方案
+
+隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。

+ 2 - 0
apps/cli/config/agent-presets/cordis/agent.cordis.yml

@@ -24,6 +24,8 @@
 
       Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it.
 
+      Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy.
+
       Load the `editing-cordis-compositions` skill before writing or changing a composition.
 
 - id: workspace-context

+ 12 - 3
apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md

@@ -17,7 +17,16 @@ Two planes, and the choice is not about how "agent-related" something feels —
 
 **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.
 
-A preset is a directory holding one `agent.cordis.yml`. The shipped ones live beside the deployment's composition; locally authored ones live under `$DSH_HOME/.agent-presets/<name>/`.
+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. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<name>/`.
+
+## Authoring a preset
+
+1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.
+2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands.
+3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster.
+4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above.
+
+The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete.
 
 ## The rule that catches people
 
@@ -48,9 +57,9 @@ A consumer left outside the group resolves the host's registry, which the preset
 
 ## Verifying a change
 
-Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests.
+Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it.
 
-After editing a preset, start a new session on it and confirm the tool list is what you intended. A preset is read at session creation, so an edit never affects a session already running; the file is never written back either, so your composition is exactly what you wrote.
+To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself.
 
 `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.
 

+ 59 - 1
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -8,7 +8,7 @@
 //
 // Zero model calls: no replay fixture mounts, so a stray stream fails loud.
 import { existsSync } from 'node:fs'
-import { mkdtemp, readFile, realpath } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring',
 const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
 const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
 const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
+const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
 /** The shipped roster, beside the composition that names it. */
 const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
 const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
@@ -174,6 +175,63 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
   }, 60_000)
 
+  it('marks damaged presets broken and clears a ghost through delete', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged'))
+    // The two hand-edit damage shapes: a composition that no longer parses,
+    // and a directory whose composition file was deleted outright.
+    await mkdir(join(userRoot, 'broken-yaml'), { recursive: true })
+    await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n  name: [unclosed\n')
+    await mkdir(join(userRoot, 'ghost'), { recursive: true })
+    await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n')
+
+    // The section reads the roster when it mounts; hop away and back.
+    const dialog = settingsDialog()
+    await dialog.getByRole('button', { name: '通用设置' }).click()
+    await dialog.getByRole('button', { name: 'Agent 预设' }).click()
+    await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
+
+    const snapshot = withPresetRoot(
+      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+    await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
+    // Both damage shapes surface as marked, unselectable, uncopyable cards
+    // that still carry their metadata and the discovery-reported reason.
+    expect(snapshot).toContain('已损坏: broken-yaml')
+    expect(snapshot).toContain('已损坏: 幽灵预设')
+    expect(snapshot).toContain('not valid YAML')
+    expect(snapshot).toContain('agent.cordis.yml is missing')
+    expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
+    expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
+    // A broken card offers no "set default" affordance at all — the aria name
+    // IS the broken marking, so the picking name must not exist.
+    expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
+
+    // The ghost's way out is the card's own delete — and the id it blocked
+    // is claimable again immediately afterwards.
+    await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click()
+    const confirm = page.getByRole('dialog', { name: '删除该预设?' })
+    await confirm.waitFor({ timeout: 10_000 })
+    await confirm.getByRole('button', { name: '删除', exact: true }).click()
+    await confirm.waitFor({ state: 'detached', timeout: 10_000 })
+    await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
+    expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
+
+    await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
+    const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
+    await copyDialog.waitFor({ timeout: 10_000 })
+    await copyDialog.getByPlaceholder('my-agent').fill('ghost')
+    await copyDialog.getByRole('button', { name: '创建' }).click()
+    await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
+    await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
+
+    // Leave the roster as the earlier tests shaped it.
+    await dialog.getByRole('button', { name: '删除: ghost' }).click()
+    const cleanup = page.getByRole('dialog', { name: '删除该预设?' })
+    await cleanup.waitFor({ timeout: 10_000 })
+    await cleanup.getByRole('button', { name: '删除', exact: true }).click()
+    await cleanup.waitFor({ state: 'detached', timeout: 10_000 })
+    await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true })
+  }, 60_000)
+
   it('starts a creator-mode session from the section', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator'))
     // Without a workspace the flow only stages (there is no session to land

+ 93 - 0
apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md

@@ -0,0 +1,93 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "Agent 预设" [level=2]
+  - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
+  - heading "内置" [level=3]
+  - list:
+    - listitem:
+      - 'button "当前使用: 标准模式" [disabled] [pressed]':
+        - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
+        - code: standard
+      - 'button "查看: 标准模式"':
+        - img
+        - text: 查看
+      - 'button "复制: 标准模式"':
+        - img
+        - text: 复制
+    - listitem:
+      - 'button "设为默认: 代码模式"':
+        - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
+        - code: code
+      - 'button "查看: 代码模式"':
+        - img
+        - text: 查看
+      - 'button "复制: 代码模式"':
+        - img
+        - text: 复制
+    - listitem:
+      - 'button "设为默认: 极简模式"':
+        - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
+        - code: minimal
+      - 'button "查看: 极简模式"':
+        - img
+        - text: 查看
+      - 'button "复制: 极简模式"':
+        - img
+        - text: 复制
+    - listitem:
+      - 'button "设为默认: 创造模式"':
+        - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
+        - code: cordis
+      - 'button "查看: 创造模式"':
+        - img
+        - text: 查看
+      - 'button "复制: 创造模式"':
+        - img
+        - text: 复制
+  - heading "自定义" [level=3]
+  - list:
+    - listitem:
+      - 'button "已损坏: broken-yaml" [disabled]':
+        - text: broken-yaml 已损坏 自定义 暂无描述。
+        - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
+        - code: broken-yaml
+      - 'button "查看路径: broken-yaml"':
+        - img
+        - text: 查看路径
+      - 'button "复制: broken-yaml" [disabled]':
+        - img
+        - text: 预设已损坏,无法复制
+      - 'button "删除: broken-yaml"':
+        - img
+        - text: 删除
+    - listitem:
+      - 'button "已损坏: 幽灵预设" [disabled]':
+        - text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
+        - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
+        - code: ghost
+      - 'button "查看路径: 幽灵预设"':
+        - img
+        - text: 查看路径
+      - 'button "复制: 幽灵预设" [disabled]':
+        - img
+        - text: 预设已损坏,无法复制
+      - 'button "删除: 幽灵预设"':
+        - img
+        - text: 删除
+  - button "用「创造模式」创作自定义预设":
+    - img
+    - text: 用「创造模式」创作自定义预设

+ 1 - 1
docs/config-catalog.md

@@ -139,7 +139,7 @@ export interface PresetRoot {
 export type PresetTrust = 'system' | 'user'
 ```
 
-Source: [`packages/preset/agent-presets/src/types.ts:35`](../packages/preset/agent-presets/src/types.ts)
+Source: [`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts)
 
 ## `@deepseek-ai/dsh-agent-spine-demo`
 

+ 4 - 0
docs/cordis-catalog/services.md

@@ -61,6 +61,10 @@ async list(): Promise<AgentPreset[]>
 
 /**
  * Resolve one preset by id.
+ *
+ * A broken preset resolves — deleting one, reading one, and reporting one
+ * all need the row — and the mounting paths refuse it AFTER resolution
+ * through {@link resolveMountable}.
  * @param id - the preset id, or `undefined` for {@link defaultId}.
  * @returns the resolved preset.
  * @throws when no configured root supplies that id.

+ 2 - 2
packages/client/ui-agent-preset/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
-README.md: 35358b1cbecffa3ad8214375ca2f929fed2d4b60
-README.zh.md: c6cce1aaf19748e44f21fde7b3f3fb3c8319e5a7
+README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c
+README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd

+ 2 - 0
packages/client/ui-agent-preset/README.md

@@ -42,6 +42,8 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re
 
 Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
 
+A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
+
 Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
 
 `agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it.

+ 2 - 0
packages/client/ui-agent-preset/README.zh.md

@@ -42,6 +42,8 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
 
 删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
 
+名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
+
 设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
 
 `agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。

+ 29 - 0
packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css

@@ -67,6 +67,35 @@
   border-color: var(--dsw-alias-label-primary);
 }
 
+/* A broken preset reads as damaged before anything else: the card cannot be
+   picked, so its border carries the warning the disabled body cannot. */
+.cardBroken {
+  border-color: var(--dsw-alias-state-error-primary);
+}
+
+.cardBroken:hover {
+  border-color: var(--dsw-alias-state-error-primary);
+}
+
+.brokenBadge {
+  border-radius: 999px;
+  padding: 1px 8px;
+  font-size: 11px;
+  line-height: 17px;
+  white-space: nowrap;
+  font-weight: 500;
+  background: var(--dsw-alias-state-error-primary);
+  color: var(--dsw-alias-bg-layer-3);
+}
+
+/* The discovery-reported reason, verbatim: it names the file and the fix. */
+.cardBrokenReason {
+  font-size: 12px;
+  line-height: 1.5;
+  color: var(--dsw-alias-state-error-primary);
+  overflow-wrap: anywhere;
+}
+
 /* The card body is the control that picks the preset. */
 .cardMain {
   flex: 1;

+ 39 - 19
packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx

@@ -177,49 +177,67 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
             <h3 className={css.groupHead}>{heading}</h3>
             <ul className={css.cards}>
               {group.map(row => (
-                <li key={row.id} className={row.isDefault ? `${css.card} ${css.cardActive}` : css.card}>
+                <li
+                  key={row.id}
+                  className={row.broken !== undefined
+                    ? `${css.card} ${css.cardBroken}`
+                    : row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
+                >
                   {/* The card body IS the control: picking a preset is the
                       common act, so it should not hide behind a small button.
                       The action row sits outside it — nesting buttons is
-                      invalid, and these act on the card rather than select it. */}
+                      invalid, and these act on the card rather than select it.
+                      A broken preset cannot compose a session, so its body is
+                      disabled and the card says why instead of offering it. */}
                   <button
                     type="button"
                     className={css.cardMain}
                     aria-pressed={row.isDefault}
-                    disabled={row.isDefault}
+                    disabled={row.isDefault || row.broken !== undefined}
                     // Without this the name is the whole card read aloud —
                     // title, badge, description, id.
-                    aria-label={`${row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
-                    title={row.isDefault ? t('inUse') : t('setDefault')}
+                    aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
+                    title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
                     onClick={() => { void props.makeDefault(row.id) }}
                   >
                     <span className={css.cardHead}>
                       <span className={css.cardName}>{row.name ?? row.id}</span>
+                      {row.broken !== undefined
+                        ? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
+                        : null}
                       <span className={css.badge}>
                         {row.trust === 'user' ? t('userTrust') : t('builtIn')}
                       </span>
                       {row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
                     </span>
                     <span className={css.cardDesc}>{row.description ?? t('noDescription')}</span>
+                    {row.broken === undefined
+                      ? null
+                      : <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
                     <code className={css.cardId}>{row.id}</code>
                   </button>
                   <div className={css.cardFoot}>
                     {/* Shipped presets are the compositions a copy starts
                         from, so READING one is the point; a custom preset is
                         edited in its files instead, which the location action
-                        leads to. */}
+                        leads to. A broken shipped preset has no readable
+                        composition to offer, so its viewer is withheld; a
+                        broken custom one keeps the location action — the
+                        files are where it gets fixed. */}
                     {row.trust === 'system'
-                      ? (
-                        <button
-                          type="button"
-                          className={css.iconButton}
-                          data-tip={t('view')}
-                          aria-label={`${t('view')}: ${row.name ?? row.id}`}
-                          onClick={() => { void props.view(row.id) }}
-                        >
-                          <IconBrowseOutline16 />
-                        </button>
-                      )
+                      ? row.broken === undefined
+                        ? (
+                          <button
+                            type="button"
+                            className={css.iconButton}
+                            data-tip={t('view')}
+                            aria-label={`${t('view')}: ${row.name ?? row.id}`}
+                            onClick={() => { void props.view(row.id) }}
+                          >
+                            <IconBrowseOutline16 />
+                          </button>
+                        )
+                        : null
                       : (
                         <button
                           type="button"
@@ -234,8 +252,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
                     <button
                       type="button"
                       className={css.iconButton}
-                      disabled={!state.authorable}
-                      data-tip={state.authorable ? t('duplicate') : t('duplicateUnavailable')}
+                      disabled={!state.authorable || row.broken !== undefined}
+                      data-tip={row.broken !== undefined
+                        ? t('brokenNoCopy')
+                        : state.authorable ? t('duplicate') : t('duplicateUnavailable')}
                       aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
                       onClick={() => { props.beginCopy(row.id) }}
                     >

+ 5 - 0
packages/client/ui-agent-preset/src/client/locales.ts

@@ -7,6 +7,7 @@ export type AgentPresetSettingsKey =
   | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
   | 'displayName' | 'displayNamePlaceholder'
   | 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
+  | 'brokenBadge' | 'brokenNoCopy'
   | 'composition' | 'cancel' | 'close' | 'retry'
   | 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
   | 'openLocation' | 'showLocation' | 'revealedPathLabel'
@@ -40,6 +41,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
   builtInGroup: 'Built-in',
   customGroup: 'Custom',
   noDescription: 'No description.',
+  brokenBadge: 'Broken',
+  brokenNoCopy: 'Broken presets cannot be duplicated',
   copyOf: 'Copied from',
   composition: 'Composition (agent.cordis.yml)',
   cancel: 'Cancel',
@@ -90,6 +93,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
   builtInGroup: '内置',
   customGroup: '自定义',
   noDescription: '暂无描述。',
+  brokenBadge: '已损坏',
+  brokenNoCopy: '预设已损坏,无法复制',
   copyOf: '复制自',
   composition: '组装(agent.cordis.yml)',
   cancel: '取消',

+ 7 - 0
packages/client/ui-agent-preset/src/client/section-store.ts

@@ -33,6 +33,13 @@ export interface PresetRow {
   trust: 'system' | 'user'
   /** Whether a session that names no preset gets this one. */
   isDefault: boolean
+  /**
+   * Why the preset cannot compose a session, absent when it can. A broken
+   * row renders marked and unselectable — its directory still occupies the
+   * id, so deleting it (or fixing the files) is the way out, and this page
+   * is where both of those live.
+   */
+  broken?: string
 }
 
 /** The copy dialog: a new id and optional display name over a fixed source. */

+ 15 - 8
packages/client/ui-agent-preset/src/client/settings-store.ts

@@ -73,6 +73,8 @@ export interface RosterPreset {
   name?: string
   /** One sentence on what the preset is for. */
   description?: string
+  /** Why the preset cannot compose a session, absent when it can. */
+  broken?: string
 }
 
 /** The roster the host answered with. */
@@ -134,19 +136,24 @@ export async function beginRosterRead<S extends { status: string; error: string
 }
 
 /**
- * The roster entries as every surface renders them.
+ * The roster entries as the pickers render them: healthy presets only.
  *
- * The chip, the row, and the management section all show the same three
- * facts, and `exactOptionalPropertyTypes` makes "absent" and "present as
- * undefined" different shapes — so the spread dance belongs in one place
- * rather than once per store.
+ * The chip and the row exist to choose the NEXT session's composition, and a
+ * broken preset cannot compose one — offering it would defer the discovery
+ * of that fact to a failed session start. The management section renders the
+ * full roster (broken rows included) from its own store instead.
+ *
+ * The chip, the row, and the management section all show the same facts, and
+ * `exactOptionalPropertyTypes` makes "absent" and "present as undefined"
+ * different shapes — so the spread dance belongs in one place rather than
+ * once per store.
  * @param presets - the roster the host answered with.
- * @returns one option per preset, in roster order.
+ * @returns one option per selectable preset, in roster order.
  */
 export function presetOptions(
-  presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string }[],
+  presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string; broken?: string }[],
 ): AgentPresetOption[] {
-  return presets.map(preset => ({
+  return presets.filter(preset => preset.broken === undefined).map(preset => ({
     id: preset.id,
     trust: preset.trust,
     ...preset.name === undefined ? {} : { name: preset.name },

+ 37 - 0
packages/client/ui-agent-preset/tests/section.spec.tsx

@@ -172,6 +172,43 @@ describe('the preset list', () => {
     expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable)
   })
 
+  it('marks a broken custom preset: unselectable, uncopyable, still deletable', () => {
+    const actions = renderSection({
+      rows: [
+        { id: 'standard', trust: 'system', isDefault: true },
+        { id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', broken: 'the composition file agent.cordis.yml is missing' },
+      ],
+    })
+
+    const ghost = rowFor('ghost')
+    // The reason is on the card, and the body cannot pick what cannot mount.
+    expect(within(ghost).getByText(en.brokenBadge)).toBeTruthy()
+    expect(within(ghost).getByRole('alert').textContent).toContain('is missing')
+    const body = within(ghost).getByRole('button', { name: `${en.brokenBadge}: 幽灵预设` })
+    expect(body).toHaveProperty('disabled', true)
+    fireEvent.click(body)
+    expect(actions.makeDefault).not.toHaveBeenCalled()
+    // Copying a broken preset would only mint another broken one; deleting
+    // and the location remain — the files are where it gets fixed.
+    const duplicate = within(ghost).getByRole('button', { name: `${en.duplicate}: 幽灵预设` })
+    expect(duplicate).toHaveProperty('disabled', true)
+    expect(duplicate.getAttribute('data-tip')).toBe(en.brokenNoCopy)
+    expect(within(ghost).getByRole('button', { name: `${en.delete}: 幽灵预设` })).toBeTruthy()
+    expect(within(ghost).getByRole('button', { name: `${en.openLocation}: 幽灵预设` })).toBeTruthy()
+  })
+
+  it('withholds the viewer on a broken shipped preset', () => {
+    renderSection({
+      rows: [{ id: 'standard', trust: 'system', isDefault: false, name: '标准模式', broken: 'the composition is not valid YAML' }],
+    })
+
+    // There is no readable composition to offer; the reason on the card is
+    // the whole story a shipped row can tell.
+    const standard = rowFor('standard')
+    expect(within(standard).queryByRole('button', { name: `${en.view}: 标准模式` })).toBeNull()
+    expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML')
+  })
+
   it('labels the location by what it will do without a desktop', () => {
     renderSection({ hasDocument: false })
 

+ 14 - 0
packages/client/ui-agent-preset/tests/settings-store.spec.ts

@@ -90,6 +90,20 @@ describe('the agent-preset settings controller', () => {
     ])
   })
 
+  it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => {
+    const controller = new AgentPresetSettingsController(fakeApi([
+      { id: 'standard', trust: 'system', isDefault: true },
+      { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' },
+    ] as never))
+
+    await controller.load()
+
+    // A broken preset cannot compose a session; listing it here would defer
+    // that discovery to a failed session start. The management section shows
+    // (and deletes) it from its own store instead.
+    expect(controller.store.getSnapshot().options.map(option => option.id)).toEqual(['standard'])
+  })
+
   it('carries the display metadata a preset published', async () => {
     const controller = new AgentPresetSettingsController(fakeApi([
       { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },

+ 2 - 2
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -90,7 +90,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async resolve(id?: string): Promise<AgentPreset>',
-        jsDoc: '/**\n * Resolve one preset by id.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the resolved preset.\n * @throws when no configured root supplies that id.\n */',
+        jsDoc: '/**\n * Resolve one preset by id.\n *\n * A broken preset resolves — deleting one, reading one, and reporting one\n * all need the row — and the mounting paths refuse it AFTER resolution\n * through {@link resolveMountable}.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the resolved preset.\n * @throws when no configured root supplies that id.\n */',
       },
       {
         signature: 'async mount(agentCtx: Context, id?: string): Promise<AgentPreset>',
@@ -1701,7 +1701,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'AgentPreset',
-    declaration: 'export interface AgentPreset {\n    readonly id: string;\n    readonly trust: PresetTrust;\n    readonly path: string;\n    readonly name?: string;\n    readonly description?: string;\n    readonly order?: number;\n}',
+    declaration: 'export interface AgentPreset {\n    readonly id: string;\n    readonly trust: PresetTrust;\n    readonly path: string;\n    readonly name?: string;\n    readonly description?: string;\n    readonly order?: number;\n    readonly broken?: string;\n}',
   },
   {
     name: 'AgentSetup',

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: c54c90e0ef243dd1910a4b34a2adf05dd8d43b66
-README.zh.md: 4965eb3375090c7ffafab536feee03e8015504c0
+README.md: 5567a1f0c9a428b9f5168707afd76fb534a30736
+README.zh.md: 87a98a9af21c351f2b5a4003b480b5e46a4e41e8

+ 1 - 1
packages/host/apiproxy/README.md

@@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
 
 `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
 
-The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names) and whether it is the current default. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
+The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
 
 `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
 

+ 1 - 1
packages/host/apiproxy/README.zh.md

@@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
 
 `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
 
-`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
+`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
 
 `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。
 

+ 1 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -2678,6 +2678,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             isDefault: preset.id === defaultId,
             ...preset.name === undefined ? {} : { name: preset.name },
             ...preset.description === undefined ? {} : { description: preset.description },
+            ...preset.broken === undefined ? {} : { broken: preset.broken },
           })),
           authorable: presets.authorable,
           hasDocument: canOpenPaths(),

+ 1 - 0
packages/host/apiproxy/src/api/agent-presets.schema.ts

@@ -16,6 +16,7 @@ export const agentPresetEntrySchema = z.object({
   isDefault: z.boolean(),
   name: z.string().optional(),
   description: z.string().optional(),
+  broken: z.string().min(1).optional(),
 }) satisfies z.ZodType<Wire<AgentPresetEntry>>
 
 /** agentPreset.list request payload. */

+ 7 - 0
packages/host/apiproxy/src/api/agent-presets.ts

@@ -33,6 +33,13 @@ export interface AgentPresetEntry {
   readonly name?: string
   /** One sentence on what the preset is for, when it published one. */
   readonly description?: string
+  /**
+   * Why this preset cannot compose a session, absent when it can. A broken
+   * preset stays listed — its directory still occupies the id, so a surface
+   * must be able to show and delete it — but offering it for selection would
+   * only defer this reason to a failed session start.
+   */
+  readonly broken?: string
 }
 
 /** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */

+ 2 - 2
packages/preset/agent-presets/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
-README.md: 26f54f3efe4eadc933b0b3aed7b0ed8f8816b7d4
-README.zh.md: 6688e84994beac9f937a8f01501d726267af08cf
+README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6
+README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170

+ 10 - 9
packages/preset/agent-presets/README.md

@@ -8,20 +8,20 @@ The mechanism is two seams. Entry contexts chain to the context a subtree was pl
 
 ## Service: `AgentPresets` (ctx key: `agentPresets`)
 
-Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call, so a preset authored while the process runs is visible immediately and a deleted one disappears from the next read.
+Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call, so a preset authored while the process runs is visible immediately and a deleted one disappears from the next read. Discovery also owns preset **health**: a directory whose composition is missing or unloadable (unparsable YAML — checked with the loader's own dialect, `!!js` included — or not a list of named plugin rows) is listed with a `broken` reason rather than skipped, because a skipped directory would still occupy its id on disk while every surface shows nothing to delete. A directory whose name is not a usable preset id (`[a-z0-9][a-z0-9-]*`) is skipped outright: no copy could ever claim it.
 
 - `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none.
-- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id.
-- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it.
-- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record.
-- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was.
-- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn.
+- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason.
+- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row.
+- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved.
+- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
+- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
 - `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
 - `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
 - `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
 - `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
 
-`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
+`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), `path` (the absolute composition file), and — only when the preset cannot compose a session — `broken` (one human-readable reason, shown verbatim on roster surfaces).
 
 ### Where to call `mount()`
 
@@ -44,7 +44,7 @@ The restriction to a produced-nothing agent is a product rule, not a mechanical
 Authoring is copy-only. A new preset is a whole-directory copy of an existing one — composition, metadata, skill directories, assets — landed under the first `user` root; the inputs are two ids the service resolves against its own roots plus an optional display name, so no caller ever supplies composition text and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files. `copy()` refuses three things before anything lands:
 
 - **An id that is not `[a-z0-9][a-z0-9-]*`.** The id becomes a directory name, so containment is a property of the id itself rather than of a path check after the fact — `../escape`, `a/b`, and an absolute path are all rejected as ids.
-- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk without being a preset refuses it too.
+- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk refuses it too. Discovery lists such a directory as a broken preset, so the refusal's way out — delete it — is on the same page that reported it.
 - **An unknown source.** The source may be any trust — copying a shipped preset is the primary case — but it must exist; a failed copy rolls its half-made directory back rather than leaving one discovery cannot see.
 
 The copied tree is re-tightened to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), symlinks are dereferenced so the copy is self-contained, and the root is created on first copy — a deployment configuring a user root that does not exist yet is the normal first-run state. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit in place, but its name and roster `order` are dropped — a copy presenting itself identically to its source, or sorted into the shipped set's declared order, would make the roster stop distinguishing them. `remove()` refuses a preset that ships with the deployment; the shipped set is the known-good compositions copies start from.
@@ -122,6 +122,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
 
 - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
 - **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
-- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy that fails at the next session that selects it, exactly as the source would.
+- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start.
+- **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back.
 - **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file.
 - **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`.

+ 10 - 9
packages/preset/agent-presets/README.zh.md

@@ -8,20 +8,20 @@
 
 ## 服务:`AgentPresets`(ctx 键:`agentPresets`)
 
-发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。
+发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。发现过程同时负责 preset 的**健康**:组装文件缺失或不可加载(YAML 无法解析——用加载器自己的方言检查,含 `!!js`——或不是由具名插件行组成的列表)的目录会作为携带 `broken` 原因的行列出而不是被跳过,因为被跳过的目录仍在磁盘上占着它的 id,而各个界面却没有任何可删的东西。目录名不是可用 preset id(`[a-z0-9][a-z0-9-]*`)的目录才被直接跳过:复制永远不可能占用那种名字。
 
 - `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。
-- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。
-- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。
-- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。
-- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。
-- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。
+- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。
+- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。
+- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。
+- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
+- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
 - `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
 - `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
 - `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
 - `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
 
-`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
+`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)、`path`(组装文件的绝对路径),以及——仅当该 preset 无法组装会话时——`broken`(一条人类可读的原因,名单界面原样展示)。
 
 ### 应在何处调用 `mount()`
 
@@ -44,7 +44,7 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
 创作即复制。新 preset 是某个既有 preset 的整目录副本——组装、元数据、skill 目录、附带资产——落在首个 `user` 根目录之下;输入只有两个由服务对照自身根目录解析的 id 加一个可选显示名,因此调用方从不提供组装文本,一次复制不会授予 roster 尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。`copy()` 在任何内容落盘之前拒绝三种情况:
 
 - **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。
-- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字却不是 preset 的目录同样拒绝。
+- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字的目录同样拒绝。发现过程会把这样的目录列为损坏的 preset,所以这条拒绝的出路——删掉它——就在报告它的同一页面上。
 - **未知的来源。** 来源可以是任何信任级别——复制随附 preset 正是主要用途——但必须存在;复制失败会回滚做到一半的目录,而不是留下一个 discovery 看不见的目录。
 
 复制出的目录树被收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),符号链接被解引用以保证副本自包含,且根目录在首次复制时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。复制出的 `preset.yml` 会被重写:保留来源的描述供作者就地编辑,但丢弃其名称与 roster `order`——副本若与来源呈现得一模一样、或按随附集合声明的顺序排序,roster 就不再能区分它们。`remove()` 拒绝随部署提供的 preset;随附集合正是副本的已知良好起点。
@@ -122,6 +122,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
 
 - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
 - **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
-- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出同样在下一个选择它的会话处失败的副本,与来源的失败方式完全一致。
+- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
+- **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。
 - **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。
 - **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。

+ 1 - 10
packages/preset/agent-presets/src/authoring.ts

@@ -17,16 +17,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
 import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { expandHomePath } from '@deepseek-ai/dsh-paths'
 import { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
-import type { AgentPreset, PresetRoot } from './types.ts'
-
-/**
- * Ids a preset directory may use.
- *
- * The id becomes a path segment, so this is a containment boundary rather than
- * a style rule: `..`, a separator, or an absolute-looking name would place the
- * composition outside the root the deployment authorised.
- */
-const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
+import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
 
 /** A preset id that cannot be used as a directory name under a root. */
 export class InvalidPresetIdError extends Error {

+ 90 - 5
packages/preset/agent-presets/src/discovery.ts

@@ -4,18 +4,92 @@
  * its display text; the directory name is the preset id. Discovery
  * re-reads the roots on every call so a preset authored while the process is
  * running is visible without a restart.
+ *
+ * Discovery also owns preset HEALTH: a directory whose composition is
+ * missing or unloadable is reported as a broken roster row rather than
+ * skipped. A skipped directory would still occupy its id on disk — the copy
+ * path refuses the name while no surface shows anything to delete — and a
+ * malformed composition would otherwise read as an ordinary preset until the
+ * first session fails to mount it.
  * @module @deepseek-ai/dsh-agent-presets/discovery
  */
 
-import { readdir, stat } from 'node:fs/promises'
+import { readdir, readFile, stat } from 'node:fs/promises'
 import { join, resolve } from 'node:path'
+import { load } from 'js-yaml'
+import { entryListSchema } from '@cordisjs/plugin-include'
 import { expandHomePath } from '@deepseek-ai/dsh-paths'
 import { readPresetMetadata } from './metadata.ts'
-import type { AgentPreset, PresetRoot } from './types.ts'
+import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
 
 /** The composition file that makes a directory a preset. */
 export const COMPOSITION_FILE = 'agent.cordis.yml'
 
+/**
+ * Why `rows` cannot be an entry list, or undefined when it can.
+ *
+ * A shallow shape check, deliberately short of the loader's work: it does not
+ * resolve plugin names or apply configs. What it catches is the hand-edit
+ * that produces a file the loader cannot even begin with — and it must accept
+ * everything the loader accepts, which is why rows are only required to be
+ * maps carrying a plugin `name` (groups recurse into their own lists).
+ * @param rows - the parsed composition document.
+ * @param at - row-path prefix for nested diagnostics, empty at the top level.
+ * @returns one human-readable reason, or undefined when the shape holds.
+ */
+function entryListProblem(rows: unknown, at = ''): string | undefined {
+  if (!Array.isArray(rows)) {
+    return at === ''
+      ? 'the composition must be a top-level list of plugin rows'
+      : `group ${at} must hold a list of plugin rows`
+  }
+  for (const [index, row] of rows.entries()) {
+    const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`
+    if (typeof row !== 'object' || row === null || Array.isArray(row)) {
+      return `${label} is not a plugin row (expected a map with a "name")`
+    }
+    const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown }
+    if (typeof name !== 'string' || name === '') {
+      return `${label} names no plugin (a "name" string is required)`
+    }
+    if (group === true) {
+      const nested = entryListProblem(config, label)
+      if (nested !== undefined) return nested
+    }
+  }
+  return undefined
+}
+
+/**
+ * Why the composition at `path` cannot mount, or undefined when it looks
+ * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema},
+ * the one carrying `!!js`), so health can never call a composition broken
+ * that the loader would accept.
+ * @param path - absolute path of the composition file.
+ * @returns one human-readable reason, or undefined when the file is loadable.
+ */
+async function compositionProblem(path: string): Promise<string | undefined> {
+  let content: string
+  try {
+    content = await readFile(path, 'utf8')
+  } catch {
+    // The caller statted this file moments ago; any read failure now —
+    // deleted in between, permissions — is the same answer as unparsable.
+    return `the composition file ${COMPOSITION_FILE} cannot be read`
+  }
+  let rows: unknown
+  try {
+    rows = load(content, { schema: entryListSchema })
+  } catch (error) {
+    /* v8 ignore next -- js-yaml throws YAMLException (an Error) for every parse failure; the fallback keeps a hostile value readable */
+    const full = error instanceof Error ? error.message : String(error)
+    // First line only: js-yaml appends a multi-line code-frame snippet, and
+    // the reason is displayed on a roster card, not in a terminal.
+    return `the composition is not valid YAML: ${full.replace(/\n[\s\S]*$/, '')}`
+  }
+  return entryListProblem(rows)
+}
+
 /**
  * Whether `path` names an existing regular file.
  * @param path - absolute path to test.
@@ -38,6 +112,12 @@ async function isFile(path: string): Promise<boolean> {
  * An absent root yields no presets rather than throwing: the user root does
  * not exist until the first locally authored preset, and naming a default
  * that no root supplies already fails loud at resolution.
+ *
+ * Every directory whose name is a usable preset id is a roster row — broken
+ * when its composition is missing or unloadable. A directory named outside
+ * {@link PRESET_ID} is skipped instead: no copy could ever claim that name,
+ * so it blocks nothing, and reporting `.DS_Store`-grade residue as broken
+ * presets would teach users to ignore the marker.
  * @param root - the directory and the trust its presets inherit.
  * @returns the root's presets ordered by id.
  */
@@ -52,14 +132,19 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
   }
   const found: AgentPreset[] = []
   for (const child of children) {
-    if (!child.isDirectory()) continue
+    if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue
     const directory = join(dir, child.name)
     const path = join(directory, COMPOSITION_FILE)
-    if (!await isFile(path)) continue
+    const broken = await isFile(path)
+      ? await compositionProblem(path)
+      : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`
     // Display text only, and never fatal: a preset with unreadable metadata
     // still mounts, it just shows its id.
     const metadata = await readPresetMetadata(directory)
-    found.push({ id: child.name, trust: root.trust, path, ...metadata })
+    found.push({
+      id: child.name, trust: root.trust, path, ...metadata,
+      ...broken === undefined ? {} : { broken },
+    })
   }
   // Declared order first so the shipped set reads by capability; everything
   // else falls back to the id, which keeps authored presets stable.

+ 25 - 3
packages/preset/agent-presets/src/index.ts

@@ -153,6 +153,10 @@ export class AgentPresets extends Service {
 
   /**
    * Resolve one preset by id.
+   *
+   * A broken preset resolves — deleting one, reading one, and reporting one
+   * all need the row — and the mounting paths refuse it AFTER resolution
+   * through {@link resolveMountable}.
    * @param id - the preset id, or `undefined` for {@link defaultId}.
    * @returns the resolved preset.
    * @throws when no configured root supplies that id.
@@ -167,6 +171,24 @@ export class AgentPresets extends Service {
     return found
   }
 
+  /**
+   * Resolve one preset that is about to compose an agent, refusing a broken
+   * one with its discovery-reported reason. Failing here rather than inside
+   * the loader keeps the answer the same for every unloadable shape — ghost
+   * directory, unparsable YAML, rowless list — and spends no mount attempt
+   * on a composition discovery already read as unusable.
+   * @param id - the preset id, or `undefined` for {@link defaultId}.
+   * @returns the resolved, mountable preset.
+   * @throws when the preset is unknown or discovery reports it broken.
+   */
+  private async resolveMountable(id?: string): Promise<AgentPreset> {
+    const preset = await this.resolve(id)
+    if (preset.broken !== undefined) {
+      throw new PresetMountError(preset.id, preset.broken)
+    }
+    return preset
+  }
+
   /**
    * Standing mounts by preset id, single-flight so two agents racing the
    * first use of one preset share one composition. A settled failure is
@@ -198,7 +220,7 @@ export class AgentPresets extends Service {
     if (agentKey === undefined) {
       throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset')
     }
-    const preset = await this.resolve(id)
+    const preset = await this.resolveMountable(id)
     const standing = await this.ensureStanding(preset)
     setScopeParent(agentKey, standing.key)
     return preset
@@ -314,7 +336,7 @@ export class AgentPresets extends Service {
     if (agentKey === undefined) {
       throw new Error('agent-presets: refusing to recompose an unscoped context')
     }
-    const preset = await this.resolve(id)
+    const preset = await this.resolveMountable(id)
     const standing = await this.ensureStanding(preset)
     setScopeParent(agentKey, standing.key)
     return preset
@@ -332,7 +354,7 @@ export class AgentPresets extends Service {
    * @throws when the preset is unknown or its composition is unusable.
    */
   async standingKeyFor(id?: string): Promise<ScopeKey> {
-    const preset = await this.resolve(id)
+    const preset = await this.resolveMountable(id)
     return (await this.ensureStanding(preset)).key
   }
 

+ 17 - 0
packages/preset/agent-presets/src/types.ts

@@ -7,6 +7,16 @@
  */
 export type PresetTrust = 'system' | 'user'
 
+/**
+ * Ids a preset directory may use.
+ *
+ * The id becomes a path segment, so this is a containment boundary rather than
+ * a style rule: `..`, a separator, or an absolute-looking name would place the
+ * composition outside the root the deployment authorised. Discovery shares it:
+ * a directory whose name no copy could ever claim is not a preset slot.
+ */
+export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
+
 /** One preset directory that carries a mountable agent composition. */
 export interface AgentPreset {
   /** Stable identifier; the preset directory's name. */
@@ -21,6 +31,13 @@ export interface AgentPreset {
   readonly description?: string
   /** Declared position within its group; absent sorts after those that declare one. */
   readonly order?: number
+  /**
+   * Why this preset cannot compose a session, absent when it can. A broken
+   * preset stays on the roster — hiding it would leave its directory blocking
+   * the id with nothing to see or delete — but every mounting path refuses it
+   * up front with this reason instead of failing deep inside the loader.
+   */
+  readonly broken?: string
 }
 
 /** One directory scanned for preset subdirectories. */

+ 30 - 5
packages/preset/agent-presets/tests/authoring.spec.ts

@@ -258,11 +258,36 @@ describe('display metadata beside a composition', () => {
   })
 })
 
-describe('a stray file beside the preset directories', () => {
-  it('does not become a preset', async () => {
-    await mkdir(join(userRoot, 'not-a-preset'), { recursive: true })
-    await writeFile(join(userRoot, 'not-a-preset', 'README.txt'), 'nope\n')
+describe('the on-disk occupancy backstop', () => {
+  it('refuses a directory the roster cannot see', async () => {
+    // The service's roster check sees every id-shaped directory now, so this
+    // is the race backstop: a directory appearing between the roster read and
+    // the copy still gets the readable refusal, not a filesystem error code.
+    await mkdir(join(userRoot, 'raced'), { recursive: true })
+    const source = await ctx.agentPresets.resolve('standard')
 
-    expect((await ctx.agentPresets.list()).some(preset => preset.id === 'not-a-preset')).toBe(false)
+    await expect(copyComposition(
+      [{ path: userRoot, trust: 'user' as const }], source, 'raced',
+    )).rejects.toThrow(/already exists/)
+  })
+})
+
+describe('a ghost directory under the user root', () => {
+  it('lists broken, blocks its id, and clears through remove', async () => {
+    // The classic hand-edit: the composition file was deleted, the directory
+    // stayed. It must not vanish from the roster — its id is still taken, so
+    // there has to be something to see and delete.
+    await mkdir(join(userRoot, 'ghost'), { recursive: true })
+    await writeFile(join(userRoot, 'ghost', 'README.txt'), 'composition deleted by hand\n')
+
+    const ghost = (await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')
+    expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
+    await expect(ctx.agentPresets.copy('standard', 'ghost')).rejects.toThrow(/already exists/)
+
+    // remove is the way out the roster row offers; the id is claimable again.
+    await ctx.agentPresets.remove('ghost')
+    expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
+    await ctx.agentPresets.copy('standard', 'ghost')
+    expect((await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')?.broken).toBeUndefined()
   })
 })

+ 87 - 3
packages/preset/agent-presets/tests/discovery.spec.ts

@@ -1,4 +1,4 @@
-import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -57,10 +57,28 @@ describe('preset discovery', () => {
     })
   })
 
-  it('skips a directory that holds no composition file', async () => {
+  it('reports a directory with no composition as a broken preset slot', async () => {
     const found = await scanRoot(USER)
 
-    expect(found.map(preset => preset.id)).not.toContain('not-a-preset')
+    // The directory still occupies its id — a copy to that name is refused —
+    // so hiding it would leave nothing to see or delete. It surfaces broken.
+    const ghost = found.find(preset => preset.id === 'not-a-preset')
+    expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
+  })
+
+  it('skips a directory whose name no preset id could ever claim', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-'))
+    await mkdir(join(root, '.hidden'))
+    await mkdir(join(root, 'Has_Caps'))
+    await mkdir(join(root, 'usable'))
+    await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n')
+
+    const found = await scanRoot({ path: root, trust: 'user' })
+
+    // `.hidden` and `Has_Caps` cannot collide with any copy target, so
+    // reporting tool residue as broken presets would only train users to
+    // ignore the marker.
+    expect(found.map(preset => preset.id)).toEqual(['usable'])
   })
 
   it('records the root trust on every preset it discovers', async () => {
@@ -111,3 +129,69 @@ describe('preset discovery', () => {
     expect(found).toEqual([])
   })
 })
+
+describe('composition health', () => {
+  /** One directory under a fresh root holding `composition`, scanned. */
+  async function scanned(composition: string): Promise<string | undefined> {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-'))
+    await mkdir(join(root, 'probe'))
+    await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
+    const [preset] = await scanRoot({ path: root, trust: 'user' })
+    return preset?.broken
+  }
+
+  it('reports unparsable YAML with the parser\'s reason', async () => {
+    expect(await scanned('- id: x\n  name: [unclosed\n')).toMatch(/not valid YAML/)
+  })
+
+  it('reports a composition that is not a list of rows', async () => {
+    expect(await scanned('name: not-a-list\n')).toMatch(/top-level list of plugin rows/)
+  })
+
+  it('reports the first row that names no plugin, by position', async () => {
+    expect(await scanned('- id: ok\n  name: some-plugin\n- id: broken\n'))
+      .toMatch(/row 2 names no plugin/)
+  })
+
+  it('reports a row that is not a map at all', async () => {
+    expect(await scanned('- just-a-string\n')).toMatch(/row 1 is not a plugin row/)
+  })
+
+  it('descends into a group\'s own row list', async () => {
+    const composition = '- id: grp\n  name: cordis:group\n  group: true\n  config:\n    - id: inner\n'
+    expect(await scanned(composition)).toMatch(/row 1 row 1 names no plugin/)
+  })
+
+  it('reports a group whose config is not a list', async () => {
+    const composition = '- id: grp\n  name: cordis:group\n  group: true\n  config: not-a-list\n'
+    expect(await scanned(composition)).toMatch(/group row 1 must hold a list/)
+  })
+
+  it('accepts a group whose own list is healthy', async () => {
+    const composition = '- id: grp\n  name: cordis:group\n  group: true\n  config:\n    - id: inner\n      name: some-plugin\n'
+    expect(await scanned(composition)).toBeUndefined()
+  })
+
+  it('reports a composition that stats but cannot be read', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-'))
+    await mkdir(join(root, 'sealed'))
+    const path = join(root, 'sealed', COMPOSITION_FILE)
+    await writeFile(path, '[]\n')
+    await chmod(path, 0o000)
+
+    const [preset] = await scanRoot({ path: root, trust: 'user' })
+
+    expect(preset?.broken).toMatch(/cannot be read/)
+  })
+
+  it('accepts the loader dialect, !!js scalars included', async () => {
+    // Health must never call a composition broken that the loader accepts:
+    // `!!js` is the loader's own extension, so it parses here too.
+    const composition = '- id: x\n  name: some-plugin\n  config:\n    value: !!js "1 + 1"\n'
+    expect(await scanned(composition)).toBeUndefined()
+  })
+
+  it('accepts an empty list', async () => {
+    expect(await scanned('[]\n')).toBeUndefined()
+  })
+})

+ 38 - 1
packages/preset/agent-presets/tests/mount.spec.ts

@@ -238,9 +238,11 @@ describe('the preset roster', () => {
   it('lists every root\'s presets with the earlier root winning', async () => {
     const listed = await ctx.agentPresets.list()
 
+    // `not-a-preset` is the fixture ghost: no composition file, listed broken.
     expect(listed.map(preset => preset.id).sort())
-      .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard', 'two-broken'])
+      .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'not-a-preset', 'pending', 'standard', 'two-broken'])
     expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
+    expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/)
   })
 
   it('exposes the configured default id', () => {
@@ -248,6 +250,41 @@ describe('the preset roster', () => {
   })
 })
 
+describe('composing from a broken preset', () => {
+  /** A roster whose only user preset carries `composition`. */
+  async function rosterWith(composition: string): Promise<Context> {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
+    await mkdir(join(root, 'damaged'))
+    await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
+    return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] })
+  }
+
+  it('refuses the mount up front with the discovery-reported reason', async () => {
+    const scoped = await rosterWith('- id: x\n  name: [unclosed\n')
+
+    // The refusal happens before the loader ever sees the file, so every
+    // unloadable shape gets the same early PresetMountError — and a rejected
+    // setup rolls the whole agent creation back.
+    await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError)
+    await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/)
+    expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0)
+  })
+
+  it('refuses the standing key a cold reader would mount by', async () => {
+    const scoped = await rosterWith('rows: not-a-list\n')
+
+    await expect(scoped.agentPresets.standingKeyFor('damaged'))
+      .rejects.toThrow(/top-level list of plugin rows/)
+  })
+
+  it('still resolves the broken row for the surfaces that manage it', async () => {
+    const scoped = await rosterWith('- id: x\n  name: [unclosed\n')
+
+    // Deleting and reporting need the row; only composing refuses it.
+    expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/)
+  })
+})
+
 describe('a roster with nothing in it', () => {
   it('says so instead of naming an empty list of candidates', async () => {
     const bare = new Context()