Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into turtle/orchestrator-3576650b674f

Turtle 2 недель назад
Родитель
Сommit
965de16f56
43 измененных файлов с 624 добавлено и 62 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
  2. 4 2
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
  3. 4 2
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml
  5. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
  6. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml
  8. 38 0
      .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md
  9. 38 0
      .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md
  10. 1 0
      THIRD_PARTY_NOTICES.md
  11. 2 2
      apps/desktop/README.i18n.yaml
  12. 2 2
      apps/desktop/README.md
  13. 2 2
      apps/desktop/README.zh.md
  14. 2 2
      apps/desktop/src/locale.ts
  15. 2 1
      apps/desktop/src/main.ts
  16. 6 8
      apps/desktop/src/project-manager.ts
  17. 1 1
      apps/desktop/tests/expected/fatal-dialog-en.txt
  18. 1 1
      apps/desktop/tests/expected/fatal-dialog-zh-CN.txt
  19. 13 2
      apps/desktop/tests/main-startup.spec.ts
  20. 11 5
      apps/desktop/tests/project-manager.spec.ts
  21. 14 1
      apps/web/tests/clickable-links-gallery.e2e.ts
  22. 5 0
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  23. 2 2
      docs/web-styling.i18n.yaml
  24. 1 1
      docs/web-styling.md
  25. 1 1
      docs/web-styling.zh.md
  26. 2 2
      packages/boot/app-boot/README.i18n.yaml
  27. 3 0
      packages/boot/app-boot/README.md
  28. 3 0
      packages/boot/app-boot/README.zh.md
  29. 1 0
      packages/boot/app-boot/src/index.ts
  30. 34 0
      packages/boot/app-boot/src/profile-sanitize.ts
  31. 95 0
      packages/boot/app-boot/tests/profile-sanitize.spec.ts
  32. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  33. 1 1
      packages/client/ui-primitives/README.md
  34. 1 1
      packages/client/ui-primitives/README.zh.md
  35. 2 1
      packages/client/ui-primitives/package.json
  36. 16 4
      packages/client/ui-primitives/src/LinkIcon.tsx
  37. 149 0
      packages/client/ui-primitives/src/SiteGlyph.tsx
  38. 1 1
      packages/client/ui-primitives/src/WebBlock.tsx
  39. 1 1
      packages/client/ui-primitives/src/markdown/render.tsx
  40. 105 0
      packages/client/ui-primitives/tests/link-icon.client.spec.tsx
  41. 9 0
      packages/client/ui-primitives/tests/markdown.client.spec.tsx
  42. 21 1
      packages/client/ui-primitives/tests/web-block.client.spec.tsx
  43. 14 5
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
-2026-09-15-desktop-native-fatal-recovery.md: b2a0dfff1a630d1eedaa898e4a4c8144ff7ae776
-2026-09-15-desktop-native-fatal-recovery.zh.md: 0fee729e3be791d10966050d21482c5132b14fe6
+2026-09-15-desktop-native-fatal-recovery.md: 63ec81991f3e396075fe4ed09a8ddaa87c865862
+2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md

@@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven
 
 Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
 
-The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
+The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
 
 The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
 
@@ -22,6 +22,8 @@ This supersedes recovery-page and reset behavior in the [immediate-window decisi
 
 A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control.
 
+**Disable bundles while retaining the active profile patch.** A malformed patch or a patch that inserts a broken plugin can still prevent startup. Renaming preserves the user’s exact bytes for manual repair while removing that layer from startup; unique backup names preserve earlier recovery attempts.
+
 ## Consequences
 
-Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.
+Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. A malformed home-level patch still blocks startup after profile recovery and requires manual repair. Backups accumulate in the profile directory without automatic pruning; users remove them when no longer needed. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
 
-首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
 
 Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
 
@@ -22,6 +22,8 @@ Web 文档保留在原位。宿主回调负责启动失败展示,共享启动
 
 Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。
 
+**禁用 bundle,但保留生效的 profile patch。** 损坏的 patch 或插入故障插件的 patch 仍可阻止启动。重命名保留用户原始内容供手动修复,同时将该层移出启动过程;唯一备份名保留此前恢复操作的备份。
+
 ## Consequences
 
-恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。
+恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。损坏的 home 级 patch 在 profile 恢复后仍会阻止启动,需要手动修复。备份在 profile 目录中累积,不会自动清理;用户在不再需要时删除。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
-2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc
-2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6
+2026-09-04-web-clickable-link-styles.md: fc383387cede1517981358fd31a08f47c2d76c02
+2026-09-04-web-clickable-link-styles.zh.md: 7336a740e2ba93fe67a504745dcf770cab3c803d

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md

@@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma
 One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
 
 - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
-- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
+- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
 - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
 - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
 - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
@@ -23,7 +23,7 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl
 ## Alternatives considered
 
 - **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead.
-- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category.
+- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site marks live behind the same `url` category ([known-site link marks](2026-09-16-known-site-link-marks.md)).
 - **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line.
 - **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md

@@ -13,7 +13,7 @@ Status: implemented
 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
 
 - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
-- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
+- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
@@ -23,7 +23,7 @@ Status: implemented
 ## 备选方案
 
 - **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。
-- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。
+- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份;按站点标记位于同一 `url` 类别之下([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md))。
 - **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。
 - **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md
+2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84
+2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4

+ 38 - 0
.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md

@@ -0,0 +1,38 @@
+# Agent Note: Known-site link marks
+
+Status: implemented
+
+English | [中文](2026-09-16-known-site-link-marks.zh.md)
+
+## Problem
+
+Every transcribed anchor led with the same globe, so a transcript full of GitHub, npm, and documentation links gave no signal about where a link goes until the reader parsed its label. The [clickable-link vocabulary](2026-09-04-web-clickable-link-styles.md) reserved that leading seat for one category glyph and left per-site marks as a possible later extension of the `url` category.
+
+## Decision
+
+`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site.
+
+`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe.
+
+The artwork is the [Simple Icons](https://simpleicons.org) set (CC0-1.0) consumed as a devDependency of ui-primitives, so adding a site is one host-map entry and the artwork version comes from the lockfile rather than a copied path. Each mark renders as that set's single path on its 24-unit viewBox, inset to a 28-unit box so it keeps the ~8% margin the 20-unit `ic_ds_*` glyphs draw inside their own box, and takes the link's `currentColor` rather than the brand fill the set records. Every mark is `aria-hidden`; the anchor's own text remains the accessible name.
+
+Taking `currentColor` is deliberate for a functional link glyph rather than brand presentation: YouTube, X, Instagram, and other brands publish guidelines against recoloring their marks, and the first alternative below weighs the fixed-brand-fill option. CC0-1.0 covers the path data; every mark remains its owner's trademark, and the generated third-party notices cover the package rather than this artwork.
+
+Two consumers pass their destination: the markdown renderer's `renderSafeLink`, which covers authored anchors, reference links, and URL-promoted inline code, and the web card's source and fetch links. Both already hold the sanitized destination.
+
+## Alternatives considered
+
+- **Fetching each site's favicon** from the site itself or a favicon service. This covers arbitrary sites, but rendering a transcript would issue network requests to every linked host, which discloses reading activity and turns a text render into a network operation; it also fails offline and behind a strict `img-src` policy. Rejected: a fixed local vocabulary keeps rendering deterministic and private, and the globe remains the honest fallback.
+- **Filling the mark with the site's brand color.** Rejected: the link glyphs are `currentColor`-only so they follow the link alias, dark mode, and hover; a fixed fill would be the first exception and would fight the link's own hover color.
+- **Copying the path data into this module.** Rejected: a third-party artwork set that changes upstream is exactly what a pinned dependency tracks; fourteen embedded copies would leave no update path.
+- **Adding site values to `LinkIconKind`.** Rejected: the kind is the category the consumer states, while the site is derived from the destination; folding both into one union would make every consumer spell out a site it does not know.
+- **A monogram tile for every unmapped host.** Rejected as noise: a generated letter capsule claims a site identity without carrying one, and 14px leaves no room for readable initials.
+
+## Consequences
+
+- Adding a site needs a host-map entry in `SiteGlyph.tsx` and, to be checked, a URL in the `link-icon` spec: that spec lists a representative URL per mapped site, requires thirty-four distinct marks across them, and pins the GitHub, npm, YouTube, Wikipedia, X, Telegram, and WhatsApp aliases. A dropped or duplicated entry fails the count, while a site added to the map alone stays uncovered by the spec.
+- The vocabulary stays bounded by category rather than by demand: each site costs one map entry, one upstream mark, and one spec URL, and the thirty-four marks add roughly 23 KB of path data to the source, which the shell build reduces to a few KB in the entry chunk after tree-shaking, minification, and compression.
+- `simple-icons` enters the browser build graph as a development dependency of ui-primitives; the shell build tree-shakes the imported marks instead of the complete set.
+- Unknown hosts, non-http schemes, unparseable destinations, and file categories keep the existing globe or category glyph; the `mailto` link in a transcript still shows the globe.
+- The vocabulary is deliberately finite. Sites such as `example.com` never get a mark from this mechanism; recognizing them would require the network fetching the alternatives reject.
+- Coverage: the LinkIcon spec covers the aliases, the fallback branches, and the sizing seat; the markdown spec pins one known and one unknown anchor; the web-card spec pins a source and a fetch link against the same marks.

+ 38 - 0
.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: 已知站点链接标记
+
+Status: implemented
+
+[English](2026-09-16-known-site-link-marks.md) | 中文
+
+## Problem
+
+转写内容中的每个锚点都以同一个地球图形开头,因此满是 GitHub、npm 与文档链接的对话在读者解析标签之前,无法从图形看出链接去向。[可点击链接词汇](2026-09-04-web-clickable-link-styles.zh.md) 把该前置位置留给一个分类图形,并把按站点区分的标记留作 `url` 类别日后可能的扩展。
+
+## Decision
+
+`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。
+
+ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。
+
+图形取自 [Simple Icons](https://simpleicons.org) 图标集(CC0-1.0),以 ui-primitives 的 devDependency 形式引入,因此新增站点只是主机映射中的一条记录,图形版本由 lockfile 固定,而不是复制路径数据。每个标记以该图标集的单条 path 绘制在其 24 单位 viewBox 上,并内缩到 28 单位盒中,以保留 20 单位 `ic_ds_*` 图形在自身盒内约 8% 的留白;填充使用链接的 `currentColor`,而不是图标集记录的品牌填充色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。
+
+对功能性链接图形采用 `currentColor` 是刻意的取舍,而非品牌呈现:YouTube、X、Instagram 等品牌公布的规范禁止为其标记改色,下方第一个备选方案比较了固定品牌填充的做法。CC0-1.0 覆盖路径数据;每个标记仍属其所有者的商标,生成的第三方声明覆盖该 npm 包本身,而不是这份图形。
+
+两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。
+
+## Alternatives considered
+
+- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。
+- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。
+- **把路径数据复制进本模块。** 拒绝:上游会持续更新的第三方图形集正是需要固定版本依赖来跟踪的内容;十四个内嵌副本没有更新路径。
+- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。
+- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。
+
+## Consequences
+
+- 新增站点需要在 `SiteGlyph.tsx` 的主机映射中加一条记录,并在 `link-icon` spec 中补一个 URL 才会被检查:该 spec 为每个已映射站点列出一个代表 URL、要求它们共产生三十四个互不相同的标记,并固定 GitHub、npm、YouTube、Wikipedia、X、Telegram 与 WhatsApp 的别名。记录丢失或重复会让数量断言失败,而只改主机映射、不补 spec URL 的新站点仍未被 spec 覆盖。
+- 词汇表按类别而非按需求增长:每个站点需要一条主机映射、一个上游标记与一个 spec URL;这三十四个标记在源码中增加约 23 KB 路径数据,经 shell 构建的 tree-shake、压缩与 gzip 后进入入口 chunk 的只有几 KB。
+- `simple-icons` 作为 ui-primitives 的开发依赖进入浏览器构建图;shell 构建会 tree-shake 所导入的标记,而不是整套图标。
+- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。
+- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。
+- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -110,6 +110,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`semver`](https://github.com/npm/node-semver) | ISC |
 | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
 | [`shiki`](https://github.com/shikijs/shiki) | MIT |
+| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
 | [`supports-color`](https://github.com/chalk/supports-color) | MIT |
 | [`tsx`](https://github.com/privatenumber/tsx) | MIT |
 | [`turndown`](https://github.com/mixmark-io/turndown) | MIT |

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: fbb1249752a1957c61bb20c89cb76271e48a302f
-README.zh.md: b3841813c14c2823d28258df12d90479fde7597f
+README.md: c14b98486ddd612afadab12039804ef69ec029d0
+README.zh.md: a72bb850a8d689df377c5ac7bd133950c159b86c

+ 2 - 2
apps/desktop/README.md

@@ -66,11 +66,11 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
 
 CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
 
 Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
-Recovery waits for Host shutdown before changing plugin activation. The native recovery action disables third-party bundles by writing the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document.
+Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
 
 Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 

+ 2 - 2
apps/desktop/README.zh.md

@@ -66,11 +66,11 @@ macOS 还会获得标准的 File 和 Window 菜单以及应用隐藏命令;由
 
 CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
 
 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
-恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
+恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
 
 包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
 

+ 2 - 2
apps/desktop/src/locale.ts

@@ -9,7 +9,7 @@ export const en = {
   exitApplication: 'Exit',
   restartApplication: 'Restart',
   recoveryOperationFailed: 'The recovery operation failed',
-  disableThirdPartyPlugins: 'Disable all third-party plugins and restart',
+  disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
   pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
@@ -59,7 +59,7 @@ export const zh = {
   exitApplication: '退出',
   restartApplication: '重启',
   recoveryOperationFailed: '恢复操作失败',
-  disableThirdPartyPlugins: '禁用全部第三方插件并重启',
+  disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
   pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',

+ 2 - 1
apps/desktop/src/main.ts

@@ -37,7 +37,8 @@ const recovery = new DesktopFatalRecovery({
   stop: () => { shuttingDown = true; return stopForRecovery() },
   disablePlugins: async () => {
     const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
-    await manager.disableAllPlugins()
+    const backupPath = await manager.disableAllPlugins()
+    console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' })
   },
   exit: () => { app.quit() },
   restart: () => { app.relaunch(); app.quit() },

+ 6 - 8
apps/desktop/src/project-manager.ts

@@ -25,7 +25,7 @@ import type { DesktopPaths } from './paths.ts'
 import type { DesktopRelease } from './release.ts'
 import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
-  initProfile, PROFILE_TEMPLATES, readProfileManifest, readProfilePlugins, reconcileProfilePlugins,
+  initProfile, PROFILE_TEMPLATES, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile,
   unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
 } from '@deepseek-ai/dsh-app-boot'
 import { migrateDesktopProfileLinks } from './profile-packages.ts'
@@ -122,14 +122,12 @@ export class DesktopProjectManager {
   }
 
   /**
-   * Disable third-party bundles without loading application resources or deleting plugin files.
-   * @returns Completion of the locked profile write; the caller must stop the Host first.
+   * Back up the profile patch and disable third-party bundles without loading application resources.
+   * The caller must stop the Host first.
+   * @returns Backup path after the locked profile write, or undefined if the patch was absent.
    */
-  async disableAllPlugins(): Promise<void> {
-    await this.withLock(() => {
-      if (!existsSync(join(this.paths.profile, 'package.json'))) return
-      writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
-    })
+  async disableAllPlugins(): Promise<string | undefined> {
+    return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles))
   }
 
   /** Read the dsh version supplied by this application's verified resources. */

+ 1 - 1
apps/desktop/tests/expected/fatal-dialog-en.txt

@@ -6,4 +6,4 @@ Plugin initialization failed
 If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
 Exit
 Restart
-Disable all third-party plugins and restart
+Disable third-party plugins, back up profile patch, and restart

+ 1 - 1
apps/desktop/tests/expected/fatal-dialog-zh-CN.txt

@@ -6,4 +6,4 @@ Plugin initialization failed
 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
 退出
 重启
-禁用全部第三方插件并重启
+禁用第三方插件、备份 profile patch 并重启

+ 13 - 2
apps/desktop/tests/main-startup.spec.ts

@@ -102,7 +102,10 @@ const harness = await vi.hoisted(async () => {
     openExternal: vi.fn(),
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
     mutateFailure: vi.fn<() => void>(),
-    disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }),
+    disableAllPlugins: vi.fn(async () => {
+      pluginsEnabled = false
+      return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000'
+    }),
     get preparing() { return preparing }, get prepared() { return prepared },
     get hostStarted() { return hostStarted }, get navigated() { return navigated },
     get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
@@ -172,6 +175,7 @@ beforeEach(() => {
   harness.reset()
   harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) })
   vi.spyOn(console, 'error').mockImplementation(() => {})
+  vi.spyOn(console, 'info').mockImplementation(() => {})
   vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
   vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
   vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
@@ -417,7 +421,7 @@ describe('desktop main startup', () => {
     harness.prepared.reject(new Error('runtime resources missing'))
     await harness.dialogShown.promise
     expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
-    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart'])
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart'])
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
@@ -473,6 +477,13 @@ describe('desktop main startup', () => {
     await harness.quitCompleted.promise
     expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
     expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
+    if (response === 2) {
+      expect(console.info).toHaveBeenCalledWith('Desktop profile recovery completed:', {
+        profilePatchBackup: 'desktop-test-profile/cordis.patch.yml.bak-1789555200000', homePatch: 'unchanged',
+      })
+    } else {
+      expect(console.info).not.toHaveBeenCalled()
+    }
     expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })

+ 11 - 5
apps/desktop/tests/project-manager.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -109,15 +109,19 @@ describe('desktop external plugin profile', () => {
     await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
-  it('disables plugins before runtime initialization and preserves configuration and package files', async () => {
+  it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
     const { manager } = setup()
     await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
     const patch = join(manager.paths.profile, 'cordis.patch.yml')
     writeFileSync(patch, ': broken')
     const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
-    await uninitialized.disableAllPlugins()
-    expect(readFileSync(patch, 'utf8')).toBe(': broken')
+    const backupPath = await uninitialized.disableAllPlugins()
+    expect(existsSync(patch)).toBe(false)
+    const backups = readdirSync(manager.paths.profile).filter(name => name.startsWith('cordis.patch.yml.bak-'))
+    expect(backups).toHaveLength(1)
+    expect(backupPath).toBe(join(manager.paths.profile, backups[0]!))
+    expect(readFileSync(join(manager.paths.profile, backups[0]!), 'utf8')).toBe(': broken')
     expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
     const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
       dependencies: Record<string, string>
@@ -126,11 +130,13 @@ describe('desktop external plugin profile', () => {
     expect(manifest.dependencies.plugin).toBe('1.0.0')
     expect(manifest.dsh.profile.bundles).not.toContain('plugin')
     expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app')
+    await manager.applyRelease()
+    expect(readFileSync(patch, 'utf8')).toContain('[]')
   })
 
   it('needs no runtime or package manifest when no plugins have been installed', async () => {
     const { manager } = setup()
-    await manager.disableAllPlugins()
+    await expect(manager.disableAllPlugins()).resolves.toBeUndefined()
     expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false)
     expect(existsSync(manager.paths.lock)).toBe(false)
   })

+ 14 - 1
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -2,7 +2,8 @@
 // in one settled keyless turn — the regression anchor for unifying link
 // styles. One fixture turn produces:
 // - prose: Markdown link, reference-style link, mailto link, inline-code URL,
-//   produced-file mention, plus inert contrasts (ambiguous basename, unwritten
+//   produced-file mention, a known-site link carrying that site's own leading
+//   mark, plus inert contrasts (ambiguous basename, unwritten
 //   file, command code, URL-with-flags code, javascript: destination,
 //   footnote superscript, remote image, fenced code block with its copy chrome)
 // - artifacts: seven produced files (chips overflow into the "+N" remainder
@@ -55,6 +56,9 @@ const MAILTO_URL = 'mailto:owner@example.test'
 const SOURCE_URL = 'https://docs.example.test/links'
 const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec'
 const FETCH_URL = 'https://docs.example.test/tokens'
+// A mapped host, so the prose pins that the leading glyph is the site's mark
+// rather than the globe the unmapped docs host keeps.
+const REPO_URL = 'https://github.com/example/link-gallery'
 
 /** One-part text content for a built message. */
 function text(value: string): { type: 'text'; text: string }[] {
@@ -256,6 +260,8 @@ function galleryFixture(imageUrl: string): string {
         `Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
         + `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
         '',
+        `Upstream: [the repository](${REPO_URL}).`,
+        '',
         `Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
         '',
         'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
@@ -418,6 +424,13 @@ describe('web e2e: clickable links gallery', () => {
       expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none')
       expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1)
     }
+    // A mapped host leads with its own mark; the unmapped docs host keeps the
+    // globe in the same seat.
+    const repoLink = markdown.locator(`a[href="${REPO_URL}"]`)
+    expect(await repoLink.count()).toBe(1)
+    const repoMark = await repoLink.locator('svg path').getAttribute('d')
+    const globeMark = await guideLink.locator('svg path').getAttribute('d')
+    expect(repoMark).not.toBe(globeMark)
     await guideLink.hover()
     expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
     expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted')

+ 5 - 0
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -142,6 +142,11 @@
   - text: ", and check the fine print"
   - superscript: "1"
   - text: .
+- paragraph:
+  - text: "Upstream:"
+  - link "the repository":
+    - /url: https://github.com/example/link-gallery
+  - text: .
 - paragraph:
   - text: "Inert contrasts:"
   - code: curl https://docs.example.test/api

+ 2 - 2
docs/web-styling.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/web-styling.md
-web-styling.md: dbd696307d5044c165c77948059859eecd32b4a7
-web-styling.zh.md: af3f66960e8e0596577d155919a051688aca372b
+web-styling.md: c158562f0c862e883514c9d4057c87d4df65119e
+web-styling.zh.md: 7086bab6ff65c5a4415795aebd04da1e86e68c4f

+ 1 - 1
docs/web-styling.md

@@ -23,7 +23,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
 - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
 - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
 - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders.
-- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md)).
+- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)).
 
 ## Changing the system
 

+ 1 - 1
docs/web-styling.zh.md

@@ -23,7 +23,7 @@
 - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。
-- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md))。
+- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。
 
 ## 变更系统
 

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: cf0d67fb14633cfdf9a30754125b3e08df2d0496
-README.zh.md: dab003c581ba5744893e612eeee812b56c25f1a2
+README.md: d7168c82ade6fd301f1100157ef0336cad619146
+README.zh.md: 7f918e5e38eef1eaf7060a84720825b8d5709b1c

+ 3 - 0
packages/boot/app-boot/README.md

@@ -60,6 +60,8 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
 
 Before mounting profile rows, the `dsh` launcher computes one immutable package-resolution generation from the installation and ordered bundle dependency graphs. The default link mode materializes the existing shared and profile-owned fallback links, so supported launch behavior stays unchanged. Internal callers and test harnesses can instead install the generation through Node's ESM and CommonJS resolvers in runtime mode, or materialize and verify the same generation in dual mode.
 
+`sanitizeProfile(binName, profileDir, bundles)` provides filesystem recovery without loading plugins or parsing patches. Desktop uses it for native fatal recovery. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s `cordis.patch.yml` to a unique `.bak-<timestamp>` sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. The timestamp is Unix time in milliseconds; collisions append an ordinal (`-1`, `-2`, …) without changing it. It returns the backup path, or `undefined` when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry.
+
 ### Previewing the effective configuration
 
 Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
@@ -129,6 +131,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 | [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section |
 | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback |
 | [`src/profile-plugins.ts`](src/profile-plugins.ts) | Installed dependencies, bundle activation policy, and manifest updates |
+| [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | Profile patch backup and recovery bundle activation |
 | [`src/profile-resolution/`](src/profile-resolution/) | Runtime resolver, package-metadata service, and built Worker bootstrap |
 | — | No runtime invariant companion is published; one registration owns each resolver generation, and dual mode compares the independently materialized result at resolution time. |
 

+ 3 - 0
packages/boot/app-boot/README.zh.md

@@ -60,6 +60,8 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 package resolution generation。默认 link 模式会物化现有的共享 fallback 链接与 profile 自有 fallback 链接,因此受支持的启动行为保持不变。内部调用方和测试工具可以改用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以使用 dual 模式,同时物化并校验同一份 generation。
 
+`sanitizeProfile(binName, profileDir, bundles)` 提供文件恢复,无需加载插件或解析 patch。Desktop 在原生致命错误恢复中调用它。调用前必须停止 profile 并排除并发 profile 写入。它将 profile 的 `cordis.patch.yml` 重命名为带唯一 `.bak-<timestamp>` 后缀的同目录备份,并恢复调用方指定的 bundle 列表,保留已安装包和其他 manifest 字段。时间戳为 Unix 毫秒数;同名备份已存在时追加序号(`-1`、`-2`、……),时间戳保持不变。返回值为备份路径;patch 不存在时返回 `undefined`,缺失的 profile 不会被创建。下次启动的 profile 初始化会重新创建空 patch。home 级 patch 不变。无效 profile JSON 在修改前报错;后续错误向调用方抛出,保留已完成的修改供重试。
+
 ### 预览生效配置
 
 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。
@@ -129,6 +131,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 | [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 |
 | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 |
 | [`src/profile-plugins.ts`](src/profile-plugins.ts) | 已安装依赖、bundle 启用策略与 manifest 更新 |
+| [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | profile patch 备份与恢复 bundle 启用状态 |
 | [`src/profile-resolution/`](src/profile-resolution/) | 运行时 resolver、package metadata 服务与构建后 Worker bootstrap |
 | — | 不发布运行时不变式伴生入口;每个 resolver generation 只有一个 registration 所有,dual 模式在解析时比较独立物化的结果。 |
 

+ 1 - 0
packages/boot/app-boot/src/index.ts

@@ -18,6 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group'
 import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts'
+export { sanitizeProfile } from './profile-sanitize.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 export {

+ 34 - 0
packages/boot/app-boot/src/profile-sanitize.ts

@@ -0,0 +1,34 @@
+/** Filesystem recovery for callers that own profile shutdown and write exclusion. */
+
+import { existsSync, renameSync } from 'node:fs'
+import { join } from 'node:path'
+import { PROFILE_PATCH_FILENAME, readProfileManifest } from './profile.ts'
+import { writeProfileBundles } from './profile-plugins.ts'
+
+/**
+ * Back up the profile patch and retain only the caller's recovery bundles.
+ * The caller must stop the profile and exclude concurrent profile writes.
+ * Installed packages and other manifest fields are preserved; patches are never parsed.
+ * Failures propagate and may leave completed changes in place for a retry.
+ * @param binName - Diagnostic prefix for invalid profile manifests.
+ * @param profileDir - Profile directory to recover without loading its plugins.
+ * @param bundles - Ordered bundles to enable after recovery.
+ * @returns Backup path with a Unix millisecond timestamp and optional collision ordinal, or undefined if absent.
+ */
+export function sanitizeProfile(binName: string, profileDir: string, bundles: readonly string[]): string | undefined {
+  const manifest = existsSync(join(profileDir, 'package.json')) ? readProfileManifest(binName, profileDir) : undefined
+  const patchPath = join(profileDir, PROFILE_PATCH_FILENAME)
+  const backupBase = `${patchPath}.bak-${Date.now()}`
+  let backupPath: string | undefined = backupBase
+  let ordinal = 0
+  // Caller-owned write exclusion keeps the selected destination absent until rename.
+  while (existsSync(backupPath)) backupPath = `${backupBase}-${++ordinal}`
+  try {
+    renameSync(patchPath, backupPath)
+  } catch (error) {
+    if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
+    backupPath = undefined
+  }
+  if (manifest !== undefined) writeProfileBundles(profileDir, manifest, bundles)
+  return backupPath
+}

+ 95 - 0
packages/boot/app-boot/tests/profile-sanitize.spec.ts

@@ -0,0 +1,95 @@
+/** Recovery preserves user files while removing them from profile startup. */
+
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it, vi } from 'vitest'
+import { initProfile, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, readProfileManifest, sanitizeProfile } from '../src/index.ts'
+
+vi.mock('node:fs', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:fs')>()
+  return { ...actual, renameSync: vi.fn(actual.renameSync) }
+})
+
+const roots: string[] = []
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.mocked(renameSync).mockClear()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function fixture() {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-profile-sanitize-'))
+  roots.push(root)
+  const dir = join(root, 'profiles', 'web')
+  const bundles = PROFILE_TEMPLATES.web!.bundles
+  initProfile(dir, [...bundles, 'broken-plugin'])
+  const manifestPath = join(dir, 'package.json')
+  const manifest = { ...readProfileManifest('test', dir), dependencies: { 'broken-plugin': '1.2.3' }, custom: 'retained' }
+  writeFileSync(manifestPath, JSON.stringify(manifest))
+  const patch = join(dir, PROFILE_PATCH_FILENAME)
+  writeFileSync(patch, ': broken YAML')
+  return { root, dir, bundles, manifest, manifestPath, patch }
+}
+
+it('recovers a profile without loading its broken plugins or patch', () => {
+  const { dir, bundles, manifest, patch } = fixture()
+  const packageDir = join(dir, 'node_modules', 'broken-plugin')
+  mkdirSync(packageDir, { recursive: true })
+  const pluginManifest = join(packageDir, 'package.json')
+  writeFileSync(pluginManifest, '{broken')
+  const backup = sanitizeProfile('test', dir, bundles)
+  expect(backup).toMatch(/cordis\.patch\.yml\.bak-\d{13}$/u)
+  expect(existsSync(patch)).toBe(false)
+  expect(readFileSync(backup!, 'utf8')).toBe(': broken YAML')
+  expect(readFileSync(pluginManifest, 'utf8')).toBe('{broken')
+  expect(readProfileManifest('test', dir)).toEqual({ ...manifest, dsh: { profile: { bundles } } })
+  initProfile(dir, bundles)
+  expect(readFileSync(patch, 'utf8')).toContain('[]')
+})
+
+it('preserves previous backups across retries and later recovery actions', () => {
+  const timestamp = 1_789_555_200_000
+  vi.spyOn(Date, 'now').mockReturnValue(timestamp)
+  const { dir, bundles, patch } = fixture()
+  const first = sanitizeProfile('test', dir, bundles)!
+  expect(sanitizeProfile('test', dir, bundles)).toBeUndefined()
+  writeFileSync(patch, 'second patch')
+  const second = sanitizeProfile('test', dir, bundles)!
+  expect(first).toBe(`${patch}.bak-${timestamp}`)
+  expect(second).toBe(`${patch}.bak-${timestamp}-1`)
+  writeFileSync(patch, 'third patch')
+  expect(sanitizeProfile('test', dir, bundles)).toBe(`${patch}.bak-${timestamp}-2`)
+  expect(readFileSync(first, 'utf8')).toBe(': broken YAML')
+  expect(readFileSync(second, 'utf8')).toBe('second patch')
+  expect(readFileSync(`${patch}.bak-${timestamp}-2`, 'utf8')).toBe('third patch')
+})
+
+it('does not create an absent profile and backs up a patch even without a manifest', () => {
+  const { root, bundles } = fixture()
+  const dir = join(root, 'missing')
+  expect(sanitizeProfile('test', dir, bundles)).toBeUndefined()
+  expect(existsSync(dir)).toBe(false)
+  mkdirSync(dir)
+  writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'orphan patch')
+  const backup = sanitizeProfile('test', dir, bundles)!
+  expect(readFileSync(backup, 'utf8')).toBe('orphan patch')
+  expect(existsSync(join(dir, 'package.json'))).toBe(false)
+})
+
+it('leaves the patch untouched when profile JSON is invalid', () => {
+  const { dir, bundles, manifestPath, patch } = fixture()
+  writeFileSync(manifestPath, '{broken')
+  expect(() => sanitizeProfile('test', dir, bundles)).toThrow()
+  expect(readFileSync(patch, 'utf8')).toBe(': broken YAML')
+  expect(readdirSync(dir).some(name => name.includes('.bak-'))).toBe(false)
+})
+
+it('propagates backup failures before changing activation', () => {
+  const { dir, bundles, manifest, patch } = fixture()
+  const error = Object.assign(new Error('patch rename denied'), { code: 'EACCES' })
+  vi.mocked(renameSync).mockImplementationOnce(() => { throw error })
+  expect(() => sanitizeProfile('test', dir, bundles)).toThrow(error)
+  expect(readFileSync(patch, 'utf8')).toBe(': broken YAML')
+  expect(readProfileManifest('test', dir)).toEqual(manifest)
+})

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
-README.md: 9290831ca4f33899a88e61e8fdd8f9951de905fa
-README.zh.md: b865c49c86df5f2269cea90be9d93bfa3abdff51
+README.md: 6863c40d7d5111bb1db7e71436a795eac462e0bc
+README.zh.md: 28beb2ecb36eca6c8c759b97b600ef1496bbcbe4

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/client/ui-primitives/README.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/client/ui-primitives/README.zh.md


+ 2 - 1
packages/client/ui-primitives/package.json

@@ -46,7 +46,8 @@
     "mdast-util-gfm": "^3.1.0",
     "mdast-util-math": "^3.0.0",
     "micromark-extension-gfm": "^3.0.0",
-    "micromark-util-sanitize-uri": "^2.0.1"
+    "micromark-util-sanitize-uri": "^2.0.1",
+    "simple-icons": "16.31.0"
   },
   "files": [
     "lib/index.js",

+ 16 - 4
packages/client/ui-primitives/src/LinkIcon.tsx

@@ -7,10 +7,15 @@
  * cannot compose a glyph outside a link. Design sources:
  * ic_globe_language_outline_20, ic_code_outline_20, ic_folder_outline_20,
  * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20.
+ *
+ * A `url` glyph whose destination names a well-known site is that site's own
+ * mark instead of the globe (`SiteGlyph.tsx`); every mark keeps the same
+ * currentColor-only rule.
  */
 import type { ReactNode } from 'react'
 import { classifyFileType, fileExtension } from './FileTypeIcon.tsx'
 import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts'
+import { siteGlyph } from './SiteGlyph.tsx'
 import type { IconProps } from './icons/props.ts'
 
 /**
@@ -24,6 +29,12 @@ export type LinkIconKind = 'url' | 'folder' | 'code' | 'image' | 'document' | 'o
 /** Props for {@link LinkIcon}: the category plus the shared icon sizing seat. */
 export interface LinkIconProps extends IconProps {
   kind: LinkIconKind
+  /**
+   * Destination of a `url` link. A well-known site draws its own mark, so
+   * callers that know the destination should pass it; anything else keeps the
+   * globe. Other kinds ignore it — their destination is a path, not a site.
+   */
+  href?: string | undefined
 }
 
 /**
@@ -133,13 +144,14 @@ function assertNever(value: never): never {
 
 /**
  * Render the leading glyph for one clickable artifact link.
- * @param props - The link category, optional size (default 14px — the inline
- * link text size these glyphs sit beside), and optional CSS class.
+ * @param props - The link category, the optional destination that can select a
+ * site mark, optional size (default 14px — the inline link text size these
+ * glyphs sit beside), and optional CSS class.
  * @returns The category's SVG glyph, riding currentColor.
  */
-export function LinkIcon({ kind, size = 14, className }: LinkIconProps): ReactNode {
+export function LinkIcon({ kind, href, size = 14, className }: LinkIconProps): ReactNode {
   switch (kind) {
-    case 'url': return <GlobeGlyph size={size} className={className} />
+    case 'url': return siteGlyph({ href, size, className }) ?? <GlobeGlyph size={size} className={className} />
     case 'folder': return <FolderGlyph size={size} className={className} />
     case 'code': return <CodeGlyph size={size} className={className} />
     case 'image': return <PhotoGlyph size={size} className={className} />

+ 149 - 0
packages/client/ui-primitives/src/SiteGlyph.tsx

@@ -0,0 +1,149 @@
+/**
+ * Site marks for well-known external link hosts. {@link LinkIcon} renders one in
+ * the leading glyph seat, so a familiar destination leads with its own mark
+ * instead of the generic globe. The marks are the `simple-icons` artwork set
+ * (CC0-1.0), pinned by this package's dependency on it; each mark takes the
+ * link's `currentColor` rather than the brand fill that set records.
+ */
+import type { ReactElement } from 'react'
+import {
+  siAliexpress,
+  siApple,
+  siBaidu,
+  siBilibili,
+  siCsdn,
+  siDuckduckgo,
+  siEbay,
+  siFacebook,
+  siGithub,
+  siGitlab,
+  siGoogle,
+  siInstagram,
+  siJuejin,
+  siMdnwebdocs,
+  siNetflix,
+  siNpm,
+  siPypi,
+  siQq,
+  siQuora,
+  siReddit,
+  siSinaweibo,
+  siSpotify,
+  siStackoverflow,
+  siTaobao,
+  siTelegram,
+  siTiktok,
+  siV2ex,
+  siWechat,
+  siWhatsapp,
+  siWikipedia,
+  siX,
+  siYcombinator,
+  siYoutube,
+  siZhihu,
+} from 'simple-icons'
+import type { SimpleIcon } from 'simple-icons'
+
+/**
+ * Host suffix to site mark, covering the developer sites the transcript
+ * usually cites plus the mainstream search, video, social, shopping, and
+ * reference sites a general audience links. A host matches a suffix when it
+ * equals it or is a subdomain of it, and the longest matching suffix wins, so
+ * `weixin.qq.com` keeps WeChat while `qq.com` keeps QQ and `gist.github.com`
+ * needs no entry of its own.
+ */
+const SITE_HOSTS: Readonly<Record<string, SimpleIcon>> = {
+  'github.com': siGithub,
+  'github.io': siGithub,
+  'raw.githubusercontent.com': siGithub,
+  'gitlab.com': siGitlab,
+  'npmjs.com': siNpm,
+  'pypi.org': siPypi,
+  'stackoverflow.com': siStackoverflow,
+  'developer.mozilla.org': siMdnwebdocs,
+  'wikipedia.org': siWikipedia,
+  'news.ycombinator.com': siYcombinator,
+  'youtube.com': siYoutube,
+  'youtu.be': siYoutube,
+  'x.com': siX,
+  'twitter.com': siX,
+  'bilibili.com': siBilibili,
+  'zhihu.com': siZhihu,
+  'juejin.cn': siJuejin,
+  'csdn.net': siCsdn,
+  'google.com': siGoogle,
+  'baidu.com': siBaidu,
+  'duckduckgo.com': siDuckduckgo,
+  'tiktok.com': siTiktok,
+  'netflix.com': siNetflix,
+  'spotify.com': siSpotify,
+  'facebook.com': siFacebook,
+  'instagram.com': siInstagram,
+  'reddit.com': siReddit,
+  'telegram.org': siTelegram,
+  't.me': siTelegram,
+  'weixin.qq.com': siWechat,
+  'qq.com': siQq,
+  'whatsapp.com': siWhatsapp,
+  'wa.me': siWhatsapp,
+  'weibo.com': siSinaweibo,
+  'taobao.com': siTaobao,
+  'aliexpress.com': siAliexpress,
+  'ebay.com': siEbay,
+  'quora.com': siQuora,
+  'v2ex.com': siV2ex,
+  'apple.com': siApple,
+}
+
+/**
+ * Resolve the mark named by an external destination.
+ * @param href - The link destination; only an absolute http(s) URL can name a host.
+ * @returns The matched site mark, or undefined when the host is unknown or not http(s).
+ */
+function siteIcon(href: string): SimpleIcon | undefined {
+  let host: string
+  try {
+    const url = new URL(href)
+    if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
+    host = url.hostname.toLowerCase()
+  } catch {
+    // Not an absolute URL: nothing here can name a host.
+    return undefined
+  }
+  let match: SimpleIcon | undefined
+  let matched = 0
+  for (const [suffix, icon] of Object.entries(SITE_HOSTS)) {
+    if ((host === suffix || host.endsWith(`.${suffix}`)) && suffix.length > matched) {
+      match = icon
+      matched = suffix.length
+    }
+  }
+  return match
+}
+
+/** Props for {@link siteGlyph}: the destination plus the shared icon sizing seat. */
+interface SiteGlyphProps {
+  /** The link destination; only an absolute http(s) URL can name a host. */
+  href: string | undefined
+  /** Square edge in px. */
+  size: number
+  /** Extra class for layout placement. */
+  className?: string | undefined
+}
+
+/**
+ * Render the site mark for a known external destination.
+ * @param props - The destination and the icon sizing seat.
+ * @returns The site's mark riding currentColor, or undefined for an unknown site.
+ */
+export function siteGlyph({ href, size, className }: SiteGlyphProps): ReactElement | undefined {
+  const icon = href === undefined ? undefined : siteIcon(href)
+  if (icon === undefined) return undefined
+  return (
+    // Simple Icons fill their 24-unit box edge to edge; the -2 inset leaves the
+    // same ~8% margin the 20-unit ic_ds_* glyphs draw inside their own box.
+    <svg width={size} height={size} className={className} viewBox="-2 -2 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
+      <path d={icon.path} fill="currentColor" />
+    </svg>
+  )
+}

+ 1 - 1
packages/client/ui-primitives/src/WebBlock.tsx

@@ -112,7 +112,7 @@ function SafeLink({ url, label, className }: { url: string; label: string; class
   if (href === undefined) return <span className={className}>{label}</span>
   return (
     <a className={className} href={href} target="_blank" rel="noopener noreferrer">
-      <LinkIcon kind="url" className={css.linkIcon} />
+      <LinkIcon kind="url" href={href} className={css.linkIcon} />
       {label}
     </a>
   )

+ 1 - 1
packages/client/ui-primitives/src/markdown/render.tsx

@@ -536,7 +536,7 @@ function renderSafeLink(href: string, children: ReactNode[], key: Key, glyph = t
       href={safeHref}
       {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
     >
-      {glyph && <LinkIcon kind="url" className={css.linkIcon} />}
+      {glyph && <LinkIcon kind="url" href={safeHref} className={css.linkIcon} />}
       {children}
     </a>
   )

+ 105 - 0
packages/client/ui-primitives/tests/link-icon.client.spec.tsx

@@ -63,3 +63,108 @@ describe('LinkIcon', () => {
     expect(svg.classList.contains('x')).toBe(true)
   })
 })
+
+/** One host per mapped site, plus aliases that must resolve to a mark already listed. */
+const SITE_URLS: string[] = [
+  'https://github.com/org/repo',
+  'https://gist.github.com/abc',
+  'https://org.github.io/repo',
+  'https://raw.githubusercontent.com/org/repo/main/a.ts',
+  'https://gitlab.com/org/repo',
+  'https://www.npmjs.com/package/x',
+  'https://pypi.org/project/x',
+  'https://stackoverflow.com/q/1',
+  'https://developer.mozilla.org/en-US/docs/Web',
+  'https://en.wikipedia.org/wiki/Harness',
+  'https://news.ycombinator.com/item?id=1',
+  'https://youtu.be/dQw4w9WgXcQ',
+  'https://x.com/x',
+  'https://www.bilibili.com/video/BV1',
+  'https://zhuanlan.zhihu.com/p/1',
+  'https://juejin.cn/post/1',
+  'https://blog.csdn.net/post/1',
+  'https://www.google.com/search?q=1',
+  'https://baike.baidu.com/item/1',
+  'https://duckduckgo.com/?q=1',
+  'https://www.tiktok.com/@x',
+  'https://www.netflix.com/title/1',
+  'https://open.spotify.com/track/1',
+  'https://www.facebook.com/x',
+  'https://www.instagram.com/x',
+  'https://www.reddit.com/r/x',
+  'https://t.me/x',
+  'https://weixin.qq.com/x',
+  'https://im.qq.com/x',
+  'https://wa.me/1',
+  'https://weibo.com/x',
+  'https://item.taobao.com/item.htm?id=1',
+  'https://www.aliexpress.com/item/1',
+  'https://www.ebay.com/itm/1',
+  'https://www.quora.com/q/1',
+  'https://www.v2ex.com/t/1',
+  'https://www.apple.com/mac',
+]
+
+/** The single path data of the mark a URL renders. */
+function glyphPath(href: string): string {
+  const { container } = render(<LinkIcon kind="url" href={href} />)
+  return container.querySelector('path')!.getAttribute('d')!
+}
+
+describe('LinkIcon site marks', () => {
+  it('draws each mapped site its own mark, not the globe', () => {
+    const globe = render(<LinkIcon kind="url" />).container.querySelector('path')!.getAttribute('d')
+    const marks = SITE_URLS.map(glyphPath)
+    expect(marks).not.toContain(globe)
+    // Thirty-seven destinations, thirty-four mapped sites: the three extra
+    // GitHub hosts share the GitHub mark, and any other number means the site
+    // list grew without this expectation moving with it.
+    expect(new Set(marks).size).toBe(34)
+  })
+
+  it('resolves the aliases of one site to the same mark', () => {
+    const github = glyphPath('https://github.com/org/repo')
+    expect(glyphPath('https://gist.github.com/abc')).toBe(github)
+    expect(glyphPath('https://org.github.io/repo')).toBe(github)
+    expect(glyphPath('https://raw.githubusercontent.com/org/repo/main/a.ts')).toBe(github)
+    expect(glyphPath('https://www.npmjs.com/package/x')).toBe(glyphPath('https://npmjs.com/package/x'))
+    expect(glyphPath('https://en.wikipedia.org/wiki/Harness')).toBe(glyphPath('https://www.wikipedia.org'))
+    expect(glyphPath('https://youtu.be/dQw4w9WgXcQ')).toBe(glyphPath('https://m.youtube.com/watch?v=1'))
+    expect(glyphPath('https://twitter.com/x')).toBe(glyphPath('https://x.com/x'))
+    expect(glyphPath('https://telegram.org/x')).toBe(glyphPath('https://t.me/x'))
+    expect(glyphPath('https://whatsapp.com/x')).toBe(glyphPath('https://wa.me/1'))
+  })
+
+  it('keeps the longer suffix mark for a subdomain of a mapped host', () => {
+    // weixin.qq.com ends in qq.com as well, so the longest match has to win.
+    expect(glyphPath('https://weixin.qq.com/x')).not.toBe(glyphPath('https://im.qq.com/x'))
+  })
+
+  it.each([
+    ['no destination', undefined],
+    ['an unknown host', 'https://example.com/a'],
+    ['a host that only ends in a mapped name', 'https://notgithub.com/a'],
+    ['a non-http scheme', 'mailto:owner@example.com'],
+    ['a destination that is not a URL', 'src/index.ts'],
+    ['a single-label host', 'https:///a'],
+  ])('keeps the globe for %s', (_case, href) => {
+    const globe = render(<LinkIcon kind="url" />).container.querySelector('svg')!.outerHTML
+    expect(render(<LinkIcon kind="url" href={href} />).container.querySelector('svg')!.outerHTML).toBe(globe)
+  })
+
+  it('ignores the destination for file categories', () => {
+    const plain = render(<LinkIcon kind="code" />).container.querySelector('svg')!.outerHTML
+    expect(render(<LinkIcon kind="code" href="https://github.com/a" />).container.querySelector('svg')!.outerHTML).toBe(plain)
+  })
+
+  it('sizes the site mark through the shared seat', () => {
+    const { container } = render(<LinkIcon kind="url" href="https://github.com/a" size={20} className="x" />)
+    const svg = container.querySelector('svg')!
+    expect(svg.getAttribute('width')).toBe('20')
+    expect(svg.getAttribute('height')).toBe('20')
+    expect(svg.classList.contains('x')).toBe(true)
+    expect(svg.getAttribute('aria-hidden')).toBe('true')
+    expect(container.innerHTML).toContain('currentColor')
+    expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}"/)
+  })
+})

+ 9 - 0
packages/client/ui-primitives/tests/markdown.client.spec.tsx

@@ -2,6 +2,7 @@
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it } from 'vitest'
 import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
+import { LinkIcon } from '../src/index.ts'
 import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
 import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
 
@@ -309,6 +310,14 @@ describe('MarkdownText', () => {
     expect(screen.getByText('mail diagram')).toBeTruthy()
   })
 
+  it('leads a known site link with its own mark and an unknown host with the globe', () => {
+    const { container } = render(<MarkdownText text={'[repo](https://github.com/org/repo) [docs](https://example.com/a)'} />)
+    const marks = [...container.querySelectorAll('p a svg path')].map(path => path.getAttribute('d'))
+    const globe = render(<LinkIcon kind="url" />).container.querySelector('path')!.getAttribute('d')
+    const github = render(<LinkIcon kind="url" href="https://github.com/a" />).container.querySelector('path')!.getAttribute('d')
+    expect(marks).toEqual([github, globe])
+  })
+
   it('keeps incomplete streaming Markdown renderable', () => {
     const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
     expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()

+ 21 - 1
packages/client/ui-primitives/tests/web-block.client.spec.tsx

@@ -2,7 +2,7 @@
 
 import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import { WebBlock as LocalizedWebBlock } from '../src/index.ts'
+import { LinkIcon, WebBlock as LocalizedWebBlock } from '../src/index.ts'
 import type {
   WebFetchBlockProps, WebSearchBlockProps, WebSourceView,
 } from '../src/index.ts'
@@ -28,6 +28,12 @@ function WebBlock(props: WebBlockProps) {
 
 afterEach(cleanup)
 
+/** The path data of the mark a `url` link leads with for one destination. */
+function glyphMark(href: string | undefined): string | null {
+  const { container } = render(<LinkIcon kind="url" href={href} />)
+  return container.querySelector('path')!.getAttribute('d')
+}
+
 function sources(count: number): WebSourceView[] {
   return Array.from({ length: count }, (_value, index) => ({
     url: `https://site-${index}.example.com/page`,
@@ -102,6 +108,20 @@ describe('WebBlock search card', () => {
     expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
   })
 
+  it("leads a source and a fetched url on a known site with that site's mark", () => {
+    /** The mark a link leads with, as its path data. */
+    const mark = (element: Element): string | null => element.querySelector('a svg path')!.getAttribute('d')
+    const view = render(<WebBlock kind="search" truncated={false} sources={[
+      { url: 'https://github.com/org/repo', title: 'Repo' },
+    ]} />)
+    expect(mark(view.container)).toBe(glyphMark('https://github.com/a'))
+    // An unmapped host keeps the globe, as does the fetch card's URL.
+    expect(mark(render(<WebBlock kind="search" sources={sources(1)} truncated={false} />).container))
+      .toBe(glyphMark(undefined))
+    expect(mark(render(<WebBlock kind="fetch" url="https://news.ycombinator.com/item?id=1" statusCode={200} truncated={false} />).container))
+      .toBe(glyphMark('https://news.ycombinator.com/item?id=2'))
+  })
+
   it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
     const view = render(<WebBlock kind="search" truncated={false} sources={[
       { url: 'javascript:alert(1)', title: 'Dangerous' },

+ 14 - 5
pnpm-lock.yaml

@@ -3761,6 +3761,9 @@ importers:
       shiki:
         specifier: ^4.3.1
         version: 4.3.1
+      simple-icons:
+        specifier: 16.31.0
+        version: 16.31.0
 
   packages/client/ui-reference:
     devDependencies:
@@ -8045,7 +8048,7 @@ importers:
         version: link:../../../vendor/schemastery
       '@earendil-works/pi-ai':
         specifier: ^0.85.1
-        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
+        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -18850,6 +18853,10 @@ packages:
   simple-get@4.0.1:
     resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
 
+  simple-icons@16.31.0:
+    resolution: {integrity: sha512-dDEqvQrmxL7ZnqQtGHGjCNW6CfiwzAif+83Hol9JXAnAB6wh2zsZD7Sc1kWT0O/RRAKt7xfG8W6UZhYBPD+GAA==}
+    engines: {node: '>=0.12.18'}
+
   simple-update-notifier@2.0.0:
     resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
     engines: {node: '>=10'}
@@ -20396,14 +20403,14 @@ snapshots:
     transitivePeerDependencies:
       - '@algolia/client-search'
 
-  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
+  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)':
     dependencies:
       '@anthropic-ai/sdk': 0.123.0(zod@4.4.3)
       '@aws-sdk/client-bedrock-runtime': 3.1048.0
       '@earendil-works/pi-telemetry': 0.85.1
       '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
       '@smithy/node-http-handler': 4.7.3
-      http-proxy-agent: 7.0.2
+      http-proxy-agent: 7.0.2(supports-color@9.4.0)
       https-proxy-agent: 7.0.6
       openai: 6.40.0(ws@8.21.0)(zod@4.4.3)
       partial-json: 0.1.7
@@ -23023,7 +23030,7 @@ snapshots:
       cross-spawn: 7.0.6
       debug: 4.4.3(supports-color@9.4.0)
       fs-extra: 10.1.0
-      http-proxy-agent: 7.0.2
+      http-proxy-agent: 7.0.2(supports-color@9.4.0)
       https-proxy-agent: 7.0.6
       js-yaml: 4.3.1
       sanitize-filename: 1.6.4
@@ -24320,7 +24327,7 @@ snapshots:
       statuses: 2.0.2
       toidentifier: 1.0.1
 
-  http-proxy-agent@7.0.2:
+  http-proxy-agent@7.0.2(supports-color@9.4.0):
     dependencies:
       agent-base: 7.1.4
       debug: 4.4.3(supports-color@9.4.0)
@@ -26129,6 +26136,8 @@ snapshots:
       once: 1.4.0
       simple-concat: 1.0.1
 
+  simple-icons@16.31.0: {}
+
   simple-update-notifier@2.0.0:
     dependencies:
       semver: 7.8.5

Некоторые файлы не были показаны из-за большого количества измененных файлов