Browse Source

merge: sync origin/master

_Kerman 1 month ago
parent
commit
7599652627
71 changed files with 2942 additions and 228 deletions
  1. 3 3
      .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.i18n.yaml
  2. 35 0
      .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.md
  3. 35 0
      .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.zh.md
  4. 2 2
      .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml
  5. 5 1
      .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md
  6. 5 1
      .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md
  7. 10 6
      .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md
  8. 0 47
      .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md
  9. 38 8
      .github/issue-management/policy.mjs
  10. 97 0
      .github/issue-management/policy.test.mjs
  11. 0 1
      .github/review-ownership/CODEOWNERS
  12. 5 5
      .github/review-ownership/README.md
  13. 0 59
      .github/review-ownership/README.zh.md
  14. 117 36
      .github/review-ownership/request-review.mjs
  15. 270 32
      .github/review-ownership/request-review.test.mjs
  16. 2 2
      docs/capability-seams.i18n.yaml
  17. 8 2
      docs/capability-seams.md
  18. 8 2
      docs/capability-seams.zh.md
  19. 2 2
      docs/config-catalog.i18n.yaml
  20. 2 1
      docs/config-catalog.md
  21. 2 1
      docs/config-catalog.zh.md
  22. 2 2
      docs/i18n/README.i18n.yaml
  23. 1 0
      docs/i18n/README.md
  24. 1 0
      docs/i18n/README.zh.md
  25. 2 2
      docs/module-graph.i18n.yaml
  26. 11 0
      docs/module-graph.md
  27. 11 0
      docs/module-graph.zh.md
  28. 2 2
      docs/subsystems/tools.i18n.yaml
  29. 25 0
      docs/subsystems/tools.md
  30. 25 0
      docs/subsystems/tools.zh.md
  31. 2 2
      docs/tool-catalog.i18n.yaml
  32. 64 0
      docs/tool-catalog.md
  33. 64 0
      docs/tool-catalog.zh.md
  34. 2 2
      packages/README.i18n.yaml
  35. 1 0
      packages/README.md
  36. 1 0
      packages/README.zh.md
  37. 1 0
      packages/core/system-prompt/src/index.ts
  38. 2 2
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  39. 21 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  40. 1 0
      packages/preset/agent-presets/package.json
  41. 5 0
      packages/preset/agent-presets/presets/cordis/agent.cordis.yml
  42. 5 0
      packages/preset/agent-presets/presets/standard/agent.cordis.yml
  43. 3 3
      packages/visualizer/README.i18n.yaml
  44. 41 0
      packages/visualizer/README.md
  45. 41 0
      packages/visualizer/README.zh.md
  46. 6 0
      packages/visualizer/tool-visualizer/README.i18n.yaml
  47. 101 0
      packages/visualizer/tool-visualizer/README.md
  48. 101 0
      packages/visualizer/tool-visualizer/README.zh.md
  49. 76 0
      packages/visualizer/tool-visualizer/package.json
  50. 6 0
      packages/visualizer/tool-visualizer/src/client.ts
  51. 68 0
      packages/visualizer/tool-visualizer/src/guidelines.ts
  52. 94 0
      packages/visualizer/tool-visualizer/src/index.ts
  53. 20 0
      packages/visualizer/tool-visualizer/src/model.ts
  54. 10 0
      packages/visualizer/tool-visualizer/src/prompt.ts
  55. 142 0
      packages/visualizer/tool-visualizer/src/recorded-calls.ts
  56. 104 0
      packages/visualizer/tool-visualizer/src/tools.ts
  57. 9 0
      packages/visualizer/tool-visualizer/src/types.ts
  58. 132 0
      packages/visualizer/tool-visualizer/tests/__snapshots__/model-surface.spec.ts.snap
  59. 203 0
      packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts
  60. 474 0
      packages/visualizer/tool-visualizer/tests/model-surface.spec.ts
  61. 294 0
      packages/visualizer/tool-visualizer/tests/recorded-calls.spec.ts
  62. 19 0
      packages/visualizer/tool-visualizer/tsconfig.json
  63. 14 0
      packages/visualizer/tool-visualizer/tsdown.config.ts
  64. 46 0
      pnpm-lock.yaml
  65. 1 0
      python/sdk-runtime/package.json
  66. 2 0
      scripts/gen-cordis-catalog.ts
  67. 9 2
      scripts/gen-doc-graphs.ts
  68. 24 0
      scripts/gen-tool-catalog.ts
  69. 2 0
      scripts/translation-pairing.manifest.json
  70. 3 0
      tsconfig.base.json
  71. 2 0
      tsconfig.host.json

+ 3 - 3
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.i18n.yaml → .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md
-2026-09-08-trusted-changed-file-review-routing.md: 9f59f336fb1f8d2fdd1e13f6751258a47518b4dc
-2026-09-08-trusted-changed-file-review-routing.zh.md: e845c79051858c11053597516f486cf29fa2805a
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.md
+2026-08-29-inline-visualizer-cordis-extension.md: e037dcedebf75400c2f53b8f7d0c1b66fb2a261d
+2026-08-29-inline-visualizer-cordis-extension.zh.md: 07210d03868ca4a4dad8984bb4c1d4a9c7bdf9e8

+ 35 - 0
.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.md

@@ -0,0 +1,35 @@
+# Agent Note: Inline Visualizer Cordis extension
+
+Status: implemented
+
+English | [中文](2026-08-29-inline-visualizer-cordis-extension.zh.md)
+
+## Problem
+
+DSH had no first-party Host contract for a model to request a temporary inline visual, progressively load construction guidance, or let a compatible Client submit an explicitly labelled follow-up from a successful HTML widget. Adding that surface globally would change every Agent preset even when no renderer or authority was present.
+
+## Decision
+
+`@deepseek-ai/dsh-tool-visualizer` is an independently loadable, dormant Host/model capability with two Cordis entrypoints. The root service owns Host source and complete-follow-up limits, exact-live-Agent and exact successful-`show_widget`-result authorization, the authoritative per-Agent follow-up rate limit, and insertion of authorized widget-authored follow-ups. Result authorization uses direct Session event lookup rather than a call-ID cache. Its generated Remote exposes `sendPrompt` to a separately supplied compatible Client; Client rendering limits remain independent security ceilings rather than transported Host configuration.
+
+The package's `./model` function-plugin requires `systemPrompt` and `tools`, then recoverably injects `visualizer`; while authority exists it installs the `tool:visualizer` prompt plus `widget_guidelines` and `show_widget` only in an Agent preset standing scope. `standard` and `cordis` always mount this wrapper, so late authority appearance, withdrawal, and reappearance activate, remove, and reactivate the model surface for existing and new Agents. `minimal` and `ptc` omit it. PTC's nested code-dispatch log does not provide the ordinary `show_widget` call/result identity required by the follow-up bridge. No default application composition mounts the root authority, so the shipped default prompt and tool surfaces remain unchanged.
+
+Each `widget_guidelines` result includes Delivery when `show_widget` is visible, then combines one shared Foundation with only the requested modules. The Foundation owns host-native composition, responsive flow, theme use, and cross-type accessibility. Content and behavior are independent: callers combine diagram, chart, illustration, or mockup guidance with interactive guidance when motion, manipulation, or adjustable inputs contribute to the presentation.
+
+Mounting the root authority exposes both raw SVG and HTML fragments; source-prefix detection records the kind without exposing renderer deployment policy in the model-visible schema. This change supplies no presentation Client and does not activate the capability in Web, headless, or TUI. The Host exposes no mutable-state report, recovery, or model-read surface. No Visualizer session event, Agent-loop rule, or core schema is added.
+
+## Verification
+
+Focused tests pin the shared routing prompt, unified schemas and guidance, source and complete-follow-up UTF-8 limits, exact Agent and successful-result authorization through direct event lookup, per-Agent prompt limits, and root/model disposal. REAL Loader composition tests prove that default Web and headless profiles omit the authority, `standard` and `cordis` always mount a dormant model wrapper, `minimal` and `ptc` have no row, and late authority appearance, withdrawal, and reappearance update the inherited model surface for existing and new Agents without adding global tools or prompt sections.
+
+## Alternatives considered
+
+**Register the tools from the root service.** Rejected because that would make model policy deployment-global and bypass preset inheritance and child tool filtering. The always-mounted `./model` wrapper keeps authority at the Host root and model visibility in the owning preset scope.
+
+**Enable the capability in default application compositions.** Rejected because this change does not include a presentation Client. Dormancy makes the Host/model contract reviewable and independently loadable without claiming an end-to-end user experience.
+
+**Trust a Client-supplied call id without checking the session log.** Rejected because a presentation Client must not mint authority. The Host binds each follow-up to the exact live Agent and a successful recorded HTML `show_widget` result before it enters the inbox.
+
+## Consequences
+
+The capability is isolated to one package, two preset wrapper rows, generated catalogs, and TypeScript aggregates. Default applications retain their existing model surface. The root service controls whether the always-mounted wrapper exposes the model surface; a compatible Client is additionally required for the end-to-end user experience. The authority may be mounted, withdrawn, and restored without rebuilding the preset. The Client must provide rendering security and resource controls for accepted HTML. Mutable widget state is Client-local and resets when its document is recreated.

+ 35 - 0
.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 内联 Visualizer Cordis 扩展
+
+Status: implemented
+
+[English](2026-08-29-inline-visualizer-cordis-extension.md) | 中文
+
+## 问题
+
+DSH 缺少一方 Host 契约,让模型请求临时内联视觉内容、渐进加载构建指南,或让兼容 Client 从成功的 HTML widget 提交带明确标记的 follow-up。如果全局加入这组接口,即使 renderer 或权威服务不存在,也会改变每个 Agent preset。
+
+## 决策
+
+`@deepseek-ai/dsh-tool-visualizer` 是一个可独立装载、默认休眠的 Host/模型能力,包含两个 Cordis entrypoint。根 service 拥有 Host 源码与完整 follow-up 限额、准确 live Agent 与准确成功 `show_widget` result 的授权、权威的每 Agent follow-up 频控,以及已授权 widget-authored follow-up 的写入。result 授权使用直接 Session event 查询,而不是 call-ID 缓存。它生成的 Remote 向单独提供的兼容 Client 暴露 `sendPrompt`;Client 渲染限额是独立的安全上限,而非通过传输获得的 Host 配置。
+
+同一包的 `./model` function-plugin 要求 `systemPrompt` 与 `tools`,然后可恢复地注入 `visualizer`;权威存在时,它只在 Agent preset standing scope 安装 `tool:visualizer` 提示词,以及 `widget_guidelines` 与 `show_widget`。`standard` 与 `cordis` 始终挂载该 wrapper,因此权威稍后出现、撤销与再次出现会针对现有及新 Agent 激活、移除与重新激活模型面。`minimal` 与 `ptc` 不包含该 row,因为 PTC 的嵌套 code-dispatch 日志不提供 follow-up bridge 所需的普通 `show_widget` call/result 身份。默认 application composition 均不挂载根权威,因此正式默认提示词与工具面保持不变。
+
+每个 `widget_guidelines` result 都会在 `show_widget` 可见时包含 Delivery,再把一个共享 Foundation 与所请求的模块组合起来。Foundation 拥有宿主原生构图、响应式流、主题使用与跨类型可访问性。内容和行为相互独立:当运动、操控或可调输入有助于呈现时,调用方会将 diagram、chart、illustration 或 mockup 指导与 interactive 指导组合使用。
+
+挂载根权威后会同时公开 raw SVG 与 HTML fragment;源码前缀检测记录 kind,而不在模型可见 schema 中暴露 renderer 部署策略。本次变更不提供展示 Client,也不在 Web、headless 或 TUI 中激活该能力。Host 不提供可变状态上报、恢复或模型读取面。实现不新增 Visualizer session event、Agent loop 规则或 core schema。
+
+## 验证
+
+定向测试固定了共享路由提示词、统一 schema 与 guideline、源码和完整 follow-up UTF-8 限额、通过直接 event 查询完成的准确 Agent 与成功 result 授权、每 Agent prompt 限额,以及 root/model dispose。REAL Loader composition 测试证明默认 Web 与 headless profile 都不含权威,`standard` 与 `cordis` 始终挂载休眠的 model wrapper,`minimal` 与 `ptc` 没有该 row;权威稍后出现、撤销与再次出现时,现有和新 Agent 的继承模型面都会随之更新,而且不会增加全局工具或提示词 section。
+
+## 考虑过的替代方案
+
+**从根 service 注册工具。** 不采用,因为这会让模型策略变成部署全局状态,并绕过 preset 继承与 child 工具过滤。始终挂载的 `./model` wrapper 把权威留在 Host 根层,把模型可见性留在所属 preset scope。
+
+**在默认 application composition 中启用能力。** 不采用,因为本次变更不包含展示 Client。默认休眠让 Host/模型契约可以独立评审和装载,而不会声称已有端到端用户体验。
+
+**不检查 session 日志,直接信任 Client 提交的 call id。** 不采用,因为展示 Client 不能自行生成授权。每个 follow-up 进入 inbox 前,Host 都会把它绑定到准确 live Agent 与一个成功记录的 HTML `show_widget` result。
+
+## 后果
+
+该能力的改动收在一个包、2 条 preset wrapper row、生成式 catalog 与 TypeScript aggregate 中。默认 application 保持原有模型面。根 service 决定始终挂载的 wrapper 是否暴露模型面;端到端用户体验还需要兼容 Client。无需重建 preset 即可挂载、撤销或恢复该权威。Client 必须为所接纳的 HTML 自行提供渲染安全与资源控制。可变 widget 状态只存在于 Client,并会在其 document 重建时重置。

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.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/process/2026-08-08-unified-github-label-taxonomy.md
-2026-08-08-unified-github-label-taxonomy.md: 625c5c1cac951bdc97187c17c964d677f31131c7
-2026-08-08-unified-github-label-taxonomy.zh.md: 855a2b98f44d517abe1f7718ae4e81262cb031b6
+2026-08-08-unified-github-label-taxonomy.md: 1748f9b77ed2922035c5e75ac4a2eee047f413d3
+2026-08-08-unified-github-label-taxonomy.zh.md: 3b221a5f3db43597656f46dfc505cdfda38c75db

+ 5 - 1
.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md

@@ -47,6 +47,8 @@ The area set is intentionally extensible. When no existing description honestly
 
 Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record how an Issue was created and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata.
 
+The repository lifecycle removes pull request `kind/*` labels and reserved aliases from an Issue before auditing it. Policy comments report only violations whose intended value cannot be derived from the Issue, such as a missing native Type or an unsupported Priority.
+
 Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set.
 
 ## Alternatives considered
@@ -65,8 +67,10 @@ Label migrations preserve meaning before removing aliases: add the canonical rep
 
 **Kinds on Issues.** Native Issue Type already owns that classification; duplicating it as a label creates drift.
 
+**Comment-only Issue enforcement.** A comment preserves invalid metadata and requires human cleanup even when the only valid result is removal. The lifecycle applies that removal and retains comments for choices it cannot infer.
+
 **Exactly one area per pull request.** Coherent changes can materially affect several independent APIs or behaviors, and dropping secondary areas hides affected scope.
 
 ## Consequences
 
-Reviewers and automation can query intent, semantic scope, how an Issue was created, priority, and operational triggers independently. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost.
+Reviewers and automation can query intent, semantic scope, how an Issue was created, priority, and operational triggers independently. Invalid Issue labels disappear without a policy comment, and the label event records the repair; when no other violation remains, the lifecycle deletes any earlier policy comment. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost.

+ 5 - 1
.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md

@@ -47,6 +47,8 @@ Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对
 
 Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然可选。`source/*` 标签记录 Issue 的创建方式,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。
 
+仓库生命周期会先从 Issue 中移除 PR `kind/*` 标签和保留别名,再执行审计。政策评论只报告无法从 Issue 推导预期值的违规项,例如缺失原生 Issue Type 或使用不受支持的优先级。
+
 迁移标签时,须先保留语义,再移除别名:先添加规范替代标签,核验可加标签对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。
 
 ## 考虑过的替代方案
@@ -65,8 +67,10 @@ Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然
 
 **在 Issue 上使用类型标签。** 原生 Issue Type 已负责这项分类;再用标签复制会造成漂移。
 
+**仅用评论执行 Issue 政策。** 评论会保留无效元数据;即使唯一有效结果是移除,仍要求人工清理。生命周期会直接执行这类移除,只对无法推断的选择保留评论。
+
 **每个 PR 恰好一个领域。** 内聚的变更可能对多个独立 API 或行为产生实质影响,丢弃次要领域会隐藏受影响范围。
 
 ## 后果
 
-评审人和自动化流程可以分别查询意图、语义范围、Issue 的创建方式、优先级和工作流触发条件。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。
+评审人和自动化流程可以分别查询意图、语义范围、Issue 的创建方式、优先级和工作流触发条件。无效的 Issue 标签会直接消失,不会产生政策评论;标签事件会记录该修复。如果不存在其他违规项,生命周期会删除更早的政策评论。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。

+ 10 - 6
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md

@@ -2,8 +2,6 @@
 
 Status: implemented
 
-English | [中文](2026-09-08-trusted-changed-file-review-routing.zh.md)
-
 ## Problem
 
 GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision.
@@ -22,11 +20,13 @@ The scanner fetches every changed-file record before deciding. It fails if the p
 
 The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions.
 
-The workflow prints the changed code paths, each exclusion class, per-file owner matches, and final reviewer action before any review-request mutation. For a non-draft pull request, it removes the pull-request author and users who are already requested from the login-sorted union of matched individual owners, then fills at most two current individual review-request slots. Existing individual requests consume slots even when they do not match the ownership map. Login order deterministically selects candidates when the available slots cannot cover the remaining union. The workflow does not remove requests from a non-draft pull request. For a draft, it reads the complete review-request timeline and cancels current requests whose latest requester is `github-actions[bot]`; requests made by people remain unchanged.
+The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it removes the pull-request author and users who remain requested from the matched individual owners, then keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores.
+
+When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation.
 
 ## Verification
 
-[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, complete pagination, the 3,000-file limits, log-before-mutation ordering, author and existing-reviewer filtering, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
+[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, the 3,000-file limits, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
 
 ## Alternatives considered
 
@@ -36,12 +36,16 @@ The workflow prints the changed code paths, each exclusion class, per-file owner
 
 **Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners.
 
+**Select capped candidates by login order.** Login order is stable but ignores how much reviewable code changed under each owner's directories. Changed LOC makes the limited requests follow the pull request's strongest ownership relevance while retaining login order for ties.
+
+**Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation.
+
 **Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch.
 
 ## Consequences
 
-Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
+Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, rule and changed-file updates remove obsolete workflow-authored requests on the next run, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
 
-The workflow requests every matched owner rather than choosing one owner nondeterministically. Shared ownership therefore produces at most two requests for each changed module. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
+The workflow requests at most one reviewer per run, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
 
 Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file or timeline API limit fail without applying a partial reviewer mutation.

+ 0 - 47
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md

@@ -1,47 +0,0 @@
-# Agent Note: 基于受信任的变更文件策略路由评审
-
-Status: implemented
-
-[English](2026-09-08-trusted-changed-file-review-routing.md) | 中文
-
-## 问题
-
-只要匹配路径发生变更,GitHub 原生 CODEOWNERS 就会请求评审者。它无法应用本仓库对需评审的实现或文档文件与纯测试证据的区分。使用原生 CODEOWNERS 文件还会让 GitHub 负责请求决策,而不是由可检查的仓库程序负责。
-
-评审路由需要可观测的变更文件输入、显式 owner 规则、完整的测试排除规则,以及对 fork PR 仍然安全且具备写权限的 workflow。
-
-## 决策
-
-仓库在 GitHub 原生 CODEOWNERS 路径之外的 [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS) 中保存兼容 CODEOWNERS 格式的映射。该映射只接受显式绝对目录模式,每条模式配置一至两名 GitHub 个人用户。通配符、隐藏目录模式、团队、超过两名 owner、重复模式和重复 owner 都会被拒绝。靠后的匹配模式会替换靠前的匹配结果。
-
-策略测试会统计匹配所有权规则的目录中的非测试跟踪文件行数。如果 `@turtle1999` 拥有的有效代码库超过三分之一,测试就会拒绝该映射。
-
-[`request-review` workflow](../../../../.github/workflows/request-review.yml) 在 PR 创建、同步、重新打开、标记为可评审和转为草稿时运行 `pull_request_target` 事件。具备写权限的 job 检出默认分支,只执行默认分支上的扫描器和所有权映射。它不会检出 PR 代码,也不会读取仓库 secret。
-
-扫描器在决策之前获取所有变更文件记录。如果 PR 报告的文件数超过 GitHub API 的 3,000 个文件上限,或者分页只返回了部分列表,扫描器就会失败。它会规范化仓库路径,分别检查重命名前后的路径,并在记录文件名之前进行转义。
-
-扫描器会在匹配 owner 之前排除纯测试路径。排除范围包括名为 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 或 `stress-tests` 的目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,以 `.bench.<ext>`、`.corpus.<ext>`、`.e2e.<ext>`、`.perf.<ext>`、`.snapshot.<ext>`、`.spec.<ext>`、`.stress.<ext>` 或 `.test.<ext>` 结尾的文件名,以及 Python 的 `test_*.py`、`*_test.py` 或 `*_tests.py` 文件。`vitest*.config.ts` 和门禁实现等测试基础设施仍需评审,因为它们会改变仓库证据的生成方式。[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)记录额外的文档和注释排除规则。
-
-Workflow 会在发出任何评审请求变更之前,依次打印变更代码路径、每类排除项、逐文件 owner 匹配结果和最终评审操作。对于非草稿 PR,它会从按登录名排序并合并的个人 owner 中排除 PR 作者和已经收到评审请求的用户,然后填充最多两个当前个人评审请求名额。现有个人请求即使不匹配所有权映射,也会占用名额。当可用名额无法覆盖剩余候选集合时,登录名顺序会确定性地选择候选人。Workflow 不会从非草稿 PR 移除请求。对于草稿,它会读取完整的评审请求时间线,并取消最近一次请求者为 `github-actions[bot]` 的当前请求;由人员发出的请求保持不变。
-
-## 验证
-
-[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖允许的所有权语法、拒绝的语法、每类排除项、生产文件名负向对照、重命名、最后匹配规则、未匹配文件、完整分页、两个 3,000 项上限、先记录后修改的顺序、作者与现有评审者过滤、草稿取消来源和 API 失败。[Workflow 测试](../../../../scripts/ci-workflow.spec.ts)固定事件集合、最小权限、受信任的默认分支检出、不引用 PR head 和 secret,以及执行的命令。门禁图在静态 CI 和 `check-all` 中包含这两组测试。
-
-## 考虑过的替代方案
-
-**使用原生 CODEOWNERS。** 原生路由无法忽略纯测试变更,也无法在请求评审者之前提供由仓库控制的决策日志。
-
-**在 `pull_request` 下运行并检出 PR head。** Fork workflow 无法获得具备写权限的 token,而向不受信任 head 中的代码授予写权限 token 并不安全。
-
-**在 `pull_request_target` 下执行 PR 中的扫描器或 owner 映射。** 这会让不受信任的 PR 选择自己的写权限行为或 owner。
-
-**根据 patch 或语言解析器推断任意语义源码变更。** GitHub 可能省略或截断 patch,而且仓库包含多种语言。扫描器不会尝试证明两个程序行为相同。后续的[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)只在变更行计数能够证明 GitHub 提供了完整 patch 时执行有限的词法比较。
-
-## 后果
-
-评审请求修改可以根据受信任的策略、workflow 日志中打印的文件分类,以及 PR 时间线中的请求来源复现。被排除的变更不会请求 owner,草稿 PR 不会保留 workflow 发出的请求。所有权变更只有合并后才会生效,因此修改策略的 PR 无法对自身应用其中不受信任的策略。
-
-Workflow 会请求所有匹配的 owner,不会随机选择一人。因此,共享所有权对每个变更模块最多产生两个请求。GitHub 使用 `GITHUB_TOKEN` 生成的评审请求事件可能不会启动依赖递归触发事件的其他 workflow;这些 workflow 不得把此请求作为唯一触发条件。
-
-已分配目录下不符合任何显式排除规则的变更仍符合请求条件。未匹配的路径会被记录,但不会请求任何人。超过文件或时间线 API 上限的 PR 会失败,并且不会执行不完整的评审者修改。

+ 38 - 8
.github/issue-management/policy.mjs

@@ -218,9 +218,7 @@ export function retainIssueReferences(references, issues) {
 export function validateIssue(issue) {
   const errors = []
   const status = issue.status
-  const invalidLabels = issue.labels.filter(
-    (label) => label.startsWith('kind/') || LEGACY_LABELS.has(label),
-  )
+  const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
 
   if (invalidLabels.length > 0) {
     errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
@@ -245,6 +243,10 @@ export function validateIssue(issue) {
   return errors
 }
 
+function isInvalidIssueLabel(label) {
+  return label.startsWith('kind/') || LEGACY_LABELS.has(label)
+}
+
 /**
  * Validate PR metadata and its referenced Issues.
  * @param {{authorType: string, labels: string[], references: ReturnType<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} input PR snapshot.
@@ -312,8 +314,9 @@ function projectToken() {
 }
 
 async function api(path, options = {}) {
+  const { allow404 = false, ...requestOptions } = options
   const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
-    ...options,
+    ...requestOptions,
     headers: {
       Accept: 'application/vnd.github+json',
       Authorization: `Bearer ${token()}`,
@@ -322,10 +325,10 @@ async function api(path, options = {}) {
       ...options.headers,
     },
   })
-  if (options.allow404 && response.status === 404) return null
+  if (allow404 && response.status === 404) return null
   if (!response.ok) {
     const body = await response.text()
-    throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`)
+    throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`)
   }
   if (response.status === 204) return null
   return response.json()
@@ -573,6 +576,25 @@ async function setStatus(number, status) {
   await updateStatus(await ensureProjectItem(number), status)
 }
 
+/**
+ * Remove pull-request kinds and retired aliases from one Issue snapshot.
+ * @param {{number: number, labels: string[]}} issue Issue snapshot.
+ * @returns {Promise<object>} Snapshot containing only labels that remain on the Issue.
+ */
+export async function repairIssueLabels(issue) {
+  const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
+  for (const label of invalidLabels) {
+    await api(
+      `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`,
+      { method: 'DELETE', allow404: true },
+    )
+  }
+  return {
+    ...issue,
+    labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)),
+  }
+}
+
 async function upsertAudit(number, errors) {
   const comments = await api(
     `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
@@ -605,10 +627,18 @@ async function upsertAudit(number, errors) {
   }
 }
 
-async function auditIssue(number, extraErrors = [], status = undefined) {
+/**
+ * Repair deterministic Issue metadata violations and publish the remaining audit result.
+ * @param {number} number Same-repository Issue number.
+ * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation.
+ * @param {string|null|undefined} status Optional known Project status.
+ * @returns {Promise<string[]>} Violations that remain after repair.
+ */
+export async function auditIssue(number, extraErrors = [], status = undefined) {
   const issue = await issueSnapshot(number, status)
   if (!issue) return []
-  const errors = [...extraErrors, ...validateIssue(issue)]
+  const repairedIssue = await repairIssueLabels(issue)
+  const errors = [...extraErrors, ...validateIssue(repairedIssue)]
   await upsertAudit(number, errors)
   return errors
 }

+ 97 - 0
.github/issue-management/policy.test.mjs

@@ -3,12 +3,14 @@ import { readFileSync, readdirSync } from 'node:fs'
 import test from 'node:test'
 
 import {
+  auditIssue,
   initializeIssueStartDate,
   initializePullRequestStartDates,
   issueSnapshot,
   nextResolvingIssueStatus,
   parseReferences,
   projectDate,
+  repairIssueLabels,
   retainIssueReferences,
   resolvingIssueStatusCommand,
   requiresPullRequestPolicy,
@@ -248,6 +250,101 @@ test('reserves PR kind and legacy labels for pull requests', () => {
   assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), [])
 })
 
+test('removes reserved labels from Issues before validation', async (t) => {
+  const previousToken = process.env.GH_TOKEN
+  process.env.GH_TOKEN = 'test-token'
+  t.after(() => {
+    if (previousToken === undefined) delete process.env.GH_TOKEN
+    else process.env.GH_TOKEN = previousToken
+  })
+  const requests = []
+  t.mock.method(globalThis, 'fetch', async (url, options) => {
+    requests.push({ url, method: options.method })
+    assert.equal(options.headers.Authorization, 'Bearer test-token')
+    if (url.endsWith('/labels/bug-fix')) {
+      return Response.json({ message: 'Label does not exist' }, { status: 404 })
+    }
+    return Response.json([])
+  })
+
+  const issue = {
+    ...legalIssue,
+    number: 42,
+    labels: ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'],
+  }
+  const repaired = await repairIssueLabels(issue)
+
+  assert.deepEqual(repaired.labels, ['area/web', 'source/member'])
+  assert.deepEqual(issue.labels, ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'])
+  assert.deepEqual(validateIssue(repaired), [])
+  assert.deepEqual(requests, [
+    {
+      url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix',
+      method: 'DELETE',
+    },
+    {
+      url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/bug-fix',
+      method: 'DELETE',
+    },
+  ])
+})
+
+test('deletes a stale audit comment after repairing its only violation', async (t) => {
+  const previousToken = process.env.GH_TOKEN
+  process.env.GH_TOKEN = 'test-token'
+  t.after(() => {
+    if (previousToken === undefined) delete process.env.GH_TOKEN
+    else process.env.GH_TOKEN = previousToken
+  })
+  const requests = []
+  t.mock.method(globalThis, 'fetch', async (url, options) => {
+    requests.push({ url, method: options.method ?? 'GET' })
+    if (url.endsWith('/issues/42')) {
+      return Response.json({
+        node_id: 'issue-id',
+        labels: [{ name: 'area/web' }, { name: 'kind/bug-fix' }],
+        type: { name: 'Bug' },
+        state: 'open',
+        state_reason: null,
+      })
+    }
+    if (url.endsWith('/graphql')) return Response.json({ data: projectGraphqlData() })
+    if (url.endsWith('/labels/kind%2Fbug-fix')) return Response.json([{ name: 'area/web' }])
+    if (url.endsWith('/issues/42/comments?per_page=100')) {
+      return Response.json([
+        {
+          id: 99,
+          user: { type: 'Bot' },
+          body: '<!-- dsh-issue-policy -->\nold audit',
+        },
+      ])
+    }
+    if (url.endsWith('/issues/comments/99')) return new Response(null, { status: 204 })
+    return Response.json({ message: 'unexpected request' }, { status: 500 })
+  })
+
+  assert.deepEqual(await auditIssue(42), [])
+  assert.deepEqual(
+    requests.map(({ url, method }) => ({ path: new URL(url).pathname + new URL(url).search, method })),
+    [
+      { path: '/repos/deepseek-harness/deepseek-harness/issues/42', method: 'GET' },
+      { path: '/graphql', method: 'POST' },
+      {
+        path: '/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix',
+        method: 'DELETE',
+      },
+      {
+        path: '/repos/deepseek-harness/deepseek-harness/issues/42/comments?per_page=100',
+        method: 'GET',
+      },
+      {
+        path: '/repos/deepseek-harness/deepseek-harness/issues/comments/99',
+        method: 'DELETE',
+      },
+    ],
+  )
+})
+
 test('keeps terminal Status aligned with the native close reason', () => {
   assert.deepEqual(
     validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }),

+ 0 - 1
.github/review-ownership/CODEOWNERS

@@ -6,7 +6,6 @@
 /native/ @mektpoy
 /patches/ @mektpoy
 /python/ @LegGasai
-/scripts/ @turtle1999
 /vendor/ @turtle1999
 /website/ @LegGasai
 /packages/acp/ @mektpoy

+ 5 - 5
.github/review-ownership/README.md

@@ -1,7 +1,5 @@
 # Automated review requests
 
-English | [中文](README.zh.md)
-
 ## Summary
 
 The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It classifies changed files, requests missing owners for reviewable code, and cancels its outstanding requests when a pull request becomes a draft. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly.
@@ -20,9 +18,11 @@ The [`request-review` workflow](../workflows/request-review.yml) reads the CODEO
 
 Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API.
 
-For a non-draft pull request, the workflow requests missing matched owners while keeping the total number of current individual review requests at two or fewer. Existing individual requests consume those slots, including requests made by people outside the ownership map. When more candidates remain than available slots, login order selects the reviewers deterministically. The workflow does not remove requests from a non-draft pull request. For a draft, it reads the current requested reviewers and review-request timeline, then cancels each current request whose latest requester is `github-actions[bot]`. Current requests made by people remain unchanged. The workflow fails without cancellation when the timeline exceeds 3,000 events or contains invalid request provenance.
+For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties.
+
+On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events.
 
-The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author and users who are already requested are omitted from new requests.
+The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author and users who remain requested are omitted from new requests.
 
 The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
 
@@ -50,7 +50,7 @@ Ownership changes take effect only after they merge into the default branch. Thi
 
 ## Verification
 
-Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, pagination, logging order, draft cancellation, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
+Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
 
 <a id="dev-note"></a>
 

+ 0 - 59
.github/review-ownership/README.zh.md

@@ -1,59 +0,0 @@
-# 自动请求代码评审
-
-[English](README.md) | 中文
-
-## 概要
-
-[`request-review` workflow](../workflows/request-review.yml) 从受信任的默认分支读取兼容 CODEOWNERS 格式的[所有权映射](CODEOWNERS)。它会对变更文件分类,为可评审代码请求尚未加入的 owner,并在 PR 转为草稿时取消自己尚未完成的请求。所有权映射不在 GitHub 原生 CODEOWNERS 路径中,因此 GitHub 不会直接应用它。
-
-## 目录
-
-- [路由](#routing)
-- [评审排除规则](#review-exclusions)
-- [安全性](#security)
-- [验证](#verification)
-- [开发说明](#dev-note)
-
-<a id="routing"></a>
-
-## 路由
-
-PR 在创建、同步、重新打开、标记为可评审或转为草稿时运行该 workflow。扫描器获取完整的 PR 文件列表,分别检查重命名前后的路径;如果只能取得部分列表,则停止执行,不发出评审请求。GitHub 对此 API 最多公开 3,000 个文件。
-
-对于非草稿 PR,workflow 会请求尚未加入的匹配 owner,同时确保当前个人评审请求总数不超过两个。现有个人请求会占用名额,包括由人员向所有权映射之外用户发出的请求。当剩余候选人数超过可用名额时,workflow 会按登录名顺序确定评审者。Workflow 不会从非草稿 PR 移除请求。对于草稿,workflow 会读取当前评审请求和评审请求时间线,然后取消最近一次请求者为 `github-actions[bot]` 的当前请求。由人员发出的当前请求保持不变。如果时间线超过 3,000 个事件或包含无效的请求来源,workflow 会失败且不执行取消操作。
-
-所有权映射只接受显式绝对目录模式,并允许每条模式配置一至两名 GitHub 个人用户。通配符、隐藏目录模式、团队、超过两名 owner,以及重复的模式或 owner 都会被拒绝。匹配遵循 CODEOWNERS 的最后一条匹配规则。扫描器会在修改评审请求前,打印变更代码文件、排除的测试文件、文档文件和纯注释文件,逐文件 owner 匹配结果,以及将要请求或取消的评审者。未匹配的文件仍显示在日志中。PR 作者和已经收到评审请求的用户不会收到新请求。
-
-策略测试会统计已匹配目录下的非测试跟踪文件行数,并要求 `@turtle1999` 拥有的有效代码库不超过三分之一。
-
-<a id="review-exclusions"></a>
-
-## 评审排除规则
-
-评审路由会排除仓库中的单元测试、端到端测试、预期输出、快照、基准测试、性能测试、压力测试、语料测试、原生测试和 Python 测试约定。其中包括 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 和 `stress-tests` 目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,可识别的测试文件名后缀,以及 Python 的 `test_*.py` 或 `*_test.py` 文件。
-
-能够改变证据生成方式的测试基础设施仍需评审,包括 `vitest*.config.ts` 和 `scripts` 下的门禁实现。生产文件不会仅因名称为 `test.ts`、`spec.ts` 或 `snapshot.ts` 而被排除。
-
-扩展名以不区分大小写方式匹配。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,不会贡献 owner;除非符合其他排除规则,否则 `.yml` 文件仍需评审。
-
-对于具有受支持源码扩展名的修改文件,扫描器会移除解析出的注释,再比较变更前后的文本。只有 GitHub 提供的 patch 中增删行数与文件记录一致、能够证明 patch 完整,且其余代码完全相同时,扫描器才会排除该文件。解析器会按声明的扩展名识别 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释与 HTML 注释。重命名、不受支持的语言、缺失或不完整的 patch,以及无法确定的注释形式仍需评审。
-
-<a id="security"></a>
-
-## 安全性
-
-具备写权限的 `pull_request_target` job 只检出仓库默认分支。它不会检出或执行 PR 代码,也不使用仓库 secret。PR 文件名仅作为 API 数据处理,并在日志中转义。
-
-所有权变更只有合并到默认分支后才会生效。这可以防止不受信任的 PR 为自身的 workflow 运行修改路由程序或 owner 分配。
-
-<a id="verification"></a>
-
-## 验证
-
-运行 `pnpm run test:request-review` 可检查所有权解析、文件分类、完整 patch 检查、注释解析、分页、日志顺序、草稿取消、评审者过滤和 API 行为。[Workflow 测试](../../scripts/ci-workflow.spec.ts)固定受信任检出、权限、事件和命令。仓库门禁图会在 CI 中运行这两类检查。
-
-<a id="dev-note"></a>
-
-## 开发说明
-
-[评审路由决策](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md)记录了安全模型、测试排除规则和备选方案。

+ 117 - 36
.github/review-ownership/request-review.mjs

@@ -7,9 +7,10 @@ import { pathToFileURL } from 'node:url'
 const API_VERSION = '2026-03-10'
 const MAX_OWNERS_PER_RULE = 2
 const MAX_PULL_REQUEST_FILES = 3_000
-const MAX_REQUESTED_REVIEWERS = 2
+const MAX_COUNTED_REQUESTED_REVIEWERS = 1
 const MAX_TIMELINE_EVENTS = 3_000
 const PAGE_SIZE = 100
+const UNCOUNTED_REVIEWER = 'turtle1999'
 const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]'
 const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests'])
 const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u
@@ -240,55 +241,87 @@ function stripComments(source, syntax) {
 /**
  * Expand changed-file records into reviewable, test, documentation, and comment-only paths.
  * @param {unknown[]} files Pull-request file records from GitHub.
- * @returns {{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths.
+ * @returns {{changedCodeFiles: string[], reviewableChanges: Array<{paths: string[], changedLines: number}>, excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths and their GitHub-reported changed-line counts.
  */
 export function classifyChangedFiles(files) {
   const changedCodeFiles = new Set()
+  const reviewableChanges = []
   const excludedTestFiles = new Set()
   const excludedDocumentationFiles = new Set()
   const excludedCommentOnlyFiles = new Set()
   for (const entry of files) {
     if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry')
+    const changedLines = changedLineCount(entry)
     const paths = [normalizeRepositoryPath(entry.filename)]
     const commentOnly = isCommentOnlyChange(entry)
     if (entry.previous_filename !== undefined) {
       paths.unshift(normalizeRepositoryPath(entry.previous_filename))
     }
-    for (const file of paths) {
+    const reviewablePaths = []
+    for (const file of new Set(paths)) {
       if (isTestPath(file)) excludedTestFiles.add(file)
       else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file)
       else if (commentOnly) excludedCommentOnlyFiles.add(file)
-      else changedCodeFiles.add(file)
+      else {
+        changedCodeFiles.add(file)
+        reviewablePaths.push(file)
+      }
+    }
+    if (reviewablePaths.length > 0) {
+      reviewableChanges.push({ paths: reviewablePaths.sort(), changedLines })
     }
   }
   return {
     changedCodeFiles: [...changedCodeFiles].sort(),
+    reviewableChanges,
     excludedTestFiles: [...excludedTestFiles].sort(),
     excludedDocumentationFiles: [...excludedDocumentationFiles].sort(),
     excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(),
   }
 }
 
+function changedLineCount(entry) {
+  for (const field of ['additions', 'deletions']) {
+    if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) {
+      throw new Error(`changed-file ${field} must be a non-negative integer`)
+    }
+  }
+  const changedLines = entry.additions + entry.deletions
+  if (!Number.isSafeInteger(changedLines)) throw new Error('changed-file LOC exceeds the safe integer range')
+  return changedLines
+}
+
 /**
- * Match changed paths to owners with CODEOWNERS last-match semantics.
+ * Match changed paths and rank owners by their reviewable changed LOC.
  * @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules.
- * @param {string[]} changedCodeFiles Reviewable repository paths.
- * @returns {{matches: Array<{file: string, owners: string[]}>, reviewers: string[]}} Routing plan.
+ * @param {Array<{paths: string[], changedLines: number}>} reviewableChanges Reviewable GitHub file records.
+ * @returns {{matches: Array<{file: string, changedLines: number, owners: string[]}>, reviewers: Array<{login: string, changedLines: number}>}} Routing plan.
  */
-export function planReviewers(rules, changedCodeFiles) {
+export function planReviewers(rules, reviewableChanges) {
   const matches = []
   const reviewers = new Map()
-  for (const file of changedCodeFiles) {
-    let owners = []
-    for (const rule of rules) {
-      if (file.startsWith(rule.prefix)) owners = rule.owners
+  for (const change of reviewableChanges) {
+    const changeOwners = new Map()
+    for (const file of change.paths) {
+      let owners = []
+      for (const rule of rules) {
+        if (file.startsWith(rule.prefix)) owners = rule.owners
+      }
+      matches.push({ file, changedLines: change.changedLines, owners })
+      for (const owner of owners) changeOwners.set(owner.toLowerCase(), owner.slice(1))
+    }
+    for (const [key, login] of changeOwners) {
+      const changedLines = (reviewers.get(key)?.changedLines ?? 0) + change.changedLines
+      if (!Number.isSafeInteger(changedLines)) throw new Error(`changed LOC for @${login} exceeds the safe integer range`)
+      reviewers.set(key, { login, changedLines })
     }
-    matches.push({ file, owners })
-    for (const owner of owners) reviewers.set(owner.toLowerCase(), owner.slice(1))
   }
   return {
-    matches,
-    reviewers: [...reviewers.values()].sort((left, right) => left.localeCompare(right, 'en')),
+    matches: matches.sort((left, right) => left.file.localeCompare(right.file, 'en')),
+    reviewers: [...reviewers.values()].sort((left, right) => {
+      if (left.changedLines !== right.changedLines) return left.changedLines < right.changedLines ? 1 : -1
+      return left.login.localeCompare(right.login, 'en')
+    }),
   }
 }
 
@@ -404,8 +437,8 @@ function requestedReviewerLogins(response) {
 }
 
 /**
- * Print changed paths, request missing owners on reviewable pull requests, and
- * cancel workflow-authored requests on drafts.
+ * Print changed paths, reconcile workflow-authored requests with current
+ * ownership, and cancel workflow-authored requests on drafts.
  * @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, write?: (line: string) => void}} options Runtime inputs.
  * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result.
  */
@@ -413,8 +446,8 @@ export async function requestReviews({ event, ownershipSource, api, write = line
   const pull = pullRequestFromEvent(event)
   write('This is by automated Angry Turtle Cyborg, not a human')
   const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount)
-  const classified = classifyChangedFiles(files)
-  const plan = planReviewers(parseOwnership(ownershipSource), classified.changedCodeFiles)
+  const { reviewableChanges, ...classified } = classifyChangedFiles(files)
+  const plan = planReviewers(parseOwnership(ownershipSource), reviewableChanges)
   writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file)))
   writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file)))
   writeList(
@@ -430,10 +463,16 @@ export async function requestReviews({ event, ownershipSource, api, write = line
   writeList(
     write,
     'Owners by changed file',
-    plan.matches.map(({ file, owners }) => `${JSON.stringify(file)}: ${owners.length ? owners.join(' ') : '(none)'}`),
+    plan.matches.map(({ file, changedLines, owners }) =>
+      `${JSON.stringify(file)} (${changedLines} LOC): ${owners.length ? owners.join(' ') : '(none)'}`),
+  )
+  writeList(
+    write,
+    'Owner relevance by changed LOC',
+    plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`),
   )
 
-  const candidates = plan.reviewers.filter(login => login.toLowerCase() !== pull.author.toLowerCase())
+  const candidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
   if (pull.draft) {
     const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
     const requestedReviewers = requestedReviewerLogins(existing)
@@ -455,25 +494,67 @@ export async function requestReviews({ event, ownershipSource, api, write = line
     return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers }
   }
 
-  if (candidates.length === 0) {
-    writeList(write, 'Reviewers to request', [])
-    return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
-  }
   const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
-  const alreadyRequested = new Set(requestedReviewerLogins(existing).map(login => login.toLowerCase()))
-  const availableSlots = Math.max(0, MAX_REQUESTED_REVIEWERS - alreadyRequested.size)
+  const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en'))
+  const workflowReviewers = currentReviewers.length === 0
+    ? []
+    : workflowRequestedReviewers(
+        await listPullRequestTimeline(api, pull.repository, pull.number),
+        currentReviewers,
+      )
+  const workflowReviewerKeys = new Set(workflowReviewers.map(login => login.toLowerCase()))
+  const manualReviewers = currentReviewers.filter(login => !workflowReviewerKeys.has(login.toLowerCase()))
+  let retainedCountedSlots = Math.max(
+    0,
+    MAX_COUNTED_REQUESTED_REVIEWERS
+      - manualReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
+  )
+  const retainedWorkflowReviewerKeys = new Set()
+  for (const { login } of candidates) {
+    const key = login.toLowerCase()
+    if (!workflowReviewerKeys.has(key)) continue
+    if (key === UNCOUNTED_REVIEWER) retainedWorkflowReviewerKeys.add(key)
+    else if (retainedCountedSlots > 0) {
+      retainedWorkflowReviewerKeys.add(key)
+      retainedCountedSlots--
+    }
+  }
+  const reviewersToCancel = workflowReviewers.filter(
+    login => !retainedWorkflowReviewerKeys.has(login.toLowerCase()),
+  )
+  const cancelledReviewerKeys = new Set(reviewersToCancel.map(login => login.toLowerCase()))
+  const remainingReviewers = currentReviewers.filter(login => !cancelledReviewerKeys.has(login.toLowerCase()))
+  const alreadyRequested = new Set(remainingReviewers.map(login => login.toLowerCase()))
+  const availableSlots = Math.max(
+    0,
+    MAX_COUNTED_REQUESTED_REVIEWERS
+      - remainingReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
+  )
+  writeList(write, 'Current individual review requests', currentReviewers.map(login => `@${login}`))
+  write(`Available counted review request slots: ${availableSlots}.`)
   const reviewers = candidates
-    .filter(login => !alreadyRequested.has(login.toLowerCase()))
+    .filter(({ login }) => !alreadyRequested.has(login.toLowerCase()))
     .slice(0, availableSlots)
+    .map(({ login }) => login)
+  writeList(write, 'Review requests to cancel', reviewersToCancel.map(login => `@${login}`))
   writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`))
-  if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
+  if (reviewersToCancel.length > 0) {
+    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
+      method: 'DELETE',
+      body: { reviewers: reviewersToCancel },
+    })
+    const requestLabel = reviewersToCancel.length === 1 ? 'request' : 'requests'
+    write(`Cancelled review ${requestLabel} for ${reviewersToCancel.map(login => `@${login}`).join(' ')}.`)
+  }
 
-  await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
-    method: 'POST',
-    body: { reviewers },
-  })
-  write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`)
-  return { ...classified, requestedReviewers: reviewers, cancelledReviewers: [] }
+  if (reviewers.length > 0) {
+    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
+      method: 'POST',
+      body: { reviewers },
+    })
+    write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`)
+  }
+  return { ...classified, requestedReviewers: reviewers, cancelledReviewers: reviewersToCancel }
 }
 
 function pullRequestFromEvent(event) {

+ 270 - 32
.github/review-ownership/request-review.test.mjs

@@ -32,8 +32,9 @@ const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false }
 test('loads the repository ownership policy without test-only directory rules', () => {
   const rules = parseOwnership(ownershipSource)
   const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners]))
-  assert.equal(rules.length, 58)
+  assert.equal(rules.length, 57)
   assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false)
+  assert.equal(rules.some(rule => rule.pattern === '/scripts/'), false)
   assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false)
   assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false)
   assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999'])
@@ -61,7 +62,7 @@ test('keeps turtle below one third of the eligible owned codebase', () => {
   let turtleLines = 0
   for (const file of trackedFiles) {
     if (isTestPath(file) || isDocumentationPath(file)) continue
-    const owners = planReviewers(rules, [file]).matches[0]?.owners ?? []
+    const owners = planReviewers(rules, [{ paths: [file], changedLines: 0 }]).matches[0]?.owners ?? []
     if (owners.length === 0) continue
     const content = readFileSync(file)
     const lines = content.length === 0
@@ -218,12 +219,16 @@ test('classifies both sides of a rename independently', () => {
       {
         filename: 'packages/core/agent/tests/moved.spec.ts',
         previous_filename: 'packages/core/agent/src/moved.ts',
+        additions: 3,
+        deletions: 2,
       },
       {
         filename: 'packages/client/store/src/restored.ts',
         previous_filename: 'packages/client/store/tests/restored.spec.ts',
+        additions: 2,
+        deletions: 1,
       },
-      { filename: 'packages/core/agent/README.md' },
+      { filename: 'packages/core/agent/README.md', additions: 1, deletions: 0 },
       {
         filename: 'packages/core/agent/src/commented.ts',
         status: 'modified', additions: 1, deletions: 1,
@@ -235,6 +240,10 @@ test('classifies both sides of a rename independently', () => {
         'packages/client/store/src/restored.ts',
         'packages/core/agent/src/moved.ts',
       ],
+      reviewableChanges: [
+        { paths: ['packages/core/agent/src/moved.ts'], changedLines: 5 },
+        { paths: ['packages/client/store/src/restored.ts'], changedLines: 3 },
+      ],
       excludedTestFiles: [
         'packages/client/store/tests/restored.spec.ts',
         'packages/core/agent/tests/moved.spec.ts',
@@ -245,21 +254,54 @@ test('classifies both sides of a rename independently', () => {
   )
 })
 
-test('uses the last matching ownership rule and keeps unmatched files visible', () => {
+test('uses the last matching ownership rule and ranks owners by changed LOC', () => {
   const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n')
   assert.deepEqual(
-    planReviewers(rules, ['AGENTS.md', 'packages/core/agent/src/index.ts', 'packages/fs/fs/src/index.ts']),
+    planReviewers(rules, [
+      { paths: ['AGENTS.md'], changedLines: 1 },
+      { paths: ['packages/core/agent/src/index.ts'], changedLines: 8 },
+      { paths: ['packages/fs/fs/src/index.ts'], changedLines: 3 },
+    ]),
     {
       matches: [
-        { file: 'AGENTS.md', owners: [] },
-        { file: 'packages/core/agent/src/index.ts', owners: ['@core', '@second'] },
-        { file: 'packages/fs/fs/src/index.ts', owners: ['@broad'] },
+        { file: 'AGENTS.md', changedLines: 1, owners: [] },
+        { file: 'packages/core/agent/src/index.ts', changedLines: 8, owners: ['@core', '@second'] },
+        { file: 'packages/fs/fs/src/index.ts', changedLines: 3, owners: ['@broad'] },
+      ],
+      reviewers: [
+        { login: 'core', changedLines: 8 },
+        { login: 'second', changedLines: 8 },
+        { login: 'broad', changedLines: 3 },
       ],
-      reviewers: ['broad', 'core', 'second'],
     },
   )
 })
 
+test('counts each changed-file record once per owner across rename paths', () => {
+  const rules = parseOwnership('/packages/a/ @same @a\n/packages/b/ @same @b\n/packages/c/ @c\n')
+  const plan = planReviewers(rules, [
+    { paths: ['packages/a/old.ts', 'packages/b/new.ts'], changedLines: 10 },
+    { paths: ['packages/a/other.ts'], changedLines: 5 },
+    { paths: ['packages/c/tiny.ts'], changedLines: 1 },
+  ])
+  assert.deepEqual(plan.reviewers, [
+    { login: 'a', changedLines: 15 },
+    { login: 'same', changedLines: 15 },
+    { login: 'b', changedLines: 10 },
+    { login: 'c', changedLines: 1 },
+  ])
+})
+
+test('rejects invalid changed-file LOC', () => {
+  for (const file of [
+    { filename: 'packages/core/index.ts', deletions: 0 },
+    { filename: 'packages/core/index.ts', additions: -1, deletions: 0 },
+    { filename: 'packages/core/index.ts', additions: Number.MAX_SAFE_INTEGER, deletions: 1 },
+  ]) {
+    assert.throws(() => classifyChangedFiles([file]), /changed-file|LOC/u)
+  }
+})
+
 test('fetches every declared changed file across pages', async () => {
   const calls = []
   const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` }))
@@ -303,21 +345,21 @@ test('fails closed when the review-request timeline exceeds its limit', async ()
   assert.equal(calls, 30)
 })
 
-test('prints changed code files and limits current review requests to two people', async () => {
+test('prints changed code files and requests the highest-ranked counted owner', async () => {
   const trace = []
   const files = [
-    { filename: 'packages/core/agent/src/index.ts' },
-    { filename: 'packages/preset/agent-presets/src/index.ts' },
-    { filename: 'packages/client/store/src/index.ts' },
-    { filename: 'packages/subagent/subagent/src/index.ts' },
-    { filename: 'packages/core/agent/tests/index.spec.ts' },
-    { filename: 'AGENTS.md' },
+    { filename: 'packages/core/agent/src/index.ts', additions: 70, deletions: 10 },
+    { filename: 'packages/preset/agent-presets/src/index.ts', additions: 5, deletions: 5 },
+    { filename: 'packages/client/store/src/index.ts', additions: 2, deletions: 0 },
+    { filename: 'packages/subagent/subagent/src/index.ts', additions: 40, deletions: 0 },
+    { filename: 'packages/core/agent/tests/index.spec.ts', additions: 100, deletions: 0 },
+    { filename: 'AGENTS.md', additions: 200, deletions: 0 },
   ]
   const api = async (path, options = {}) => {
     trace.push({ type: 'api', path, options })
     if (path.endsWith('/files?per_page=100&page=1')) return files
     if (path.endsWith('/requested_reviewers') && options.method !== 'POST') {
-      return { users: [{ login: 'imccyu' }], teams: [] }
+      return { users: [], teams: [] }
     }
     if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
     throw new Error(`unexpected API path ${path}`)
@@ -340,37 +382,109 @@ test('prints changed code files and limits current review requests to two people
     excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'],
     excludedDocumentationFiles: ['AGENTS.md'],
     excludedCommentOnlyFiles: [],
-    requestedReviewers: ['Dudu-0223'],
+    requestedReviewers: ['mektpoy'],
     cancelledReviewers: [],
   })
   assert.equal(trace[0].type, 'log')
   assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human')
   const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:')
+  const relevanceHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Owner relevance by changed LOC:')
   const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST')
-  assert.ok(changedHeading >= 0 && changedHeading < post)
+  assert.ok(changedHeading >= 0 && changedHeading < relevanceHeading && relevanceHeading < post)
+  assert.deepEqual(trace.slice(relevanceHeading, relevanceHeading + 6).map(item => item.line), [
+    'Owner relevance by changed LOC:',
+    '- @turtle1999: 90',
+    '- @mektpoy: 80',
+    '- @Dudu-0223: 40',
+    '- @LegGasai: 10',
+    '- @imccyu: 2',
+  ])
   assert.deepEqual(trace[post], {
     type: 'api',
     path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
     options: {
       method: 'POST',
-      body: { reviewers: ['Dudu-0223'] },
+      body: { reviewers: ['mektpoy'] },
     },
   })
 })
 
-test('does not add an owner when two people are already requested', async () => {
+test('does not add another counted owner when one is already requested', async () => {
   const calls = []
+  const output = []
   const result = await requestReviews({
     event: pullRequestEvent(),
+    ownershipSource: '/packages/core/ @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'first' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) return []
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => output.push(line),
+  })
+
+  assert.deepEqual(result.requestedReviewers, [])
+  assert.equal(calls.some(call => call.options.method === 'POST'), false)
+  assert.deepEqual(output.slice(-7), [
+    'Current individual review requests:',
+    '- @first',
+    'Available counted review request slots: 0.',
+    'Review requests to cancel:',
+    '- (none)',
+    'Reviewers to request:',
+    '- (none)',
+  ])
+})
+
+test('requests at most one owner per run when turtle ranks first', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
+    ownershipSource: '/packages/core/ @turtle1999\n/packages/client/ @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [
+          { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
+          { filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 },
+        ]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [], teams: [] }
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result.requestedReviewers, ['turtle1999'])
+  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'POST', body: { reviewers: ['turtle1999'] } },
+  })
+})
+
+test('does not add turtle when one counted reviewer is already requested', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor' }),
     ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
     api: async (path, options = {}) => {
       calls.push({ path, options })
       if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/core/agent/src/index.ts' }]
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
       }
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'first' }, { login: 'second' }], teams: [] }
+        return { users: [{ login: 'first' }], teams: [] }
       }
+      if (path.endsWith('/timeline?per_page=100&page=1')) return []
       throw new Error(`unexpected API path ${path}`)
     },
     write: () => {},
@@ -380,14 +494,136 @@ test('does not add an owner when two people are already requested', async () =>
   assert.equal(calls.some(call => call.options.method === 'POST'), false)
 })
 
+test('keeps the counted slot available when turtle is already requested', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor' }),
+    ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'turtle1999' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) return []
+      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
+  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
+  })
+})
+
+test('replaces a workflow reviewer that no longer matches current ownership', async () => {
+  const trace = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor' }),
+    ownershipSource: '/packages/core/ @mektpoy\n',
+    api: async (path, options = {}) => {
+      trace.push({ type: 'api', path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'Dudu-0223' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) {
+        return [{
+          event: 'review_requested',
+          requested_reviewer: { login: 'Dudu-0223' },
+          review_requester: { login: 'github-actions[bot]' },
+        }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
+      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => trace.push({ type: 'log', line }),
+  })
+
+  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
+  assert.deepEqual(result.cancelledReviewers, ['Dudu-0223'])
+  const cancelLog = trace.findIndex(item => item.type === 'log' && item.line === 'Review requests to cancel:')
+  const requestLog = trace.findIndex(item => item.type === 'log' && item.line === 'Reviewers to request:')
+  const firstMutation = trace.findIndex(item => item.type === 'api' && item.options.method !== undefined)
+  assert.ok(cancelLog >= 0 && requestLog >= 0 && cancelLog < firstMutation && requestLog < firstMutation)
+  assert.equal(trace[cancelLog + 1].line, '- @Dudu-0223')
+  assert.equal(trace[requestLog + 1].line, '- @mektpoy')
+  assert.deepEqual(trace.filter(item => item.type === 'api' && item.options.method !== undefined), [
+    {
+      type: 'api',
+      path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+      options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
+    },
+    {
+      type: 'api',
+      path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+      options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
+    },
+  ])
+})
+
+test('removes excess workflow reviewers using current relevance order', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
+    ownershipSource: '/packages/core/ @mektpoy\n/packages/subagent/ @Dudu-0223\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [
+          { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
+          { filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 },
+        ]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) {
+        return ['Dudu-0223', 'mektpoy'].map(login => ({
+          event: 'review_requested',
+          requested_reviewer: { login },
+          review_requester: { login: 'github-actions[bot]' },
+        }))
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result, {
+    changedCodeFiles: [
+      'packages/core/agent/src/index.ts',
+      'packages/subagent/subagent/src/index.ts',
+    ],
+    excludedTestFiles: [],
+    excludedDocumentationFiles: [],
+    excludedCommentOnlyFiles: [],
+    requestedReviewers: [],
+    cancelledReviewers: ['Dudu-0223'],
+  })
+  assert.deepEqual(calls.find(call => call.options.method === 'DELETE'), {
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
+  })
+})
+
 test('does not request reviewers for test, documentation, or comment-only changes', async () => {
   const calls = []
   const output = []
   const files = [
-    { filename: 'apps/web/tests/chat.e2e.ts' },
-    { filename: 'packages/core/agent/tests/agent.spec.ts' },
-    { filename: 'packages/core/agent/README.md' },
-    { filename: 'packages/core/agent/examples.yaml' },
+    { filename: 'apps/web/tests/chat.e2e.ts', additions: 10, deletions: 0 },
+    { filename: 'packages/core/agent/tests/agent.spec.ts', additions: 10, deletions: 0 },
+    { filename: 'packages/core/agent/README.md', additions: 10, deletions: 0 },
+    { filename: 'packages/core/agent/examples.yaml', additions: 10, deletions: 0 },
     {
       filename: 'packages/core/agent/src/index.ts',
       status: 'modified', additions: 1, deletions: 1,
@@ -399,7 +635,9 @@ test('does not request reviewers for test, documentation, or comment-only change
     ownershipSource,
     api: async (path) => {
       calls.push(path)
-      return files
+      if (path.endsWith('/files?per_page=100&page=1')) return files
+      if (path.endsWith('/requested_reviewers')) return { users: [], teams: [] }
+      throw new Error(`unexpected API path ${path}`)
     },
     write: line => output.push(line),
   })
@@ -411,7 +649,7 @@ test('does not request reviewers for test, documentation, or comment-only change
     requestedReviewers: [],
     cancelledReviewers: [],
   })
-  assert.equal(calls.length, 1)
+  assert.equal(calls.length, 2)
   assert.deepEqual(output.slice(0, 4), [
     'This is by automated Angry Turtle Cyborg, not a human',
     'Changed code files:',
@@ -423,9 +661,9 @@ test('does not request reviewers for test, documentation, or comment-only change
 test('cancels workflow-authored review requests on draft pull requests', async () => {
   const trace = []
   const files = [
-    { filename: 'packages/subagent/subagent/src/index.ts' },
-    { filename: 'packages/subagent/subagent/tests/index.spec.ts' },
-    { filename: 'packages/subagent/subagent/README.md' },
+    { filename: 'packages/subagent/subagent/src/index.ts', additions: 10, deletions: 2 },
+    { filename: 'packages/subagent/subagent/tests/index.spec.ts', additions: 10, deletions: 0 },
+    { filename: 'packages/subagent/subagent/README.md', additions: 10, deletions: 0 },
   ]
   const result = await requestReviews({
     event: pullRequestEvent({ draft: true, changedFiles: files.length }),

+ 2 - 2
docs/capability-seams.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/capability-seams.md
-capability-seams.md: a47ec285e248c4d969e49bc941d5c93faef7b65e
-capability-seams.zh.md: 42e7105611f21f74ee0d8088e06e5c7007af9376
+capability-seams.md: 008a1d3cdb75876697212c47b7570f3d971df6c3
+capability-seams.zh.md: 4ee46b66eb775252df2b67da09e6157f8d042a3f

+ 8 - 2
docs/capability-seams.md

@@ -98,7 +98,9 @@ flowchart LR
   svc_systemPrompt["ctx.systemPrompt<br/>System prompt assembly registry"]
   pkg_tools["tools"]
   pkg_tool_terminal["tool-terminal"]
+  pkg_tool_visualizer["tool-visualizer"]
   pkg_tool_web["tool-web"]
+  svc_visualizer["ctx.visualizer<br/>Inline Visualizer authority"]
   svc_tools["ctx.tools<br/>Tool registry and guarded execution pipeline"]
   pkg_tool_ask_user["tool-ask-user"]
   pkg_tool_cordis["tool-cordis"]
@@ -327,6 +329,7 @@ flowchart LR
   pkg_terminal_bash --> svc_terminals
   pkg_token_meter --> svc_tokenMeter
   pkg_tool_subagent --> svc_subagentModelSelection
+  pkg_tool_visualizer --> svc_visualizer
   pkg_tools --> svc_tools
   pkg_typert_registry --> svc_typert
   pkg_user_approval --> svc_approval
@@ -439,6 +442,7 @@ flowchart LR
   svc_systemPrompt --> pkg_agent_loop
   svc_systemPrompt --> pkg_tool_fs
   svc_systemPrompt --> pkg_tool_terminal
+  svc_systemPrompt --> pkg_tool_visualizer
   svc_systemPrompt --> pkg_tool_web
   svc_systemPrompt --> pkg_tools
   svc_terminals --> pkg_tool_terminal
@@ -453,6 +457,7 @@ flowchart LR
   svc_tools --> pkg_tool_subagent
   svc_tools --> pkg_tool_terminal
   svc_tools --> pkg_tool_todo
+  svc_tools --> pkg_tool_visualizer
   svc_tools --> pkg_tool_web
   svc_typert --> pkg_api_gateway
   svc_typert --> pkg_typert_loader
@@ -503,8 +508,9 @@ flowchart LR
 | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents. |
 | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
 | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
-| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
-| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
+| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-visualizer`](../packages/visualizer/tool-visualizer), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
+| `ctx.visualizer` | `core` | [`tool-visualizer`](../packages/visualizer/tool-visualizer) | - | - | - | Owns limits, exact live-Agent/call authorization, follow-up admission, and generated Remote operations; its preset-scoped ./model contribution consumes the authority and owns model-tool and prompt registration lifecycles. |
+| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-visualizer`](../packages/visualizer/tool-visualizer), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
 | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. |

+ 8 - 2
docs/capability-seams.zh.md

@@ -100,7 +100,9 @@ flowchart LR
   svc_systemPrompt["ctx.systemPrompt<br/>System prompt assembly registry"]
   pkg_tools["tools"]
   pkg_tool_terminal["tool-terminal"]
+  pkg_tool_visualizer["tool-visualizer"]
   pkg_tool_web["tool-web"]
+  svc_visualizer["ctx.visualizer<br/>Inline Visualizer authority"]
   svc_tools["ctx.tools<br/>Tool registry and guarded execution pipeline"]
   pkg_tool_ask_user["tool-ask-user"]
   pkg_tool_cordis["tool-cordis"]
@@ -329,6 +331,7 @@ flowchart LR
   pkg_terminal_bash --> svc_terminals
   pkg_token_meter --> svc_tokenMeter
   pkg_tool_subagent --> svc_subagentModelSelection
+  pkg_tool_visualizer --> svc_visualizer
   pkg_tools --> svc_tools
   pkg_typert_registry --> svc_typert
   pkg_user_approval --> svc_approval
@@ -441,6 +444,7 @@ flowchart LR
   svc_systemPrompt --> pkg_agent_loop
   svc_systemPrompt --> pkg_tool_fs
   svc_systemPrompt --> pkg_tool_terminal
+  svc_systemPrompt --> pkg_tool_visualizer
   svc_systemPrompt --> pkg_tool_web
   svc_systemPrompt --> pkg_tools
   svc_terminals --> pkg_tool_terminal
@@ -455,6 +459,7 @@ flowchart LR
   svc_tools --> pkg_tool_subagent
   svc_tools --> pkg_tool_terminal
   svc_tools --> pkg_tool_todo
+  svc_tools --> pkg_tool_visualizer
   svc_tools --> pkg_tool_web
   svc_typert --> pkg_api_gateway
   svc_typert --> pkg_typert_loader
@@ -505,8 +510,9 @@ flowchart LR
 | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | 该接口返回 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问与排序,但不读取文件内容。 |
 | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 |
 | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 |
-| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 |
-| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 |
+| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-visualizer`](../packages/visualizer/tool-visualizer), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 |
+| `ctx.visualizer` | `core` | [`tool-visualizer`](../packages/visualizer/tool-visualizer) | - | - | - | 拥有限额、精确 live Agent/call 授权、follow-up 准入,以及生成式 Remote 操作;其 preset 作用域的 `./model` contribution 使用该权威,并负责模型工具与提示词注册的生命周期。 |
+| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-visualizer`](../packages/visualizer/tool-visualizer), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 |
 | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 |
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 |

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: d3ea87e94ecb2de64c146c8647684785e537647c
-config-catalog.zh.md: 053ca6d1e357c07524b4b993447a59ed69761a88
+config-catalog.md: 5d138f010b112b508c9800c66992b2ad32ab3ab7
+config-catalog.zh.md: 625ea475e3802e50c1e892c90008567798427f55

+ 2 - 1
docs/config-catalog.md

@@ -2597,7 +2597,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:243`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 
@@ -3496,6 +3496,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-tool-call-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts))
 - `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
 - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
+- `@deepseek-ai/dsh-tool-visualizer` — requires `agents` ([`packages/visualizer/tool-visualizer/src/index.ts`](../packages/visualizer/tool-visualizer/src/index.ts))
 - `@deepseek-ai/dsh-user-questions` ([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts))
 - `@deepseek-ai/dsh-webhook` — requires `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` ([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts))
 - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))

+ 2 - 1
docs/config-catalog.zh.md

@@ -2599,7 +2599,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
+来源:[`packages/core/system-prompt/src/index.ts:243`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 
@@ -3498,6 +3498,7 @@ export interface Config {
 - `@deepseek-ai/dsh-tool-call-timeout-policy` — 需要 `tools`([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts))
 - `@deepseek-ai/dsh-tool-cordis` — 需要 `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect`([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
 - `@deepseek-ai/dsh-tool-subagent-control` — 需要 `tools` · `subagents`([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
+- `@deepseek-ai/dsh-tool-visualizer` — 需要 `agents`([`packages/visualizer/tool-visualizer/src/index.ts`](../packages/visualizer/tool-visualizer/src/index.ts))
 - `@deepseek-ai/dsh-user-questions`([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts))
 - `@deepseek-ai/dsh-webhook` — 需要 `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry`([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts))
 - `@deepseek-ai/dsh-workspace` — 需要 `storageDomain` · `sessionPersistence`([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))

+ 2 - 2
docs/i18n/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 docs/i18n/README.md
-README.md: 1b6ed4a3f4bff05cdb52b28fc81c5d7b47a9a260
-README.zh.md: e28db9bb230cf5fb344fdc3ddc415883f5ebe6b3
+README.md: 55ae07c18e09fde141ecf5344f715dfa25658325
+README.zh.md: 674edeb9da4bf0083c607216a3c992a98f61897a

+ 1 - 0
docs/i18n/README.md

@@ -51,6 +51,7 @@ Generated English references and graphs participate in pairing when a reviewed C
 - `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.
 - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.
 - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.
+- [review-ownership/README.md](../../.github/review-ownership/README.md) and its [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) — repository-internal automation policy maintained in English only.
 - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.
 
 **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.

+ 1 - 0
docs/i18n/README.zh.md

@@ -53,6 +53,7 @@
 - `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。
 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
+- [review-ownership/README.md](../../.github/review-ownership/README.md) 及其 [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md):仓库内部自动化政策,只以英文维护。
 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。
 
 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: f0ebb388318ed4a0aa8896a8040a5539f5477037
-module-graph.zh.md: 0cc8ff7b9a7601102f8d708b8ebb2d17c7bb0612
+module-graph.md: 9e4f38cfd9b7b8942323b2fd6ee9b2e64e6f15e6
+module-graph.zh.md: dfbe00aa829523dc71e4e4fa1fc476f8748fef79

+ 11 - 0
docs/module-graph.md

@@ -358,6 +358,9 @@ flowchart TD
     pkg_typert_protocol["typert-protocol"]
     pkg_typert_registry["typert-registry"]
   end
+  subgraph group_visualizer["packages/visualizer"]
+    pkg_tool_visualizer["tool-visualizer"]
+  end
   subgraph group_webhook["packages/webhook"]
     pkg_webhook["webhook"]
     pkg_webhook_github["webhook-github"]
@@ -879,6 +882,13 @@ flowchart TD
   pkg_llm_replay --> pkg_deepseek_llm_api_extensions
   pkg_llm_replay --> pkg_llm
   pkg_llm_replay --> pkg_session
+  pkg_tool_visualizer --> pkg_agent
+  pkg_tool_visualizer --> pkg_llm
+  pkg_tool_visualizer --> pkg_scope
+  pkg_tool_visualizer --> pkg_session
+  pkg_tool_visualizer --> pkg_system_prompt
+  pkg_tool_visualizer --> pkg_tools
+  pkg_tool_visualizer --> pkg_typert_protocol
   pkg_tool_workflow --> pkg_agent
   pkg_tool_workflow --> pkg_invariants
   pkg_tool_workflow --> pkg_llm
@@ -1394,6 +1404,7 @@ flowchart TD
 | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
 | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+| [`tool-visualizer`](../packages/visualizer/tool-visualizer) | `visualizer` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |

+ 11 - 0
docs/module-graph.zh.md

@@ -360,6 +360,9 @@ flowchart TD
     pkg_typert_protocol["typert-protocol"]
     pkg_typert_registry["typert-registry"]
   end
+  subgraph group_visualizer["packages/visualizer"]
+    pkg_tool_visualizer["tool-visualizer"]
+  end
   subgraph group_webhook["packages/webhook"]
     pkg_webhook["webhook"]
     pkg_webhook_github["webhook-github"]
@@ -881,6 +884,13 @@ flowchart TD
   pkg_llm_replay --> pkg_deepseek_llm_api_extensions
   pkg_llm_replay --> pkg_llm
   pkg_llm_replay --> pkg_session
+  pkg_tool_visualizer --> pkg_agent
+  pkg_tool_visualizer --> pkg_llm
+  pkg_tool_visualizer --> pkg_scope
+  pkg_tool_visualizer --> pkg_session
+  pkg_tool_visualizer --> pkg_system_prompt
+  pkg_tool_visualizer --> pkg_tools
+  pkg_tool_visualizer --> pkg_typert_protocol
   pkg_tool_workflow --> pkg_agent
   pkg_tool_workflow --> pkg_invariants
   pkg_tool_workflow --> pkg_llm
@@ -1396,6 +1406,7 @@ flowchart TD
 | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
 | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+| [`tool-visualizer`](../packages/visualizer/tool-visualizer) | `visualizer` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |

+ 2 - 2
docs/subsystems/tools.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/tools.md
-tools.md: 9939e8ab9fff9fa5bd23fd370e07f6296a608824
-tools.zh.md: 52e812a35d6932a5ed00a86a3d3fe71460b26cd4
+tools.md: 27e5aa6481eb723110dd70e25ecd5e403f9413a6
+tools.zh.md: 52e579da6aaf544ab59ae21877bce3d43cab75ae

+ 25 - 0
docs/subsystems/tools.md

@@ -573,6 +573,31 @@ Types: [ScopeKey](scope.md)
 
 Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts)
 
+<a id="ctxvisualizer--visualizerservice"></a>
+
+### `ctx.visualizer` — `VisualizerService`
+
+One Host service owns every authority-bearing part of the Visualizer extension.
+
+```ts cordis-catalog
+/**
+ * Install the model-facing Visualizer contribution in the caller's preset scope.
+ * @param modelCtx - Preset-scoped context that owns the prompt and tool effects.
+ */
+installModelSurface(modelCtx: Context): void
+
+/**
+ * Queue a widget-authored message after Host-side authorization and rate limiting.
+ * @param agent - Exact live Agent resolved from the Remote session scope.
+ * @param request - Follow-up for one successful interactive widget call.
+ */
+@Remote('sendPrompt') remoteSendPrompt(agent: Agent, request: WidgetPromptRequest): void
+```
+
+Types: [Agent](core.md)
+
+Source: [`packages/visualizer/tool-visualizer/src/index.ts`](../../packages/visualizer/tool-visualizer/src/index.ts)
+
 <a id="tools-events"></a>
 
 ### `tools/*` events

+ 25 - 0
docs/subsystems/tools.zh.md

@@ -573,6 +573,31 @@ Types: [ScopeKey](scope.zh.md)
 
 Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts)
 
+<a id="ctxvisualizer--visualizerservice"></a>
+
+### `ctx.visualizer` — `VisualizerService`
+
+One Host service owns every authority-bearing part of the Visualizer extension.
+
+```ts cordis-catalog
+/**
+ * Install the model-facing Visualizer contribution in the caller's preset scope.
+ * @param modelCtx - Preset-scoped context that owns the prompt and tool effects.
+ */
+installModelSurface(modelCtx: Context): void
+
+/**
+ * Queue a widget-authored message after Host-side authorization and rate limiting.
+ * @param agent - Exact live Agent resolved from the Remote session scope.
+ * @param request - Follow-up for one successful interactive widget call.
+ */
+@Remote('sendPrompt') remoteSendPrompt(agent: Agent, request: WidgetPromptRequest): void
+```
+
+Types: [Agent](core.zh.md)
+
+Source: [`packages/visualizer/tool-visualizer/src/index.ts`](../../packages/visualizer/tool-visualizer/src/index.ts)
+
 <a id="tools-events"></a>
 
 ### `tools/*` events

+ 2 - 2
docs/tool-catalog.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/tool-catalog.md
-tool-catalog.md: 1a98079dcf3d30696cf2dae2895372a90377204d
-tool-catalog.zh.md: 41b5f6d4bc8b0e666e74dde5b4bb43bd0064da8e
+tool-catalog.md: 34dc6ebd4f0291fee48d6e8c6d3ac0570cbd1e7b
+tool-catalog.zh.md: 14f7a3a4ab18e632787f1b4462d0bf504c6f3b16

+ 64 - 0
docs/tool-catalog.md

@@ -38,6 +38,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
 | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. |
 | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. |
+| `@deepseek-ai/dsh-tool-visualizer` | `show_widget`, `widget_guidelines` | `ctx.visualizer`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.followup() for authorized widget follow-ups` | - | The standard and cordis presets always mount a recoverable model wrapper, which contributes prompt and tools only while the authority service exists and follows later authority withdrawal or reappearance. Minimal and PTC omit it; PTC nested code-dispatch calls lack the ordinary show_widget call/result identity required by the follow-up bridge. |
 | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
 
@@ -2078,6 +2079,69 @@ Source: [`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/
 
 todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task.
 
+<a id="deepseek-aidsh-tool-visualizer"></a>
+
+## `@deepseek-ai/dsh-tool-visualizer`
+
+### `show_widget`
+
+Render one temporary inline visual from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "title": {
+      "type": "string",
+      "description": "Short user-facing title in the user's language."
+    },
+    "widget_code": {
+      "type": "string",
+      "description": "For HTML: one fragment. Put any inline <style> first, content next (semantic HTML, inline <svg>, <canvas> using 2D or WebGL, or native controls), and any inline <script> last. For SVG: raw SVG beginning with <svg>, with a viewBox that declares its aspect ratio. Keep the widget self-contained: include all required styles, scripts, data, and assets in the source."
+    }
+  },
+  "required": [
+    "title",
+    "widget_code"
+  ]
+}
+```
+
+Source: [`packages/visualizer/tool-visualizer/src/tools.ts`](../packages/visualizer/tool-visualizer/src/tools.ts)
+
+### `widget_guidelines`
+
+Load request-matched construction guidance for a widget response.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "modules": {
+      "type": "array",
+      "description": "Choose every relevant module; content and behavior are independent, so combine modules when useful. diagram: nodes and relationships. chart: quantitative data. illustration: a scene or image. interactive: motion that contributes to the presentation, or direct manipulation and adjustable inputs requested by the user. mockup: a product surface shown to explain one interaction.",
+      "items": {
+        "type": "string",
+        "enum": [
+          "diagram",
+          "mockup",
+          "interactive",
+          "chart",
+          "illustration"
+        ]
+      }
+    }
+  },
+  "required": [
+    "modules"
+  ]
+}
+```
+
+Source: [`packages/visualizer/tool-visualizer/src/tools.ts`](../packages/visualizer/tool-visualizer/src/tools.ts)
+
+The standard and cordis presets always mount a recoverable model wrapper, which contributes prompt and tools only while the authority service exists and follows later authority withdrawal or reappearance. Minimal and PTC omit it; PTC nested code-dispatch calls lack the ordinary show_widget call/result identity required by the follow-up bridge.
+
 <a id="deepseek-aidsh-tool-workflow"></a>
 
 ## `@deepseek-ai/dsh-tool-workflow`

+ 64 - 0
docs/tool-catalog.zh.md

@@ -42,6 +42,7 @@
 | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
 | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 |
 | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 |
+| `@deepseek-ai/dsh-tool-visualizer` | `show_widget`、`widget_guidelines` | `ctx.visualizer`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result`、经 `agent.followup()` 写入获授权 widget follow-up 的 `user/message` | - | standard 与 cordis preset 始终挂载可恢复的模型 wrapper;它只在权威 service 存在时贡献提示词和工具,并跟随权威后续撤销或再次出现。Minimal 与 PTC 不挂载;PTC 的嵌套 code-dispatch 调用缺少 follow-up bridge 所需的普通 show_widget call/result 身份。 |
 | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 |
 
@@ -2085,6 +2086,69 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
 
 todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。
 
+<a id="deepseek-aidsh-tool-visualizer"></a>
+
+## `@deepseek-ai/dsh-tool-visualizer`
+
+### `show_widget`
+
+从对话内容或已完成的工具结果渲染一份临时内联视觉内容。以 <svg 开头的源码使用 SVG,其余源码使用 HTML。在本次调用中传入一份小而完整的源码;提交后才会进行校验。
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "title": {
+      "type": "string",
+      "description": "Short user-facing title in the user's language."
+    },
+    "widget_code": {
+      "type": "string",
+      "description": "For HTML: one fragment. Put any inline <style> first, content next (semantic HTML, inline <svg>, <canvas> using 2D or WebGL, or native controls), and any inline <script> last. For SVG: raw SVG beginning with <svg>, with a viewBox that declares its aspect ratio. Keep the widget self-contained: include all required styles, scripts, data, and assets in the source."
+    }
+  },
+  "required": [
+    "title",
+    "widget_code"
+  ]
+}
+```
+
+来源:[`packages/visualizer/tool-visualizer/src/tools.ts`](../packages/visualizer/tool-visualizer/src/tools.ts)
+
+### `widget_guidelines`
+
+加载与请求匹配的 widget 构造指导。
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "modules": {
+      "type": "array",
+      "description": "Choose every relevant module; content and behavior are independent, so combine modules when useful. diagram: nodes and relationships. chart: quantitative data. illustration: a scene or image. interactive: motion that contributes to the presentation, or direct manipulation and adjustable inputs requested by the user. mockup: a product surface shown to explain one interaction.",
+      "items": {
+        "type": "string",
+        "enum": [
+          "diagram",
+          "mockup",
+          "interactive",
+          "chart",
+          "illustration"
+        ]
+      }
+    }
+  },
+  "required": [
+    "modules"
+  ]
+}
+```
+
+来源:[`packages/visualizer/tool-visualizer/src/tools.ts`](../packages/visualizer/tool-visualizer/src/tools.ts)
+
+只有权威 service 存在时,standard 与 cordis preset 才会挂载本包的完整模型 contribution。Minimal 与 PTC 不挂载;PTC 的嵌套 code-dispatch 调用缺少 follow-up bridge 所需的普通 show_widget call/result 身份。
+
 <a id="deepseek-aidsh-tool-workflow"></a>
 
 ## `@deepseek-ai/dsh-tool-workflow`

+ 2 - 2
packages/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/README.md
-README.md: 9c298d3f2426bab92a6d602495d836ef8ee9fb44
-README.zh.md: 309a0e678968927b79d527991447847d0fa45f83
+README.md: e40089aa8b3e7e1291a01c559fc8443ab9c03849
+README.zh.md: f6e69fae4c02ae176150d79fb77bcc79fd7a4e42

+ 1 - 0
packages/README.md

@@ -71,6 +71,7 @@ Every package lives in exactly one group; new packages join existing groups, and
 | [`sdk/`](sdk/README.md) | Out-of-process SDK: JSON-RPC protocol and TypeScript client/server |
 | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server |
 | [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool |
+| [`visualizer/`](visualizer/README.md) | Inline visual capability: Host authorization and preset-scoped model tools |
 | [`boot/`](boot/README.md) | Shared app-bin boot glue |
 | [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server |
 | [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins |

+ 1 - 0
packages/README.zh.md

@@ -71,6 +71,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会
 | [`sdk/`](sdk/README.zh.md) | 进程外 SDK:JSON-RPC 协议与 TypeScript 客户端/服务器 |
 | [`acp/`](acp/README.zh.md) | 仅面向自动化的 Agent Client Protocol 服务器 |
 | [`interaction/`](interaction/README.zh.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 |
+| [`visualizer/`](visualizer/README.zh.md) | 内联视觉能力:Host 授权与 preset 作用域模型工具 |
 | [`boot/`](boot/README.zh.md) | 共享的 app bin 启动粘合层 |
 | [`host/`](host/README.zh.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 |
 | [`client/`](client/README.zh.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 |

+ 1 - 0
packages/core/system-prompt/src/index.ts

@@ -136,6 +136,7 @@ const SECTION_ORDERS = {
   TOOL_PTY: 1700,
   TOOL_WEB_SEARCH: 2000,
   TOOL_WEB_FETCH: 2100,
+  TOOL_VISUALIZER: 2150,
   TOOL_LSP: 2200,
   TOOL_SESSION_QUERY: 2300,
   TOOL_GOAL: 2400,

+ 2 - 2
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -33,11 +33,11 @@ describe('gen-tool-catalog collectToolCatalog', () => {
       'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph',
       'read', 'read_image', 'run_code', 'schedule_create', 'schedule_delete',
       'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search',
-      'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate',
+      'session_event_trace', 'session_search', 'session_trace', 'show_widget', 'skill', 'spawn_teammate',
       'str_replace_editor', 'subagent', 'team_task_create',
       'team_task_get', 'team_task_list', 'team_task_update', 'terminal_close', 'terminal_list',
       'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write',
-      'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'write',
+      'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'widget_guidelines', 'workflow', 'write',
     ])
     // Every tool carries a JSON-Schema `parameters` object (what the model sees).
     for (const entry of catalog) {

+ 21 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2736,6 +2736,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'visualizer',
+    summary: 'One Host service owns every authority-bearing part of the Visualizer extension.',
+    description: 'One Host service owns every authority-bearing part of the Visualizer extension.',
+    methods: [
+      {
+        signature: 'installModelSurface(modelCtx: Context): void',
+        description: 'Install the model-facing Visualizer contribution in the caller\'s preset scope.',
+        parameters: [{ name: 'modelCtx', description: 'Preset-scoped context that owns the prompt and tool effects.' }],
+      },
+      {
+        signature: '@Remote(\'sendPrompt\') remoteSendPrompt(agent: Agent, request: WidgetPromptRequest): void',
+        description: 'Queue a widget-authored message after Host-side authorization and rate limiting.',
+        parameters: [{ name: 'agent', description: 'Exact live Agent resolved from the Remote session scope.' }, { name: 'request', description: 'Follow-up for one successful interactive widget call.' }],
+      },
+    ],
+  },
   {
     key: 'web',
     summary: 'The web access service.',
@@ -6303,6 +6320,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'WebUpgradeRoute',
     declaration: 'export interface WebUpgradeRoute {\n    path: string;\n    handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>;\n}',
   },
+  {
+    name: 'WidgetPromptRequest',
+    declaration: 'export interface WidgetPromptRequest {\n    readonly resultSeq: number;\n    readonly text: string;\n}',
+  },
   {
     name: 'WorkflowAgentEndInfo',
     declaration: 'export interface WorkflowAgentEndInfo extends WorkflowAgentInfo {\n    outcome: WorkflowAgentOutcome;\n}',

+ 1 - 0
packages/preset/agent-presets/package.json

@@ -72,6 +72,7 @@
   "dependencies": {
     "js-yaml": "^4.1.0",
     "@deepseek-ai/schemastery": "workspace:^",
+    "@deepseek-ai/dsh-tool-visualizer": "workspace:^",
     "zod": "^4.4.3"
   },
   "devDependencies": {

+ 5 - 0
packages/preset/agent-presets/presets/cordis/agent.cordis.yml

@@ -239,6 +239,11 @@
     fetch: true
     searchTimeoutMs: 60000
 
+# This always-mounted wrapper owns the inherited model surface and activates its
+# prompt and tools whenever the deployment Host's Visualizer authority is present.
+- id: tool-visualizer-model
+  name: '@deepseek-ai/dsh-tool-visualizer/model'
+
 # ── self-modification ───────────────────────────────────────────────────────
 
 # Read the live runtime, mount a temporary plugin, unmount it. The toolset is a

+ 5 - 0
packages/preset/agent-presets/presets/standard/agent.cordis.yml

@@ -250,3 +250,8 @@
   config:
     fetch: true
     searchTimeoutMs: 60000
+
+# This always-mounted wrapper owns the inherited model surface and activates its
+# prompt and tools whenever the deployment Host's Visualizer authority is present.
+- id: tool-visualizer-model
+  name: '@deepseek-ai/dsh-tool-visualizer/model'

+ 3 - 3
.github/review-ownership/README.i18n.yaml → packages/visualizer/README.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .github/review-ownership/README.md
-README.md: 4c412a7c47f114e0aa6aa9a3048fd7f833f0b0e3
-README.zh.md: 1db7d1b56d4927a83c502557f393cb6518dc09f1
+#   pnpm run verify-translation-pairing --write packages/visualizer/README.md
+README.md: f5a8df12c2131d9e289b30ad4b5a183d25384bc0
+README.zh.md: 76bae9d1fe86f0d6b98235281e0705a0f286170b

+ 41 - 0
packages/visualizer/README.md

@@ -0,0 +1,41 @@
+---
+description: "The Visualizer package group: dormant Host authority and preset-scoped model tools for inline visual components."
+kind: "package-group"
+---
+
+# visualizer/ — inline visual components
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Visualizer currently provides an independently loadable Host/model capability for temporary inline visuals. Its root Cordis service owns Host limits and authorized widget follow-ups, while a second entrypoint contributes prompt and tools from an Agent preset scope. Follow-up authority is bound to the retained immutable Session result, not to the lifetime of a presentation iframe. The default application compositions do not mount the authority, so the always-mounted model wrapper contributes nothing until an authority appears. A separately supplied compatible Client can consume the follow-up Remote and apply its own rendering limits; this package group does not yet provide or activate presentation.
+
+## Table of Contents
+
+- [Packages](#packages)
+- [Related documentation](#related-documentation)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="packages"></a>
+## Packages
+
+The group currently contains one Host package with separate authority and model entrypoints.
+
+| Package | Role |
+|---|---|
+| [`tool-visualizer/`](tool-visualizer/README.md) | Provides the dormant Host authority, generated follow-up Remote, preset-scoped prompt, and Visualizer tools |
+
+<a id="related-documentation"></a>
+## Related documentation
+
+- [Tool subsystem](../../docs/subsystems/tools.md) — model tool registration, execution, and logged results.
+- [System prompt subsystem](../../docs/subsystems/system-prompt.md) — ordered prompt sections and context contributions.
+- [Implementation decision](../../.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.md) — dormancy, authority, and model-scope boundaries.
+
+<a id="dev-note"></a>
+## Dev Note
+
+Keep Host authority in the root service and model policy in the same package's preset-scoped entrypoint. A compatible presentation Client may consume the generated follow-up Remote without adding a presentation contract to session/core types; none is mounted by the default compositions.

+ 41 - 0
packages/visualizer/README.zh.md

@@ -0,0 +1,41 @@
+---
+description: "Visualizer 包组:用于内联视觉组件的休眠 Host 权威与 preset-scoped 模型工具。"
+kind: "package-group"
+---
+
+# visualizer/:内联视觉组件
+
+[English](README.md) | 中文
+
+## 概述
+
+Visualizer 当前提供一个可独立装载的 Host/模型能力,用于临时内联视觉内容。根 Cordis service 拥有 Host 限额与已授权 widget follow-up,第二个 entrypoint 则从 Agent preset scope 贡献提示词和工具。follow-up 权威绑定到已保留的不可变 Session result,而非展示 iframe 的生命周期。默认 application composition 不挂载该权威,因此始终挂载的 model wrapper 在权威出现前不贡献任何模型面。单独提供的兼容 Client 可以消费 follow-up Remote,并应用自身的渲染限额;本包组尚不提供或激活展示能力。
+
+## 目录
+
+- [包](#packages)
+- [相关文档](#related-documentation)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="packages"></a>
+## 包
+
+本包组目前只包含一个 Host 包,其中权威与模型入口彼此分离。
+
+| 包 | 职责 |
+|---|---|
+| [`tool-visualizer/`](tool-visualizer/README.zh.md) | 提供休眠 Host 权威、生成式 follow-up Remote、preset-scoped 提示词与 Visualizer 工具 |
+
+<a id="related-documentation"></a>
+## 相关文档
+
+- [工具子系统](../../docs/subsystems/tools.zh.md):模型工具注册、执行与已记录结果。
+- [系统提示词子系统](../../docs/subsystems/system-prompt.zh.md):有序提示词 section 与 context contribution。
+- [实现决策](../../.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.zh.md):休眠、权威与模型 scope 边界。
+
+<a id="dev-note"></a>
+## 开发备注
+
+Host 权威留在根 service,模型策略留在同一包的 preset-scoped entrypoint。兼容的展示 Client 可以消费生成式 follow-up Remote,而无需向 session/core type 添加展示契约;默认 composition 不挂载任何此类 Client。

+ 6 - 0
packages/visualizer/tool-visualizer/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/visualizer/tool-visualizer/README.md
+README.md: ea8424262e224edc44f3d7c97094e6fc3cef68b1
+README.zh.md: 77e92cba06eb3e7e5454dafd47684d4d44f5a843

+ 101 - 0
packages/visualizer/tool-visualizer/README.md

@@ -0,0 +1,101 @@
+---
+description: "Host authority, model tools, prompt policy, and authorized follow-ups for inline Visualizer widgets."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-tool-visualizer
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-tool-visualizer` provides the bounded Host contract for temporary inline visuals. Its root Cordis service owns source limits, call authorization, follow-up admission, and a generated follow-up Remote for a separately supplied compatible Client; its `./model` entrypoint contributes the prompt and tools from an Agent preset scope. Compositions without the root authority retain their existing prompt and tool surfaces. A mounted authority accepts both raw SVG and HTML fragments; the compatible Client must isolate each detected source kind appropriately.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Dev Note](#dev-note)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount the root service only in a composition that supplies a compatible Client:
+
+```yaml
+- name: '@deepseek-ai/dsh-tool-visualizer'
+```
+
+`standard` and `cordis` always mount this Agent-preset wrapper; `minimal` and `ptc` omit it:
+
+```yaml
+- name: '@deepseek-ai/dsh-tool-visualizer/model'
+```
+
+The wrapper waits recoverably for root authority. It contributes no prompt or tools while authority is absent, activates the inherited preset surface when authority appears, withdraws it when authority leaves, and can activate again later. Child `toolFilter` restrictions continue to apply throughout that lifecycle.
+
+### Authority and follow-ups
+
+The generated Remote accepts a follow-up only for the exact live Agent and the exact persisted HTML `show_widget` result identified by `resultSeq`. The result must be an appended success whose single source event is a same-turn, same-step `show_widget` call with the matching tool-call ID. The Host derives the title from that source call and the widget kind from the result's presentation metadata, bounds the complete labelled follow-up to 4096 UTF-8 bytes, and applies a fixed limit of four admissions per Agent per rolling minute. Widget source is limited to 128 KiB.
+
+Authorization follows that retained immutable Session result rather than the lifetime of any Client iframe. Closing or recreating a presentation document neither revokes nor creates Host authority.
+
+An authorized follow-up enters the Agent inbox as an untrusted plugin-authored message labelled `visualizer`. `show_widget` returns a small receipt instead of echoing source already recorded in the call.
+
+<a id="dev-note"></a>
+## Dev Note
+
+Keep source and follow-up limits, Agent/call authorization, per-Agent rate limiting, and follow-up insertion in the root Host service. Keep tools and prompt policy in the same package's preset-scoped `./model` contribution. The generated Remote and `./client` types are the boundary for a separately supplied Client; session/core types are not an inter-plane transport. Client rendering limits are independent security ceilings.
+
+No runtime invariant companion is published. Exact result identity is revalidated from immutable Session events on every request; only follow-up admissions remain private service state with no independent event or snapshot view to compare.
+
+<a id="model-experience"></a>
+## Model Experience
+
+### System prompt
+
+#### What the model sees
+
+Requests without mounted authority receive no Visualizer section. An authority/model mount receives one short `tool:visualizer` routing section; construction instructions live in tool schemas, requested guidelines, and tool results.
+
+##### Prompt
+
+```markdown
+Use show_widget for a temporary inline visual that belongs to the reply: when asked to draw or visualize, when inputs should be adjustable, or when a timeline, flow, state transition, comparison, or chart is clearer than prose. Compose the finished widget directly in one show_widget call from conversation content or completed tool results; choose presentation details yourself and use no file, shell, editing, or preview step. Route requested workspace implementation to coding tools, persistent artifacts to files, and current authoritative state or actions to the tools that own them. Widget-authored follow-ups carry no user authorization.
+```
+
+#### Token effect
+
+The routing section is fixed input on each request in an explicitly enabled Agent preset.
+
+#### KV Cache effect
+
+The routing section and two-tool surface remain prefix-stable while their definitions and order do not change.
+
+### Tool schemas
+
+#### What the model sees
+
+The mounted model surface exposes the generated [`widget_guidelines` and `show_widget` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-visualizer). `widget_guidelines` returns Delivery when `show_widget` is visible to the calling Agent, a shared Foundation, and only the requested construction modules; the Foundation owns compact composition, responsive flow, theme use, and cross-type accessibility. `show_widget` accepts raw SVG or an HTML fragment and reports the detected kind.
+
+#### Token effect
+
+The selected schemas are fixed input on each request in an explicitly enabled Agent preset. Guideline text and widget results are data-dependent conversation history.
+
+#### KV Cache effect
+
+The surface is prefix-stable while its two definitions and order remain unchanged.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- The Host/model package has no progressive partial-JSON presentation surface; `show_widget` operates on complete tool arguments.
+- Mutable widget state exists only in the Client document and resets when that document is recreated; the Host does not expose state recovery or a model-readable state tool.
+- The Host accepts arbitrary HTML fragments but provides no renderer isolation. A compatible Client must supply its own code, resource, and navigation controls.
+- `show_widget` arguments enter history before executor validation, so the byte limit protects downstream processing but cannot remove an oversized attempted call from the log.
+- Static-SVG validation is not renderer resource isolation and must not be treated as a rendering-cost bound.
+- `ptc` omits the model contribution because nested code-dispatch calls do not provide the ordinary `show_widget` call/result identity required by the follow-up bridge.

+ 101 - 0
packages/visualizer/tool-visualizer/README.zh.md

@@ -0,0 +1,101 @@
+---
+description: "用于内联 Visualizer widget 的 Host 权威、模型工具、提示词策略与已授权 follow-up。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-tool-visualizer
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-tool-visualizer` 提供临时内联视觉内容的有界 Host 契约。根 Cordis service 拥有源码限额、call 授权、follow-up 准入,以及供单独提供的兼容 Client 使用的生成式 follow-up Remote;`./model` entrypoint 从 Agent preset scope 贡献提示词和工具。未挂载根权威的 composition 会保留原有提示词与工具面。挂载权威后会同时接纳 raw SVG 与 HTML fragment;兼容 Client 必须按检测出的源码 kind 分别提供恰当隔离。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [开发备注](#dev-note)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+只在提供兼容 Client 的 composition 中挂载根 service:
+
+```yaml
+- name: '@deepseek-ai/dsh-tool-visualizer'
+```
+
+`standard` 与 `cordis` 始终挂载下面这个 Agent-preset wrapper;`minimal` 和 `ptc` 不包含:
+
+```yaml
+- name: '@deepseek-ai/dsh-tool-visualizer/model'
+```
+
+wrapper 会可恢复地等待根权威。权威不存在时它不贡献提示词或工具;权威出现时激活继承的 preset 模型面,权威撤销时移除该模型面,之后权威再次出现仍可重新激活。child `toolFilter` 限制在整个生命周期中都会继续生效。
+
+### 权威与 follow-up
+
+生成式 Remote 只接纳命中准确 live Agent,且由 `resultSeq` 标识的准确已持久化 HTML `show_widget` result。该 result 必须是 append 成功的事件,其唯一 source event 必须是同 turn、同 step 且 tool-call ID 匹配的 `show_widget` call。Host 从该 source call 派生标题,从 result 的 presentation metadata 派生 widget kind,将完整已标记 follow-up 限制在 4096 UTF-8 字节以内,并固定为每个 Agent 每个滚动分钟最多接纳 4 次。Widget 源码限制为 128 KiB。
+
+授权跟随该已保留的不可变 Session result,而非任何 Client iframe 的生命周期。关闭或重建展示 document 既不会撤销 Host 权威,也不会创建 Host 权威。
+
+已授权 follow-up 会以标记为 `visualizer` 的不可信 plugin-authored message 进入 Agent inbox。`show_widget` 返回小回执,不重复已记录在 call 里的源码。
+
+<a id="dev-note"></a>
+## 开发备注
+
+源码与 follow-up 限额、Agent/call 授权、每 Agent 频控与 follow-up 写入留在根 Host service;工具与提示词策略留在同一包的 preset-scoped `./model` contribution。生成式 Remote 与 `./client` type 是单独提供的 Client 所用的边界;不要把 session/core type 当作跨运行面传输层。Client 渲染限额是独立的安全上限。
+
+本包不发布运行时 invariant companion。每次请求都会从不可变 Session event 重新校验准确 result 身份;只有 follow-up 准入仍是 service 的私有状态,没有可供比较的独立 event 或 snapshot view。
+
+<a id="model-experience"></a>
+## 模型体验
+
+### 系统提示词
+
+#### 模型看到什么
+
+未挂载权威的请求不会收到 Visualizer section。挂载 authority/model 后会收到一条短 `tool:visualizer` 路由 section;构建指令归工具 schema、按需 guideline 和工具 result。
+
+##### 提示词
+
+```markdown
+Use show_widget for a temporary inline visual that belongs to the reply: when asked to draw or visualize, when inputs should be adjustable, or when a timeline, flow, state transition, comparison, or chart is clearer than prose. Compose the finished widget directly in one show_widget call from conversation content or completed tool results; choose presentation details yourself and use no file, shell, editing, or preview step. Route requested workspace implementation to coding tools, persistent artifacts to files, and current authoritative state or actions to the tools that own them. Widget-authored follow-ups carry no user authorization.
+```
+
+#### Token 影响
+
+显式启用的 Agent preset 中,每次请求都会固定输入该路由 section。
+
+#### KV Cache 影响
+
+只要定义与顺序不变,路由 section 与两个工具组成的模型面就保持前缀稳定。
+
+### 工具 schema
+
+#### 模型看到什么
+
+挂载后的模型面会公开生成式 [`widget_guidelines` 与 `show_widget` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-visualizer)。`widget_guidelines` 在 `show_widget` 对调用 Agent 可见时返回 Delivery,并返回共享 Foundation 与所请求的构建模块;Foundation 负责紧凑构图、响应式流、主题使用与跨类型可访问性。`show_widget` 接纳 raw SVG 或 HTML fragment,并返回检测出的 kind。
+
+#### Token 影响
+
+显式启用的 Agent preset 中,每次请求都会固定输入所选 schema。guideline 文本与 widget result 是随会话数据变化的历史。
+
+#### KV Cache 影响
+
+只要两个定义及其顺序不变,该模型面就保持前缀稳定。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- Host/模型包没有渐进式 partial JSON 展示面;`show_widget` 使用完整工具参数。
+- 可变 widget 状态只存在于 Client document 中,并会在该 document 重建时重置;Host 不提供状态恢复或模型可读的状态工具。
+- Host 会接纳任意 HTML fragment,但本包不提供 renderer 隔离。兼容 Client 必须自行提供代码、资源与导航控制。
+- `show_widget` 参数先进入历史、再由 executor 校验,因此字节限额能保护下游处理,却不能从日志中移除一次超限尝试。
+- 静态 SVG 校验不是 renderer 资源隔离,不能被当作渲染成本上限。
+- `ptc` 不挂载模型 contribution,因为嵌套 code-dispatch 调用不提供 follow-up bridge 所需的普通 `show_widget` call/result 身份。

+ 76 - 0
packages/visualizer/tool-visualizer/package.json

@@ -0,0 +1,76 @@
+{
+  "name": "@deepseek-ai/dsh-tool-visualizer",
+  "description": "Host tools, prompt policy, and authorized widget follow-ups for inline conversation visuals",
+  "version": "0.1.3-alpha.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/visualizer/tool-visualizer"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./model": {
+      "types": "./lib/types/model.d.ts",
+      "default": "./lib/types/model.js"
+    },
+    "./client": {
+      "types": "./lib/types/client.d.ts",
+      "default": "./lib/types/client.js"
+    },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "zod": "^4.4.3"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  }
+}

+ 6 - 0
packages/visualizer/tool-visualizer/src/client.ts

@@ -0,0 +1,6 @@
+/** Visualizer wire types re-exported for browser bundles. */
+
+export type {
+  WidgetKind,
+  WidgetPromptRequest,
+} from './types.ts'

+ 68 - 0
packages/visualizer/tool-visualizer/src/guidelines.ts

@@ -0,0 +1,68 @@
+/** Progressive visual guidance categories exposed to the model. */
+export const WIDGET_GUIDELINE_MODULES = [
+  'diagram', 'mockup', 'interactive', 'chart', 'illustration',
+] as const
+
+/** One progressive visual guidance category exposed to the model. */
+export type WidgetGuidelineModule = (typeof WIDGET_GUIDELINE_MODULES)[number]
+
+const DELIVERY_GUIDANCE = `## Delivery
+- Call show_widget next with the complete source; keep planning brief.`
+
+const FOUNDATION_GUIDANCE = `## Foundation
+- Theme variables available: --dsw-alias-bg-base, --dsw-alias-bg-layer-1, --dsw-alias-bg-layer-2, --dsw-alias-border-l1, --dsw-alias-border-l2, --dsw-alias-brand-primary, --dsw-alias-button-primary-fill, --dsw-alias-interactive-bg-hover, --dsw-alias-interactive-bg-active, --dsw-alias-label-primary, --dsw-alias-label-primary-inverted, --dsw-alias-label-secondary, --dsw-alias-label-tertiary, --dsw-alias-state-error-primary.
+- Use readable controls and a visual surface suited to the subject; avoid wrapping the result in an extra outer frame or giving every item its own panel.
+- Center the task's primary result or relationship; leave secondary explanation to the reply.
+- For a broad request, choose one representative scenario and leave alternatives to the reply.
+- Keep related inputs and primary results in one compact flow; reserve separate panels for distinct tasks.
+- Keep labels in the user's language.
+- Keep content inside its coordinate space or normal document flow. At narrow widths, reflow controls, legends, comparison layouts, and exact-value rows instead of clipping, shrinking labels, or adding nested scrolling; let flexible grid and flex children shrink with min-width: 0.
+- Pair color with a label, shape, or pattern.
+- Give every primary visual a concise accessible name and summary.`
+
+const GUIDELINES: Readonly<Record<WidgetGuidelineModule, string>> = Object.freeze({
+  diagram: `## Diagram
+- When using raw SVG for a diagram, begin the source with <svg>, put any styles inside it, and give it one responsive viewBox that fills the available width; do not assume a fixed card width.
+- Match layout to the relationship: use one reading direction for sequences, a cycle only when recurrence is the point, a staged state view when stages need inspection, shallow nested regions for containment, and simplified forms with outside labels for spatial structure. Keep connectors behind nodes, avoid crossings, and label relationships directly.
+- Keep node text to labels and short phrases; move sentence-level explanation to the reply. Fit labels by widening or wrapping nodes. If the relationships still cannot fit at a readable size, split the visual into overview and detail views instead of shrinking text. Use semantic groups, consistent radii and spacing, and a visible hierarchy before decoration. If geometry is illustrative rather than sourced, label it as schematic or not to scale. Do not use emoji as decoration.`,
+  mockup: `## Mockup
+- Show only the product surface needed to explain the interaction; this is an explanatory mockup, not a full application shell.
+- Keep controls clearly labelled and state changes unambiguous; use accessible native controls when interaction is required.
+- Make simulated content and actions unmistakably non-live.`,
+  interactive: `## Interactive
+- Keep the task's primary interaction or result dominant.
+- Match each input to its semantics with one primary control per value: fields for exact entry, choice controls for discrete values, and ranges only for values meant to be swept. Give every control a clear label. Native controls come styled, focusable, and keyboard-accessible; custom controls must match that.
+- Within an HTML fragment, use inspectable DOM or inline SVG elements when they can represent the scene directly; use canvas only when retaining one element per visual mark would be impractical.
+- Keep self-contained interactions and computation local; they must not call the model.
+- Use motion when change over time materially contributes to the meaning or experience. Motion may start immediately when it is central to the result; otherwise begin stable and let continuous motion follow a user action. Honor reduced-motion preferences.
+- Reuse a bounded set of DOM or SVG nodes for recurring updates; change textContent or attributes in place instead of rebuilding markup, and stop scheduling while idle, paused, or settled.
+- Keep changing numeric readouts from shifting the layout by using tabular numerals and reserving enough width for expected values.
+- Call window.dshWidget.sendPrompt(text, event) directly from a trusted click or keydown handler, at most once per event, and only when the next step genuinely needs the model. The message enters the conversation immediately, labelled as widget-authored; there is no confirmation step.
+- Recover from non-fatal errors. Keep behavior deterministic; when variation is meaningful, make it reproducible and resettable.`,
+  chart: `## Chart
+- Choose the smallest chart that exposes the relationship: bars for comparison, lines for ordered change, dots for distribution, and tables when exact lookup matters most.
+- Start quantitative axes at zero when magnitude comparison depends on it; otherwise mark the scale and domain clearly. When a wide magnitude range hides structure, consider a clearly labelled log scale, normalized view, or small multiples only when it preserves the comparison's meaning.
+- Leave room so marks, ticks, and labels do not overlap or clip. Include units, preserve meaningful precision, distinguish missing values from zero, format signs consistently, and use a legend when direct labels would collide.
+- Keep series count small and include the main finding in the accessible summary. When series become visually indistinguishable, use a consolidated or direct-difference view if separate marks no longer carry meaning.`,
+  illustration: `## Illustration
+- Use illustration only when its visual form carries information that prose does not.
+- Derive the visual language from the subject and requested tone, with one clear focal point.
+- An illustration may be still or animated. Combine it with Interactive when motion or manipulation contributes to the presentation.
+- Use canvas for dense or procedurally repeated imagery; use raw SVG when a modest set of inspectable shapes is enough.`,
+})
+
+/**
+ * Compose requested modules once each, preserving caller order.
+ * @param modules - Visual guidance categories requested by the model.
+ * @param includeDelivery - Whether the caller can use the delivery tool.
+ * @returns The selected guidance sections joined in caller order.
+ */
+export function composeGuidelines(
+  modules: readonly WidgetGuidelineModule[],
+  includeDelivery: boolean,
+): string {
+  const selected = [...new Set(modules)].map(module => GUIDELINES[module])
+  return [includeDelivery ? DELIVERY_GUIDANCE : undefined, FOUNDATION_GUIDANCE, ...selected]
+    .filter(section => section !== undefined)
+    .join('\n\n')
+}

+ 94 - 0
packages/visualizer/tool-visualizer/src/index.ts

@@ -0,0 +1,94 @@
+/** Host Visualizer authority and recoverable preset model-surface installer. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { scopeOf } from '@deepseek-ai/dsh-scope'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { visualizerSystemPrompt } from './prompt.ts'
+import { RecordedWidgetCalls } from './recorded-calls.ts'
+import { registerVisualizerTools } from './tools.ts'
+import type { WidgetPromptRequest } from './types.ts'
+
+export type * from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    visualizer: VisualizerService
+  }
+}
+
+const MAX_TITLE_BYTES = 256
+const MAX_WIDGET_BYTES = 131_072
+const MAX_PROMPT_BYTES = 4_096
+const MAX_PROMPTS_PER_MINUTE_PER_AGENT = 4
+
+/** One Host service owns every authority-bearing part of the Visualizer extension. */
+export class VisualizerService extends TypertRemoteService {
+  static inject = ['agents']
+
+  private readonly rootCtx: Context
+  private readonly recordedCalls: RecordedWidgetCalls
+
+  constructor(ctx: Context) {
+    super(ctx, 'visualizer')
+    this.rootCtx = ctx
+    this.recordedCalls = new RecordedWidgetCalls(
+      MAX_PROMPT_BYTES,
+      MAX_PROMPTS_PER_MINUTE_PER_AGENT,
+    )
+  }
+
+  /**
+   * Install the model-facing Visualizer contribution in the caller's preset scope.
+   * @param modelCtx - Preset-scoped context that owns the prompt and tool effects.
+   */
+  installModelSurface(modelCtx: Context): void {
+    if (scopeOf(modelCtx) === undefined || modelCtx.agent !== undefined) {
+      throw new Error('tool-visualizer: the model contribution requires a scoped context without an Agent')
+    }
+    modelCtx.systemPrompt.section({
+      name: 'tool:visualizer',
+      order: modelCtx.systemPrompt.getSectionOrder('TOOL_VISUALIZER'),
+      text: context => modelCtx.tools.get('show_widget', context.scope) === undefined
+        ? ''
+        : visualizerSystemPrompt(),
+    })
+    registerVisualizerTools(modelCtx, {
+      maxTitleBytes: MAX_TITLE_BYTES,
+      maxWidgetBytes: MAX_WIDGET_BYTES,
+    })
+  }
+
+  /**
+   * Queue a widget-authored message after Host-side authorization and rate limiting.
+   * @param agent - Exact live Agent resolved from the Remote session scope.
+   * @param request - Follow-up for one successful interactive widget call.
+   */
+  @Remote('sendPrompt')
+  remoteSendPrompt(agent: Agent, request: WidgetPromptRequest): void {
+    let text: string
+    try {
+      this.requireLiveAgent(agent)
+      text = this.recordedCalls.authorizePrompt(agent, request)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      throw new RemoteError('gateway/bad-request', message, {}, { cause: error })
+    }
+    agent.followup(createUserMessage({
+      content: [{
+        type: 'text',
+        text,
+      }],
+      source: { kind: 'plugin', plugin: 'visualizer' },
+    }))
+  }
+
+  private requireLiveAgent(agent: Agent): void {
+    if (this.rootCtx.agents.get(agent.id) !== agent) {
+      throw new Error('visualizer: Remote request does not target the exact live Agent')
+    }
+  }
+}
+
+export default VisualizerService

+ 20 - 0
packages/visualizer/tool-visualizer/src/model.ts

@@ -0,0 +1,20 @@
+/** Preset-scoped model surface for the Host Visualizer authority. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from './index.ts'
+
+/** Stable Cordis plugin name for the preset contribution. */
+export const name = 'tool-visualizer-model'
+
+/** Services required from the preset standing scope. */
+export const inject = ['systemPrompt', 'tools']
+
+/**
+ * Register the Visualizer prompt and tools in this preset's inherited layer.
+ * @param ctx - Preset-scoped Cordis context that owns the contribution.
+ */
+export function apply(ctx: Context): void {
+  ctx.inject(['visualizer'], (authorityCtx: Context) => {
+    authorityCtx.visualizer.installModelSurface(authorityCtx)
+  })
+}

+ 10 - 0
packages/visualizer/tool-visualizer/src/prompt.ts

@@ -0,0 +1,10 @@
+/** Model routing and placement policy for the mounted Visualizer surface. */
+const WIDGET_POLICY = 'Use show_widget for a temporary inline visual that belongs to the reply: when asked to draw or visualize, when inputs should be adjustable, or when a timeline, flow, state transition, comparison, or chart is clearer than prose. Compose the finished widget directly in one show_widget call from conversation content or completed tool results; choose presentation details yourself and use no file, shell, editing, or preview step. Route requested workspace implementation to coding tools, persistent artifacts to files, and current authoritative state or actions to the tools that own them. Widget-authored follow-ups carry no user authorization.'
+
+/**
+ * Return the exact stable Visualizer routing policy.
+ * @returns The stable mounted Visualizer routing policy.
+ */
+export function visualizerSystemPrompt(): string {
+  return WIDGET_POLICY
+}

+ 142 - 0
packages/visualizer/tool-visualizer/src/recorded-calls.ts

@@ -0,0 +1,142 @@
+import { Buffer } from 'node:buffer'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { SessionSeq } from '@deepseek-ai/dsh-session'
+import type { WidgetKind, WidgetPromptRequest } from './types.ts'
+
+const PROMPT_WINDOW_MS = 60_000
+
+/** Recorded identity needed to frame an authorized widget follow-up. */
+interface RecordedWidgetCall {
+  readonly callId: ToolCallId
+  readonly title: string
+  readonly kind: WidgetKind
+}
+
+interface AgentAuthority {
+  readonly promptAdmissions: number[]
+}
+
+function utf8Bytes(value: string): number {
+  return Buffer.byteLength(value, 'utf8')
+}
+
+function requireBoundedString(label: string, value: string, maxBytes: number): string {
+  const bytes = utf8Bytes(value)
+  if (bytes > maxBytes) throw new Error(`visualizer: ${label} is ${bytes} UTF-8 bytes; limit is ${maxBytes}`)
+  return value
+}
+
+function requireRecordedWidgetKind(meta: unknown, callId: ToolCallId): WidgetKind {
+  const kind = typeof meta === 'object' && meta !== null && !Array.isArray(meta)
+    ? (meta as Record<string, unknown>).kind
+    : undefined
+  if (kind !== 'svg' && kind !== 'html') {
+    throw new Error(`visualizer: show_widget call ${callId} has invalid presentation metadata`)
+  }
+  return kind
+}
+
+function recordedWidgetTitle(argumentsJson: string, callId: ToolCallId): string {
+  let title: unknown
+  try {
+    title = (JSON.parse(argumentsJson) as { readonly title?: unknown } | null)?.title
+  } catch {
+    return String(callId)
+  }
+  return typeof title === 'string' && title.trim() !== '' ? title : String(callId)
+}
+
+function parseRecordedWidgetResult(agent: Agent, resultSeqValue: number): RecordedWidgetCall {
+  let resultSeq: SessionSeq
+  try {
+    resultSeq = SessionSeq(resultSeqValue)
+  } catch {
+    throw new Error('visualizer: resultSeq must be a non-negative safe integer')
+  }
+  const resultEvent = agent.session.eventAt(resultSeq)
+  if (resultEvent?.type !== 'tool/result' || resultEvent.surfaceOp !== 'append') {
+    throw new Error(`visualizer: resultSeq ${resultSeq} does not identify a recorded appended tool result`)
+  }
+  if (resultEvent.sourceEventSeqs?.length !== 1) {
+    throw new Error(`visualizer: tool result ${resultSeq} does not identify one exact source call`)
+  }
+  const sourceSeq = resultEvent.sourceEventSeqs[0]
+  if (sourceSeq === undefined) {
+    throw new Error(`visualizer: tool result ${resultSeq} does not identify one exact source call`)
+  }
+  const callEvent = agent.session.eventAt(sourceSeq)
+  if (callEvent?.type !== 'tool/call' || callEvent.data.name !== 'show_widget'
+    || callEvent.data.turn !== resultEvent.data.turn || callEvent.data.step !== resultEvent.data.step) {
+    throw new Error(`visualizer: tool result ${resultSeq} is not linked to a show_widget call in the same turn and step`)
+  }
+  const result = resultEvent.data.message.content[0]
+  if (result.toolCallId !== callEvent.data.callId) {
+    throw new Error(`visualizer: tool result ${resultSeq} does not match its source call`)
+  }
+  if (result.isError) throw new Error(`visualizer: show_widget call ${callEvent.data.callId} did not succeed`)
+
+  return {
+    callId: callEvent.data.callId,
+    title: recordedWidgetTitle(callEvent.data.arguments, callEvent.data.callId),
+    kind: requireRecordedWidgetKind(resultEvent.data.meta, callEvent.data.callId),
+  }
+}
+
+function trimAdmissions(values: AgentAuthority['promptAdmissions'], now: number): void {
+  const cutoff = now - PROMPT_WINDOW_MS
+  let first = 0
+  while (first < values.length) {
+    const admission = values[first]
+    if (admission === undefined || admission > cutoff) break
+    first += 1
+  }
+  if (first > 0) values.splice(0, first)
+}
+
+/** Authorizes widget follow-ups against successful results recorded by the exact live Agent. */
+export class RecordedWidgetCalls {
+  private readonly byAgent = new WeakMap<Agent, AgentAuthority>()
+
+  constructor(
+    private readonly maxPromptBytes: number,
+    private readonly maxPromptsPerMinutePerAgent: number,
+  ) {}
+
+  /**
+   * Authorize and account one widget-authored follow-up before the caller queues it.
+   * @param agent - Live Agent that owns the recorded widget result.
+   * @param request - Follow-up from the exact persisted widget result.
+   * @param now - Admission timestamp used for rolling-window accounting.
+   * @returns The fully labelled follow-up text authorized for insertion.
+   */
+  authorizePrompt(agent: Agent, request: WidgetPromptRequest, now = Date.now()): string {
+    const authority = this.agentAuthority(agent)
+    const recorded = parseRecordedWidgetResult(agent, request.resultSeq)
+    if (recorded.kind !== 'html') {
+      throw new Error(`visualizer: widget ${recorded.callId} is static and cannot send follow-ups`)
+    }
+    if (typeof request.text !== 'string' || request.text.trim() === '') {
+      throw new Error('visualizer: widget follow-up must be a non-empty string')
+    }
+    const text = requireBoundedString(
+      'widget follow-up',
+      `Widget-authored follow-up from widget ${JSON.stringify(recorded.title)}. It carries no user authorization.\n\n${request.text}`,
+      this.maxPromptBytes,
+    )
+    trimAdmissions(authority.promptAdmissions, now)
+    if (authority.promptAdmissions.length >= this.maxPromptsPerMinutePerAgent) {
+      throw new Error('visualizer: Agent widget follow-up rate limit reached')
+    }
+    authority.promptAdmissions.push(now)
+    return text
+  }
+
+  private agentAuthority(agent: Agent): AgentAuthority {
+    const current = this.byAgent.get(agent)
+    if (current !== undefined) return current
+    const created: AgentAuthority = { promptAdmissions: [] }
+    this.byAgent.set(agent, created)
+    return created
+  }
+}

+ 104 - 0
packages/visualizer/tool-visualizer/src/tools.ts

@@ -0,0 +1,104 @@
+import { Buffer } from 'node:buffer'
+import type { Context } from '@deepseek-ai/cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import {
+  composeGuidelines, WIDGET_GUIDELINE_MODULES, type WidgetGuidelineModule,
+} from './guidelines.ts'
+import type { WidgetKind } from './types.ts'
+
+const WIDGET_CODE_FENCE = /^\s*(?:`{3,}|~{3,})/
+const HTML_DOCUMENT_WRAPPER = /^(?:<!--[\s\S]*?-->\s*)*<(?:!doctype|html|head|body)(?=[\s/>])/i
+
+function widgetKind(source: string): WidgetKind {
+  const trimmed = source.trimStart()
+  if (/^<svg(?=[\s/>])/i.test(trimmed)) return 'svg'
+  return 'html'
+}
+
+function validateWidgetArgs(
+  title: string,
+  source: string,
+  limits: { readonly maxTitleBytes: number; readonly maxWidgetBytes: number },
+): WidgetKind {
+  if (title.trim() === '') throw new Error('visualizer: title must be non-empty')
+  const titleBytes = Buffer.byteLength(title, 'utf8')
+  if (titleBytes > limits.maxTitleBytes) {
+    throw new Error(`visualizer: title is ${titleBytes} UTF-8 bytes; limit is ${limits.maxTitleBytes}`)
+  }
+  const bytes = Buffer.byteLength(source, 'utf8')
+  if (source.trim() === '') throw new Error('visualizer: widget_code must be non-empty')
+  if (bytes > limits.maxWidgetBytes) {
+    throw new Error(`visualizer: widget_code is ${bytes} UTF-8 bytes; limit is ${limits.maxWidgetBytes}`)
+  }
+  if (WIDGET_CODE_FENCE.test(source)) {
+    throw new Error('visualizer: widget_code must be raw source without Markdown code fences')
+  }
+  const trimmed = source.trimStart()
+  if (HTML_DOCUMENT_WRAPPER.test(trimmed)) {
+    throw new Error('visualizer: widget_code must be a fragment without document wrappers')
+  }
+  return widgetKind(source)
+}
+
+/**
+ * Register the stable model-visible Visualizer surface.
+ * @param ctx - Host Cordis context that owns the Tool contributions.
+ * @param limits - Host limits applied to model-provided widget arguments.
+ */
+export function registerVisualizerTools(
+  ctx: Context,
+  limits: { readonly maxTitleBytes: number; readonly maxWidgetBytes: number },
+): void {
+  const sourceDescription = 'For HTML: one fragment. Put any inline <style> first, content next (semantic HTML, inline <svg>, <canvas> using 2D or WebGL, or native controls), and any inline <script> last. For SVG: raw SVG beginning with <svg>, with a viewBox that declares its aspect ratio. Keep the widget self-contained: include all required styles, scripts, data, and assets in the source.'
+
+  ctx.tools.register(defineTool({
+    name: 'widget_guidelines',
+    description: 'Load request-matched construction guidance for a widget response.',
+    parameters: {
+      modules: {
+        type: 'array',
+        required: true,
+        items: { type: 'string', enum: WIDGET_GUIDELINE_MODULES },
+        description: 'Choose every relevant module; content and behavior are independent, so combine modules when useful. diagram: nodes and relationships. chart: quantitative data. illustration: a scene or image. interactive: motion that contributes to the presentation, or direct manipulation and adjustable inputs requested by the user. mockup: a product surface shown to explain one interaction.',
+      },
+    },
+    output: {
+      schema: { type: 'string' },
+      render: (_args, value) => [{ type: 'text', text: value }],
+    },
+    isConcurrencySafe: () => true,
+    execute(args, exec): Promise<string> {
+      const canShowWidget = ctx.tools.get('show_widget', exec.agent) !== undefined
+      return Promise.resolve(composeGuidelines(args.modules as readonly WidgetGuidelineModule[], canShowWidget))
+    },
+    presentCall: args => ({ card: 'generic', kind: 'read', title: `Load widget guidance: ${args.modules.join(', ')}` }),
+  }))
+
+  ctx.tools.register(defineTool({
+    name: 'show_widget',
+    description: 'Render one temporary inline visual from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.',
+    parameters: {
+      title: { type: 'string', required: true, description: 'Short user-facing title in the user\'s language.' },
+      widget_code: { type: 'string', required: true, description: sourceDescription },
+    },
+    output: {
+      schema: {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          kind: { type: 'string', required: true, enum: ['svg', 'html'] },
+        },
+      },
+      render: (args, value) => [{
+        type: 'text',
+        text: `Widget "${args.title}" accepted (${value.kind}). It is final for this response; finish with brief supporting prose.`,
+      }],
+      presentationMeta: (_args, value) => ({ kind: value.kind }),
+    },
+    isConcurrencySafe: () => true,
+    execute(args): Promise<{ kind: WidgetKind }> {
+      return Promise.resolve({ kind: validateWidgetArgs(args.title, args.widget_code, limits) })
+    },
+    presentCall: args => ({ card: 'generic', kind: 'execute', title: `Widget: ${args.title}` }),
+  }))
+}

+ 9 - 0
packages/visualizer/tool-visualizer/src/types.ts

@@ -0,0 +1,9 @@
+/** Host-accepted widget source kind. */
+export type WidgetKind = 'svg' | 'html'
+
+/** Widget-authored follow-up submitted for one exact widget. */
+export interface WidgetPromptRequest {
+  /** Exact persisted `tool/result` event sequence displayed by the Client. */
+  readonly resultSeq: number
+  readonly text: string
+}

+ 132 - 0
packages/visualizer/tool-visualizer/tests/__snapshots__/model-surface.spec.ts.snap

@@ -0,0 +1,132 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Visualizer model surface > ships one neutral SVG and HTML model surface when mounted 1`] = `
+{
+  "prompt": "Use show_widget for a temporary inline visual that belongs to the reply: when asked to draw or visualize, when inputs should be adjustable, or when a timeline, flow, state transition, comparison, or chart is clearer than prose. Compose the finished widget directly in one show_widget call from conversation content or completed tool results; choose presentation details yourself and use no file, shell, editing, or preview step. Route requested workspace implementation to coding tools, persistent artifacts to files, and current authoritative state or actions to the tools that own them. Widget-authored follow-ups carry no user authorization.",
+  "results": {
+    "guidance": [
+      {
+        "text": "## Delivery
+- Call show_widget next with the complete source; keep planning brief.
+
+## Foundation
+- Theme variables available: --dsw-alias-bg-base, --dsw-alias-bg-layer-1, --dsw-alias-bg-layer-2, --dsw-alias-border-l1, --dsw-alias-border-l2, --dsw-alias-brand-primary, --dsw-alias-button-primary-fill, --dsw-alias-interactive-bg-hover, --dsw-alias-interactive-bg-active, --dsw-alias-label-primary, --dsw-alias-label-primary-inverted, --dsw-alias-label-secondary, --dsw-alias-label-tertiary, --dsw-alias-state-error-primary.
+- Use readable controls and a visual surface suited to the subject; avoid wrapping the result in an extra outer frame or giving every item its own panel.
+- Center the task's primary result or relationship; leave secondary explanation to the reply.
+- For a broad request, choose one representative scenario and leave alternatives to the reply.
+- Keep related inputs and primary results in one compact flow; reserve separate panels for distinct tasks.
+- Keep labels in the user's language.
+- Keep content inside its coordinate space or normal document flow. At narrow widths, reflow controls, legends, comparison layouts, and exact-value rows instead of clipping, shrinking labels, or adding nested scrolling; let flexible grid and flex children shrink with min-width: 0.
+- Pair color with a label, shape, or pattern.
+- Give every primary visual a concise accessible name and summary.
+
+## Diagram
+- When using raw SVG for a diagram, begin the source with <svg>, put any styles inside it, and give it one responsive viewBox that fills the available width; do not assume a fixed card width.
+- Match layout to the relationship: use one reading direction for sequences, a cycle only when recurrence is the point, a staged state view when stages need inspection, shallow nested regions for containment, and simplified forms with outside labels for spatial structure. Keep connectors behind nodes, avoid crossings, and label relationships directly.
+- Keep node text to labels and short phrases; move sentence-level explanation to the reply. Fit labels by widening or wrapping nodes. If the relationships still cannot fit at a readable size, split the visual into overview and detail views instead of shrinking text. Use semantic groups, consistent radii and spacing, and a visible hierarchy before decoration. If geometry is illustrative rather than sourced, label it as schematic or not to scale. Do not use emoji as decoration.
+
+## Mockup
+- Show only the product surface needed to explain the interaction; this is an explanatory mockup, not a full application shell.
+- Keep controls clearly labelled and state changes unambiguous; use accessible native controls when interaction is required.
+- Make simulated content and actions unmistakably non-live.
+
+## Interactive
+- Keep the task's primary interaction or result dominant.
+- Match each input to its semantics with one primary control per value: fields for exact entry, choice controls for discrete values, and ranges only for values meant to be swept. Give every control a clear label. Native controls come styled, focusable, and keyboard-accessible; custom controls must match that.
+- Within an HTML fragment, use inspectable DOM or inline SVG elements when they can represent the scene directly; use canvas only when retaining one element per visual mark would be impractical.
+- Keep self-contained interactions and computation local; they must not call the model.
+- Use motion when change over time materially contributes to the meaning or experience. Motion may start immediately when it is central to the result; otherwise begin stable and let continuous motion follow a user action. Honor reduced-motion preferences.
+- Reuse a bounded set of DOM or SVG nodes for recurring updates; change textContent or attributes in place instead of rebuilding markup, and stop scheduling while idle, paused, or settled.
+- Keep changing numeric readouts from shifting the layout by using tabular numerals and reserving enough width for expected values.
+- Call window.dshWidget.sendPrompt(text, event) directly from a trusted click or keydown handler, at most once per event, and only when the next step genuinely needs the model. The message enters the conversation immediately, labelled as widget-authored; there is no confirmation step.
+- Recover from non-fatal errors. Keep behavior deterministic; when variation is meaningful, make it reproducible and resettable.
+
+## Chart
+- Choose the smallest chart that exposes the relationship: bars for comparison, lines for ordered change, dots for distribution, and tables when exact lookup matters most.
+- Start quantitative axes at zero when magnitude comparison depends on it; otherwise mark the scale and domain clearly. When a wide magnitude range hides structure, consider a clearly labelled log scale, normalized view, or small multiples only when it preserves the comparison's meaning.
+- Leave room so marks, ticks, and labels do not overlap or clip. Include units, preserve meaningful precision, distinguish missing values from zero, format signs consistently, and use a legend when direct labels would collide.
+- Keep series count small and include the main finding in the accessible summary. When series become visually indistinguishable, use a consolidated or direct-difference view if separate marks no longer carry meaning.
+
+## Illustration
+- Use illustration only when its visual form carries information that prose does not.
+- Derive the visual language from the subject and requested tone, with one clear focal point.
+- An illustration may be still or animated. Combine it with Interactive when motion or manipulation contributes to the presentation.
+- Use canvas for dense or procedurally repeated imagery; use raw SVG when a modest set of inspectable shapes is enough.",
+        "type": "text",
+      },
+    ],
+    "show_widget": [
+      {
+        "text": "Widget "Snapshot" accepted (html). It is final for this response; finish with brief supporting prose.",
+        "type": "text",
+      },
+    ],
+  },
+  "tools": {
+    "show_widget": {
+      "description": "Render one temporary inline visual from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.",
+      "output": {
+        "additionalProperties": false,
+        "properties": {
+          "kind": {
+            "enum": [
+              "svg",
+              "html",
+            ],
+            "type": "string",
+          },
+        },
+        "required": [
+          "kind",
+        ],
+        "type": "object",
+      },
+      "parameters": {
+        "properties": {
+          "title": {
+            "description": "Short user-facing title in the user's language.",
+            "type": "string",
+          },
+          "widget_code": {
+            "description": "For HTML: one fragment. Put any inline <style> first, content next (semantic HTML, inline <svg>, <canvas> using 2D or WebGL, or native controls), and any inline <script> last. For SVG: raw SVG beginning with <svg>, with a viewBox that declares its aspect ratio. Keep the widget self-contained: include all required styles, scripts, data, and assets in the source.",
+            "type": "string",
+          },
+        },
+        "required": [
+          "title",
+          "widget_code",
+        ],
+        "type": "object",
+      },
+    },
+    "widget_guidelines": {
+      "description": "Load request-matched construction guidance for a widget response.",
+      "output": {
+        "type": "string",
+      },
+      "parameters": {
+        "properties": {
+          "modules": {
+            "description": "Choose every relevant module; content and behavior are independent, so combine modules when useful. diagram: nodes and relationships. chart: quantitative data. illustration: a scene or image. interactive: motion that contributes to the presentation, or direct manipulation and adjustable inputs requested by the user. mockup: a product surface shown to explain one interaction.",
+            "items": {
+              "enum": [
+                "diagram",
+                "mockup",
+                "interactive",
+                "chart",
+                "illustration",
+              ],
+              "type": "string",
+            },
+            "type": "array",
+          },
+        },
+        "required": [
+          "modules",
+        ],
+        "type": "object",
+      },
+    },
+  },
+}
+`;

+ 203 - 0
packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts

@@ -0,0 +1,203 @@
+/** REAL-composition proof for Host authority and the preset model contribution. */
+
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { createScope } from '@deepseek-ai/dsh-scope'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { Session } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import VisualizerService from '../src/index.ts'
+import * as VisualizerModel from '../src/model.ts'
+
+const REPOSITORY_ROOT = fileURLToPath(new URL('../../../../', import.meta.url))
+const bundlePatch = (name: 'base' | 'headless') => loadOverlayPatches(
+  'visualizer composition test',
+  join(REPOSITORY_ROOT, 'packages', 'bundle', name, 'cordis.patch.yml'),
+)
+
+const contexts: Context[] = []
+const roots: string[] = []
+
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(async (ctx) => { await ctx.fiber.dispose() }))
+  await Promise.all(roots.splice(0).map(async (root) => { await rm(root, { recursive: true, force: true }) }))
+})
+
+async function boot(rows: string[]): Promise<{ ctx: Context; imports: string[] }> {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-visualizer-composition-'))
+  roots.push(root)
+  const configPath = join(root, 'cordis.yml')
+  await writeFile(configPath, `${rows.join('\n')}\n`)
+  const ctx = new Context()
+  contexts.push(ctx)
+  ctx.baseUrl = pathToFileURL(root).href + '/'
+  await ctx.plugin(Loader)
+  ctx.loader.builtins.include = Include
+  const imports: string[] = []
+  const modules = new Map<string, unknown>([
+    ['@deepseek-ai/dsh-agent', AgentRegistry],
+    ['@deepseek-ai/dsh-system-prompt', SystemPrompt],
+    ['@deepseek-ai/dsh-tools', ToolRuntime],
+    ['@deepseek-ai/dsh-tool-visualizer', VisualizerService],
+    ['@deepseek-ai/dsh-tool-visualizer/model', VisualizerModel],
+  ])
+  ctx.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      imports.push(specifier)
+      if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+      return modules.get(specifier)
+    },
+  } as unknown as NonNullable<typeof ctx.loader.internal>
+  await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
+  await ctx.loader.await()
+  return { ctx, imports }
+}
+
+async function scopedBase(ctx: Context): Promise<Context> {
+  let base: Context | undefined
+  await ctx.plugin(Object.assign((inner: Context) => { base = inner }, { inject: ['systemPrompt', 'tools'] }))
+  if (base === undefined) throw new Error('missing scoped test base')
+  return base
+}
+
+async function mountPresetModel(ctx: Context) {
+  const base = await scopedBase(ctx)
+  const presetKey = { agentPreset: 'standard' }
+  const presetScope = createScope(base, presetKey)
+  const presetRoot = await mkdtemp(join(tmpdir(), 'dsh-visualizer-preset-row-'))
+  roots.push(presetRoot)
+  const composition = join(presetRoot, 'agent.cordis.yml')
+  await writeFile(composition, [
+    '- id: tool-visualizer',
+    "  name: '@deepseek-ai/dsh-tool-visualizer/model'",
+    '',
+  ].join('\n'))
+  const modelTree = presetScope.ctx.plugin(Include, { path: pathToFileURL(composition).href })
+  await modelTree.await()
+
+  return { base, presetKey }
+}
+
+function registerPresetAgent(
+  ctx: Context,
+  preset: Awaited<ReturnType<typeof mountPresetModel>>,
+  id: string,
+) {
+  const session = Session.create(SessionId(id))
+  const agent = { id: session.id, session, ctx } as unknown as Agent
+  const agentScope = createScope(preset.base, agent, { parent: preset.presetKey })
+  Object.assign(agent, { ctx: agentScope.ctx.extend({ agent }) })
+  ctx.agents.register(agent)
+  return agent
+}
+
+async function expectVisualizerSurface(ctx: Context, agent: Agent, visible: boolean): Promise<void> {
+  await vi.waitFor(async () => {
+    expect(ctx.tools.schemas(agent).map(schema => schema.name).sort())
+      .toEqual(visible ? ['show_widget', 'widget_guidelines'] : [])
+    const prompt = await ctx.systemPrompt.assemble({ agent, scope: agent })
+    expect(prompt.sections.some(section => section.name === 'tool:visualizer')).toBe(visible)
+  })
+}
+
+describe('Visualizer Host composition', () => {
+  it('keeps headless dormant and mounts the model wrapper only in full presets', () => {
+    const headlessEntries = composeEntries([bundlePatch('base'), bundlePatch('headless')])
+    expect(headlessEntries.some(entry => entry.name === '@deepseek-ai/dsh-tool-visualizer')).toBe(false)
+
+    const presetModelRows = (id: 'standard' | 'minimal' | 'ptc' | 'cordis') => loadOverlayPatches(
+      'visualizer preset composition test',
+      join(REPOSITORY_ROOT, 'packages/preset/agent-presets/presets', id, 'agent.cordis.yml'),
+    ).filter(entry => entry.name === '@deepseek-ai/dsh-tool-visualizer/model')
+    for (const id of ['standard', 'cordis'] as const) {
+      const rows = presetModelRows(id)
+      expect(rows).toHaveLength(1)
+      expect(rows[0]?.disabled).toBeUndefined()
+    }
+    expect(presetModelRows('ptc')).toEqual([])
+    expect(presetModelRows('minimal')).toEqual([])
+  })
+
+  it('boots the test-only Host and model through Loader without global model state', async () => {
+    const { ctx, imports } = await boot([
+      "- id: agents\n  name: '@deepseek-ai/dsh-agent'",
+      "- id: system-prompt\n  name: '@deepseek-ai/dsh-system-prompt'",
+      "- id: tools\n  name: '@deepseek-ai/dsh-tools'",
+      "- id: tool-visualizer\n  name: '@deepseek-ai/dsh-tool-visualizer'",
+    ])
+    expect(ctx.tools.schemas()).toEqual([])
+    expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'tool:visualizer'))
+      .toBe(false)
+
+    const preset = await mountPresetModel(ctx)
+    const agent = registerPresetAgent(ctx, preset, 'test-standard')
+    expect(imports).toContain('@deepseek-ai/dsh-tool-visualizer')
+    expect(imports).toContain('@deepseek-ai/dsh-tool-visualizer/model')
+    expect(ctx.tools.schemas(agent).map(schema => schema.name).sort())
+      .toEqual(['show_widget', 'widget_guidelines'])
+    const prompt = await ctx.systemPrompt.assemble({ agent, scope: agent })
+    expect(prompt.sections.find(section => section.name === 'tool:visualizer')?.text)
+      .toContain('Use show_widget for a temporary inline visual')
+  })
+
+  it('loads a dormant model wrapper without Host authority', async () => {
+    const { ctx, imports } = await boot([
+      "- id: agents\n  name: '@deepseek-ai/dsh-agent'",
+      "- id: system-prompt\n  name: '@deepseek-ai/dsh-system-prompt'",
+      "- id: tools\n  name: '@deepseek-ai/dsh-tools'",
+    ])
+    const preset = await mountPresetModel(ctx)
+    const agent = registerPresetAgent(ctx, preset, 'dormant-standard')
+
+    expect(imports).toContain('@deepseek-ai/dsh-tool-visualizer/model')
+    expect(ctx.get('visualizer')).toBeUndefined()
+    expect(ctx.tools.schemas()).toEqual([])
+    await expectVisualizerSurface(ctx, agent, false)
+  })
+
+  it('activates, withdraws, and reactivates the wrapper for existing and new Agents', async () => {
+    const { ctx } = await boot([
+      "- id: agents\n  name: '@deepseek-ai/dsh-agent'",
+      "- id: system-prompt\n  name: '@deepseek-ai/dsh-system-prompt'",
+      "- id: tools\n  name: '@deepseek-ai/dsh-tools'",
+    ])
+    const preset = await mountPresetModel(ctx)
+    const beforeAuthority = registerPresetAgent(ctx, preset, 'before-authority')
+    await expectVisualizerSurface(ctx, beforeAuthority, false)
+
+    const firstAuthorityId = await ctx.loader.create({ name: '@deepseek-ai/dsh-tool-visualizer' })
+    await ctx.loader.await()
+    await expectVisualizerSurface(ctx, beforeAuthority, true)
+
+    const duringAuthority = registerPresetAgent(ctx, preset, 'during-authority')
+    await expectVisualizerSurface(ctx, duringAuthority, true)
+
+    await ctx.loader.remove(firstAuthorityId)
+    await ctx.loader.await()
+    await expectVisualizerSurface(ctx, beforeAuthority, false)
+    await expectVisualizerSurface(ctx, duringAuthority, false)
+
+    const betweenAuthorities = registerPresetAgent(ctx, preset, 'between-authorities')
+    await expectVisualizerSurface(ctx, betweenAuthorities, false)
+
+    await ctx.loader.create({ name: '@deepseek-ai/dsh-tool-visualizer' })
+    await ctx.loader.await()
+    await expectVisualizerSurface(ctx, beforeAuthority, true)
+    await expectVisualizerSurface(ctx, duringAuthority, true)
+    await expectVisualizerSurface(ctx, betweenAuthorities, true)
+
+    const afterReactivation = registerPresetAgent(ctx, preset, 'after-reactivation')
+    await expectVisualizerSurface(ctx, afterReactivation, true)
+  })
+})

+ 474 - 0
packages/visualizer/tool-visualizer/tests/model-surface.spec.ts

@@ -0,0 +1,474 @@
+import { Buffer } from 'node:buffer'
+import { Context } from '@deepseek-ai/cordis'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
+import { createScope, scopeOf, type ScopeKey } from '@deepseek-ai/dsh-scope'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { describe, expect, it, vi } from 'vitest'
+import * as VisualizerClient from '../src/client.ts'
+import VisualizerService from '../src/index.ts'
+import * as VisualizerModel from '../src/model.ts'
+import { visualizerSystemPrompt } from '../src/prompt.ts'
+
+const SIGNAL = new AbortController().signal
+const SCOPED_BASES = new WeakMap<Context, Context>()
+
+async function captureScopedBase(ctx: Context): Promise<void> {
+  await ctx.plugin(Object.assign((inner: Context) => {
+    SCOPED_BASES.set(ctx, inner)
+  }, { inject: ['systemPrompt', 'tools'] }))
+}
+
+async function mount() {
+  const ctx = new Context()
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRuntime)
+  const authorityFiber = await ctx.plugin(VisualizerService)
+  await captureScopedBase(ctx)
+  const base = SCOPED_BASES.get(ctx)
+  if (base === undefined) throw new Error('missing scoped test base')
+  const presetKey: ScopeKey = { agentPreset: 'surface' }
+  const presetScope = createScope(base, presetKey)
+  const modelFiber = await presetScope.ctx.plugin(VisualizerModel)
+  const agent = recordedAgent(ctx, 'surface-agent', undefined, undefined, presetKey)
+  ctx.agents.register(agent)
+  return { ctx, authorityFiber, modelFiber, presetScope, agent }
+}
+
+async function modelFace(ctx: Context, agent: Agent) {
+  const prompt = await ctx.systemPrompt.assemble({ agent, scope: agent })
+  const names = ['widget_guidelines', 'show_widget']
+    .filter(name => ctx.tools.get(name, agent) !== undefined)
+  const guidance = await ctx.tools.execute({
+    signal: SIGNAL,
+    callId: ToolCallId('snapshot-guidance'),
+    name: 'widget_guidelines',
+    arguments: { modules: ['diagram', 'mockup', 'interactive', 'chart', 'illustration'] },
+    agent,
+  })
+  const receipt = await ctx.tools.execute({
+    signal: SIGNAL,
+    callId: ToolCallId('snapshot-widget'),
+    name: 'show_widget',
+    arguments: {
+      title: 'Snapshot',
+      widget_code: '<button>Choose</button>',
+    },
+    agent,
+  })
+  return {
+    prompt: prompt.sections.find(section => section.name === 'tool:visualizer')?.text,
+    tools: Object.fromEntries(names.map((name) => {
+      const tool = ctx.tools.get(name, agent)
+      if (tool === undefined) throw new Error(`missing Visualizer tool ${name}`)
+      return [name, { description: tool.description, parameters: tool.parameters, output: tool.output.schema }]
+    })),
+    results: { guidance: guidance.content, show_widget: receipt.content },
+  }
+}
+
+function recordedAgent(
+  ctx: Context,
+  id: string,
+  followup: Agent['followup'] = vi.fn<Agent['followup']>(),
+  source = '<button>Choose</button><script></script>',
+  presetParent?: ScopeKey,
+): Agent {
+  const session = Session.create(SessionId(id))
+  session.append('turn/start', { turn: 1 })
+  session.append('step/start', { turn: 1, step: 1 })
+  const call = session.append('tool/call', {
+    turn: 1,
+    step: 1,
+    callId: ToolCallId('widget'),
+    name: 'show_widget',
+    arguments: JSON.stringify({ title: 'Choice', widget_code: source }),
+  })
+  session.append('tool/result', {
+    turn: 1,
+    step: 1,
+    message: createToolResultMessage({ callId: ToolCallId('widget'), content: [], isError: false }),
+    meta: { kind: /^<svg(?=[\s/>])/i.test(source.trimStart()) ? 'svg' : 'html' },
+  }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
+  const agent = { id: session.id, session, ctx, followup } as unknown as Agent
+  const scope = createScope(
+    SCOPED_BASES.get(ctx) ?? ctx,
+    agent,
+    presetParent === undefined ? undefined : { parent: presetParent },
+  )
+  Object.assign(agent, { ctx: scope.ctx.extend({ agent }) })
+  return agent
+}
+
+describe('Visualizer model surface', () => {
+  it('ships one neutral SVG and HTML model surface when mounted', async () => {
+    const { ctx, agent } = await mount()
+    expect(await modelFace(ctx, agent)).toMatchSnapshot()
+    const prompt = await ctx.systemPrompt.assemble({ agent, scope: agent })
+    expect(prompt.sections.find(section => section.name === 'tool:visualizer')?.text)
+      .toBe(visualizerSystemPrompt())
+
+    const guidance = ctx.tools.get('widget_guidelines', agent)!
+    const show = ctx.tools.get('show_widget', agent)!
+    expect(ctx.tools.schemas()).toEqual([])
+    const mockupGuidelines = await ctx.tools.execute({
+      signal: SIGNAL,
+      callId: ToolCallId('mockup'),
+      name: 'widget_guidelines',
+      arguments: { modules: ['mockup'] },
+      agent,
+    })
+    const mockupGuidelineText = mockupGuidelines.content
+      .flatMap(item => item.type === 'text' ? [item.text] : [])
+      .join('\n')
+    expect(mockupGuidelineText).toContain('native controls')
+    const interactiveGuidelines = await ctx.tools.execute({
+      signal: SIGNAL,
+      callId: ToolCallId('interactive'),
+      name: 'widget_guidelines',
+      arguments: { modules: ['interactive'] },
+      agent,
+    })
+    const interactiveGuidelineText = interactiveGuidelines.content
+      .flatMap(item => item.type === 'text' ? [item.text] : [])
+      .join('\n')
+    expect(interactiveGuidelineText).toContain('window.dshWidget.sendPrompt')
+    expect(interactiveGuidelineText).toContain('Call show_widget next')
+    expect(visualizerSystemPrompt()).toContain('choose presentation details yourself')
+    expect(visualizerSystemPrompt()).toContain('use no file, shell, editing, or preview step')
+    expect(interactiveGuidelineText).toContain('one primary control per value')
+    expect(interactiveGuidelineText).toContain('Give every control a clear label')
+    expect(interactiveGuidelineText).not.toContain('window.dshWidget.onInit')
+    expect(interactiveGuidelineText).not.toContain('window.dshWidget.reportState')
+    expect(guidance.parameters).toEqual({
+      type: 'object',
+      properties: {
+        modules: {
+          type: 'array',
+          items: { type: 'string', enum: ['diagram', 'mockup', 'interactive', 'chart', 'illustration'] },
+          description: 'Choose every relevant module; content and behavior are independent, so combine modules when useful. diagram: nodes and relationships. chart: quantitative data. illustration: a scene or image. interactive: motion that contributes to the presentation, or direct manipulation and adjustable inputs requested by the user. mockup: a product surface shown to explain one interaction.',
+        },
+      },
+      required: ['modules'],
+    })
+    expect(show.description)
+      .toBe('Render one temporary inline visual from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.')
+    const staticReceipt = show.output.render?.(
+      { title: 'Static', widget_code: '<svg></svg>' },
+      { kind: 'svg' },
+    )[0]
+    expect(staticReceipt?.type === 'text' ? staticReceipt.text : '')
+      .toBe('Widget "Static" accepted (svg). It is final for this response; finish with brief supporting prose.')
+    expect((show.output.schema as { properties: { kind: { enum: string[] } } }).properties.kind.enum)
+      .toEqual(['svg', 'html'])
+    const sourceDescription = JSON.stringify(show.parameters)
+    expect(sourceDescription).toContain('For HTML: one fragment')
+    expect(sourceDescription).toContain('inline <style>')
+    expect(sourceDescription).toContain('<canvas> using 2D or WebGL')
+    expect(sourceDescription).toContain('inline <script>')
+    expect(sourceDescription).toContain('For SVG: raw SVG')
+    expect(sourceDescription).toContain(
+      'Keep the widget self-contained: include all required styles, scripts, data, and assets in the source',
+    )
+    expect(sourceDescription).not.toContain('load and follow the interactive widget_guidelines module')
+    expect(sourceDescription).not.toContain('window.dshWidget')
+  })
+
+  it('applies the fixed per-Agent prompt rate in the preset scope', async () => {
+    const { ctx, presetScope, agent } = await mount()
+    expect((await ctx.systemPrompt.assemble()).contexts).toEqual([])
+    const presetKey = scopeOf(presetScope.ctx)
+    if (presetKey === undefined) throw new Error('missing preset scope key')
+    expect((await ctx.systemPrompt.assemble({ scope: presetKey })).contexts).toEqual([])
+    expect((await ctx.systemPrompt.assemble({ agent, scope: agent })).contexts).toEqual([])
+    for (let index = 0; index < 4; index += 1) {
+      ctx.visualizer.remoteSendPrompt(agent, {
+        resultSeq: 3, text: `follow-up ${index}`,
+      })
+    }
+    expect(() => {
+      ctx.visualizer.remoteSendPrompt(agent, { resultSeq: 3, text: 'one too many' })
+    }).toThrow('Agent widget follow-up rate limit reached')
+  })
+
+  it('keeps Host authority model-empty and exposes ./model only through its preset standing scope', async () => {
+    const { ctx, agent } = await mount()
+    const bare = recordedAgent(ctx, 'bare-agent')
+    ctx.agents.register(bare)
+
+    expect(Object.keys(VisualizerClient)).toEqual([])
+    expect(ctx.tools.schemas()).toEqual([])
+    expect(ctx.tools.schemas(agent).map(tool => tool.name).sort())
+      .toEqual(['show_widget', 'widget_guidelines'])
+    expect(ctx.tools.schemas(bare)).toEqual([])
+    expect((await ctx.systemPrompt.assemble({ agent, scope: agent })).sections
+      .some(section => section.name === 'tool:visualizer')).toBe(true)
+    expect((await ctx.systemPrompt.assemble({ agent: bare, scope: bare })).sections
+      .some(section => section.name === 'tool:visualizer')).toBe(false)
+  })
+
+  it('withdraws the inherited router when a child filter hides the feature tools', async () => {
+    const { ctx, presetScope } = await mount()
+    const presetKey = scopeOf(presetScope.ctx)
+    if (presetKey === undefined) throw new Error('missing preset scope key')
+    const child = recordedAgent(ctx, 'filtered-child', undefined, undefined, presetKey)
+    ctx.agents.register(child)
+    const visible = await ctx.systemPrompt.assemble({ agent: child, scope: child })
+    expect(visible.sections.find(section => section.name === 'tool:visualizer')?.text)
+      .toBe(visualizerSystemPrompt())
+    expect(visible.contexts).toEqual([])
+
+    child.ctx.tools.restrict({ allow: [] })
+    expect(ctx.tools.schemas(child)).toEqual([])
+    const hidden = await ctx.systemPrompt.assemble({ agent: child, scope: child })
+    expect(hidden.sections.find(section => section.name === 'tool:visualizer')?.text).toBe('')
+    expect(hidden.contexts).toEqual([])
+  })
+
+  it('keeps the show_widget contract satisfiable when a child filter hides widget_guidelines', async () => {
+    const { ctx, presetScope } = await mount()
+    const presetKey = scopeOf(presetScope.ctx)
+    if (presetKey === undefined) throw new Error('missing preset scope key')
+    const child = recordedAgent(ctx, 'show-only-child', undefined, undefined, presetKey)
+    ctx.agents.register(child)
+
+    child.ctx.tools.restrict({ allow: ['show_widget'] })
+
+    expect(ctx.tools.schemas(child).map(tool => tool.name)).toEqual(['show_widget'])
+    expect(ctx.tools.get('widget_guidelines', child)).toBeUndefined()
+    expect(ctx.tools.get('show_widget', child)?.description).not.toContain('widget_guidelines')
+    const prompt = await ctx.systemPrompt.assemble({ agent: child, scope: child })
+    expect(prompt.sections.find(section => section.name === 'tool:visualizer')?.text)
+      .toBe(visualizerSystemPrompt())
+  })
+
+  it('omits delivery guidance when a child filter hides show_widget', async () => {
+    const { ctx, presetScope } = await mount()
+    const presetKey = scopeOf(presetScope.ctx)
+    if (presetKey === undefined) throw new Error('missing preset scope key')
+    const child = recordedAgent(ctx, 'guidelines-only-child', undefined, undefined, presetKey)
+    ctx.agents.register(child)
+
+    child.ctx.tools.restrict({ allow: ['widget_guidelines'] })
+
+    expect(ctx.tools.schemas(child).map(tool => tool.name)).toEqual(['widget_guidelines'])
+    const result = await ctx.tools.execute({
+      signal: SIGNAL,
+      callId: ToolCallId('filtered-guidance'),
+      name: 'widget_guidelines',
+      arguments: { modules: ['diagram'] },
+      agent: child,
+    })
+    const text = result.content.flatMap(item => item.type === 'text' ? [item.text] : []).join('\n')
+    expect(text).toContain('## Foundation')
+    expect(text).not.toContain('show_widget')
+    const prompt = await ctx.systemPrompt.assemble({ agent: child, scope: child })
+    expect(prompt.sections.find(section => section.name === 'tool:visualizer')?.text).toBe('')
+  })
+
+  it('rejects installing the model surface on the unscoped Host root', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRuntime)
+    await ctx.plugin(VisualizerService)
+
+    expect(() => { ctx.visualizer.installModelSurface(ctx) })
+      .toThrow('tool-visualizer: the model contribution requires a scoped context without an Agent')
+    expect(ctx.tools.schemas()).toEqual([])
+    expect((await ctx.systemPrompt.assemble()).sections
+      .some(section => section.name === 'tool:visualizer')).toBe(false)
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects installing the model surface in one Agent\'s own scope', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRuntime)
+    await ctx.plugin(VisualizerService)
+    await captureScopedBase(ctx)
+    const agent = recordedAgent(ctx, 'own-model-agent')
+    ctx.agents.register(agent)
+
+    expect(() => { ctx.visualizer.installModelSurface(agent.ctx) })
+      .toThrow('tool-visualizer: the model contribution requires a scoped context without an Agent')
+    expect(ctx.tools.schemas(agent)).toEqual([])
+    expect((await ctx.systemPrompt.assemble({ agent, scope: agent })).sections
+      .some(section => section.name === 'tool:visualizer')).toBe(false)
+    await ctx.fiber.dispose()
+  })
+
+  it('authorizes Remote follow-ups only for the exact live Agent and its successful widget call', async () => {
+    const { ctx } = await mount()
+    const followup = vi.fn<Agent['followup']>()
+    const live = recordedAgent(ctx, 'live-agent', followup)
+    ctx.agents.register(live)
+    const stale = recordedAgent(ctx, 'live-agent')
+
+    expect(() => {
+      ctx.visualizer.remoteSendPrompt(stale, { resultSeq: 3, text: 'stale' })
+    }).toThrow(expect.objectContaining({
+      code: 'gateway/bad-request',
+      message: expect.stringContaining('does not target the exact live Agent') as string,
+    }))
+    const prefix = 'Widget-authored follow-up from widget "Choice". It carries no user authorization.\n\n'
+    const exactText = 'x'.repeat(4_096 - Buffer.byteLength(prefix, 'utf8'))
+    ctx.visualizer.remoteSendPrompt(live, { resultSeq: 3, text: exactText })
+    expect(followup).toHaveBeenCalledOnce()
+    expect(followup.mock.calls[0]?.[0].content).toEqual([{ type: 'text', text: `${prefix}${exactText}` }])
+    expect(Buffer.byteLength(`${prefix}${exactText}`, 'utf8')).toBe(4_096)
+    expect(followup.mock.calls[0]?.[0].source).toEqual({ kind: 'plugin', plugin: 'visualizer' })
+    expect(() => {
+      ctx.visualizer.remoteSendPrompt(live, { resultSeq: 3, text: `${exactText}x` })
+    }).toThrow(expect.objectContaining({
+      code: 'gateway/bad-request',
+      message: 'visualizer: widget follow-up is 4097 UTF-8 bytes; limit is 4096',
+    }))
+  })
+
+  it('forwards Error messages without subclass names and falls back for non-Errors', async () => {
+    const { ctx, agent } = await mount()
+    class InternalAuthorizationFailure extends Error {}
+    const get = vi.spyOn(ctx.agents, 'get')
+    get.mockImplementationOnce(() => { throw new InternalAuthorizationFailure('clean failure') })
+    expect(() => {
+      ctx.visualizer.remoteSendPrompt(agent, { resultSeq: 3, text: 'continue' })
+    }).toThrow(expect.objectContaining({ message: 'clean failure' }))
+    get.mockImplementationOnce(() => { throw 'plain failure' })
+    expect(() => {
+      ctx.visualizer.remoteSendPrompt(agent, { resultSeq: 3, text: 'continue' })
+    }).toThrow(expect.objectContaining({ message: 'plain failure' }))
+  })
+
+  it('denies Remote follow-ups for a successful raw SVG call', async () => {
+    const { ctx } = await mount()
+    const live = recordedAgent(ctx, 'static-agent', vi.fn(), '<svg></svg>')
+    ctx.agents.register(live)
+    expect(() => {
+      ctx.visualizer.remoteSendPrompt(live, { resultSeq: 3, text: 'continue' })
+    }).toThrow(expect.objectContaining({
+      code: 'gateway/bad-request',
+      message: expect.stringContaining('is static and cannot send follow-ups') as string,
+    }))
+  })
+
+  it('enforces the renderer byte boundary and does not echo widget source', async () => {
+    const { ctx, agent } = await mount()
+    const exactSource = 'x'.repeat(131_072)
+    const exact = await ctx.tools.execute({
+      signal: SIGNAL,
+      callId: ToolCallId('exact'),
+      name: 'show_widget',
+      arguments: { title: '图', widget_code: exactSource },
+      agent,
+    })
+    expect(exact.isError).toBe(false)
+    expect(exact.content.map(block => block.type === 'text' ? block.text : '').join('')).not.toContain(exactSource)
+
+    const oversized = await ctx.tools.execute({
+      signal: SIGNAL,
+      callId: ToolCallId('oversized'),
+      name: 'show_widget',
+      arguments: { title: '图', widget_code: `${exactSource}x` },
+      agent,
+    })
+    expect(oversized.isError).toBe(true)
+    expect(oversized.content).toEqual([{
+      type: 'text',
+      text: 'Error: visualizer: widget_code is 131073 UTF-8 bytes; limit is 131072',
+    }])
+  })
+
+  it('rejects an empty interactive fragment', async () => {
+    const { ctx, agent } = await mount()
+    const result = await ctx.tools.execute({
+      signal: SIGNAL,
+      callId: ToolCallId('empty'),
+      name: 'show_widget',
+      arguments: { title: 'Empty', widget_code: '  ' },
+      agent,
+    })
+    expect(result).toMatchObject({ isError: true })
+    expect(result.content).toEqual([{ type: 'text', text: 'Error: visualizer: widget_code must be non-empty' }])
+  })
+
+  it('validates every widget argument boundary and detects source kinds case-insensitively', async () => {
+    const { ctx, agent } = await mount()
+    const execute = async (title: string, widget_code: string) => await ctx.tools.execute({
+      signal: SIGNAL, callId: ToolCallId(`case-${title.length}-${widget_code.length}`),
+      name: 'show_widget', arguments: { title, widget_code }, agent,
+    })
+    const errorText = async (title: string, source: string): Promise<string> => {
+      const block = (await execute(title, source)).content[0]
+      return block?.type === 'text' ? block.text : ''
+    }
+    expect(await errorText(' ', '<svg></svg>')).toContain('title must be non-empty')
+    expect(await errorText('汉'.repeat(86), '<svg></svg>')).toContain('title is 258 UTF-8 bytes')
+    expect(await errorText('A', '<html></html>')).toContain('document wrappers')
+    expect(await errorText('A', '<html/>')).toContain('document wrappers')
+    expect(await errorText('A', '<body/>')).toContain('document wrappers')
+    expect(await errorText('A', '<!--note--><html lang="en">')).toContain('document wrappers')
+    expect(await errorText('A', '```html\n<html></html>\n```')).toContain('raw source without Markdown code fences')
+    expect(await errorText('A', '```html\n  \n```')).toContain('raw source without Markdown code fences')
+    expect(await errorText('A', '```html5\n<button>A</button>\n```')).toContain('raw source without Markdown code fences')
+    expect(await errorText('A', '````html\n<button>A</button>\n````')).toContain('raw source without Markdown code fences')
+    expect(await errorText('A', '~~~html\n<button>A</button>\n~~~')).toContain('raw source without Markdown code fences')
+    expect(await execute('A', '<svgx></svgx>')).toMatchObject({ isError: false, meta: { kind: 'html' } })
+    expect(await execute('A', '  <SVG></SVG>')).toMatchObject({ isError: false, meta: { kind: 'svg' } })
+    expect(await execute('A', '<svg/>')).toMatchObject({ isError: false, meta: { kind: 'svg' } })
+    expect(await errorText('A', '```svg\n<svg viewBox="0 0 2 1"></svg>\n```'))
+      .toContain('raw source without Markdown code fences')
+    expect(await errorText('A', '```html\n<button>A</button>\n```'))
+      .toContain('raw source without Markdown code fences')
+  })
+
+  it('accepts interactive HTML and exercises every tool presentation surface', async () => {
+    const { ctx, agent } = await mount()
+    const guidance = ctx.tools.get('widget_guidelines', agent)!
+    const show = ctx.tools.get('show_widget', agent)!
+    expect(guidance.isConcurrencySafe?.({ modules: ['chart'] })).toBe(true)
+    expect(guidance.presentCall?.({ modules: ['chart'] })).toMatchObject({ title: 'Load widget guidance: chart' })
+    expect(guidance.output.render?.({ modules: ['chart'] }, 'rules')).toEqual([{ type: 'text', text: 'rules' }])
+    expect(show.isConcurrencySafe?.({ title: 'Demo', widget_code: '<button>A</button>' })).toBe(true)
+    expect(show.presentCall?.({ title: 'Demo', widget_code: '<button>A</button>' })).toMatchObject({ title: 'Widget: Demo' })
+    expect(show.output.render?.({ title: 'Demo', widget_code: '<button>A</button>' }, { kind: 'html' }))
+      .toEqual([{
+        type: 'text',
+        text: 'Widget "Demo" accepted (html). It is final for this response; finish with brief supporting prose.',
+      }])
+    expect(show.output.presentationMeta?.({ title: 'Demo', widget_code: '<button>A</button>' }, { kind: 'html' }))
+      .toEqual({ kind: 'html' })
+    const html = await ctx.tools.execute({
+      signal: SIGNAL, callId: ToolCallId('html'), name: 'show_widget',
+      arguments: { title: 'Demo', widget_code: '<button>A</button>' }, agent,
+    })
+    expect(html).toMatchObject({ isError: false, meta: { kind: 'html' } })
+  })
+
+  it('removes tools and prompt with the ./model fiber while keeping Host authority', async () => {
+    const { ctx, modelFiber, agent } = await mount()
+    expect(ctx.tools.get('show_widget', agent)).toBeDefined()
+    await modelFiber.dispose()
+    expect(ctx.tools.get('show_widget', agent)).toBeUndefined()
+    expect((await ctx.systemPrompt.assemble({ agent, scope: agent })).sections
+      .some(section => section.name === 'tool:visualizer')).toBe(false)
+    expect(ctx.get('visualizer') !== undefined).toBe(true)
+  })
+
+  it('withdraws the ./model contribution when its Host authority disappears', async () => {
+    const { ctx, authorityFiber, modelFiber, agent } = await mount()
+    expect(ctx.tools.get('show_widget', agent)).toBeDefined()
+
+    await authorityFiber.dispose()
+
+    await vi.waitFor(() => { expect(ctx.tools.get('show_widget', agent)).toBeUndefined() })
+    expect((await ctx.systemPrompt.assemble({ agent, scope: agent })).sections
+      .some(section => section.name === 'tool:visualizer')).toBe(false)
+    await modelFiber.dispose()
+  })
+})

+ 294 - 0
packages/visualizer/tool-visualizer/tests/recorded-calls.spec.ts

@@ -0,0 +1,294 @@
+import { Buffer } from 'node:buffer'
+import { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { describe, expect, it, vi } from 'vitest'
+import { RecordedWidgetCalls } from '../src/recorded-calls.ts'
+
+const MAX_PROMPT_BYTES = 128
+const FIRST_RESULT_SEQ = 3
+
+function createAuthority(
+  maxPromptsPerMinutePerAgent = 3,
+): RecordedWidgetCalls {
+  return new RecordedWidgetCalls(MAX_PROMPT_BYTES, maxPromptsPerMinutePerAgent)
+}
+
+function widgetAgent(
+  id: string,
+  calls: Array<{
+    callId: string
+    title?: unknown
+    arguments?: string
+    name?: string
+    result?: 'success' | 'error' | 'none'
+    resultMeta?: unknown
+    omitResultMeta?: boolean
+    omitResultSource?: boolean
+  }> = [{ callId: 'widget-1', title: 'Status' }],
+): Agent {
+  const session = Session.create(SessionId(id))
+  session.append('turn/start', { turn: 1 })
+  session.append('step/start', { turn: 1, step: 1 })
+  for (const call of calls) {
+    const callEvent = session.append('tool/call', {
+      turn: 1,
+      step: 1,
+      callId: ToolCallId(call.callId),
+      name: call.name ?? 'show_widget',
+      arguments: call.arguments ?? JSON.stringify({
+        title: call.title ?? 'Status', widget_code: '<button>Choose</button>',
+      }),
+    })
+    if (call.result !== 'none') {
+      session.append('tool/result', {
+        turn: 1,
+        step: 1,
+        message: createToolResultMessage({
+          callId: ToolCallId(call.callId), content: [], isError: call.result === 'error',
+        }),
+        ...call.omitResultMeta
+          ? {}
+          : { meta: (Object.hasOwn(call, 'resultMeta') ? call.resultMeta : { kind: 'html' }) as never },
+      }, {
+        surfaceOp: 'append',
+        ...call.omitResultSource ? {} : { sourceEventSeqs: [callEvent.seq] },
+      })
+    }
+  }
+  return {
+    id: session.id,
+    session,
+    ctx: new Context(),
+    followup: vi.fn(),
+  } as unknown as Agent
+}
+
+type EventAtResult = ReturnType<Agent['session']['eventAt']>
+
+function wrapSession(
+  base: Agent,
+  transform: (seq: number, event: EventAtResult) => EventAtResult,
+): Agent {
+  const session = Object.create(base.session) as Agent['session']
+  Object.defineProperty(session, 'eventAt', {
+    value: (seq: SessionSeq) => transform(seq, base.session.eventAt(seq)),
+  })
+  const agent = Object.create(base) as Agent
+  Object.defineProperty(agent, 'session', { value: session })
+  return agent
+}
+
+describe('Recorded widget calls', () => {
+  it('binds through two exact event lookups without scanning the Session log', () => {
+    const authority = createAuthority()
+    const base = widgetAgent('agent')
+    const eventAt = vi.fn((seq: SessionSeq) => base.session.eventAt(seq))
+    const session = Object.create(base.session) as Agent['session']
+    Object.defineProperties(session, {
+      eventAt: { value: eventAt },
+      snapshotEvents: { value: () => { throw new Error('must not scan') } },
+    })
+    const agent = Object.create(base) as Agent
+    Object.defineProperty(agent, 'session', { value: session })
+
+    expect(authority.authorizePrompt(agent, {
+      resultSeq: FIRST_RESULT_SEQ, text: 'continue',
+    }, 1)).toBe('Widget-authored follow-up from widget "Status". It carries no user authorization.\n\ncontinue')
+    expect(eventAt.mock.calls.map(([seq]) => seq)).toEqual([SessionSeq(3), SessionSeq(2)])
+    expect(authority.authorizePrompt(agent, {
+      resultSeq: FIRST_RESULT_SEQ, text: 'again',
+    }, 2)).toBe('Widget-authored follow-up from widget "Status". It carries no user authorization.\n\nagain')
+    expect(eventAt).toHaveBeenCalledTimes(4)
+  })
+
+  it('keeps reused ToolCallIds unambiguous by binding each exact result event', () => {
+    const authority = createAuthority()
+    const agent = widgetAgent('reused-call-id', [{
+      callId: 'widget-1', title: 'First', resultMeta: { kind: 'html' },
+    }])
+    expect(authority.authorizePrompt(agent, {
+      resultSeq: FIRST_RESULT_SEQ, text: 'before reuse',
+    }, 1)).toContain('widget "First"')
+
+    agent.session.append('step/end', { turn: 1, step: 1 })
+    agent.session.append('step/start', { turn: 1, step: 2 })
+    const secondCall = agent.session.append('tool/call', {
+      turn: 1,
+      step: 2,
+      callId: ToolCallId('widget-1'),
+      name: 'show_widget',
+      arguments: JSON.stringify({ title: 'Second', widget_code: '<button>Second</button>' }),
+    })
+    const secondResult = agent.session.append('tool/result', {
+      turn: 1,
+      step: 2,
+      message: createToolResultMessage({ callId: ToolCallId('widget-1'), content: [], isError: false }),
+      meta: { kind: 'html' },
+    }, { surfaceOp: 'append', sourceEventSeqs: [secondCall.seq] })
+
+    expect(authority.authorizePrompt(agent, {
+      resultSeq: FIRST_RESULT_SEQ, text: 'after reuse',
+    }, 2)).toContain('widget "First"')
+    expect(authority.authorizePrompt(agent, {
+      resultSeq: secondResult.seq, text: 'second',
+    }, 3)).toContain('widget "Second"')
+  })
+
+  it.each([-1, -0, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
+    'rejects invalid resultSeq %s',
+    (resultSeq) => {
+      expect(() => createAuthority().authorizePrompt(widgetAgent('invalid-seq'), {
+        resultSeq, text: 'continue',
+      })).toThrow('resultSeq must be a non-negative safe integer')
+    },
+  )
+
+  it('requires resultSeq to identify an appended tool result with one source', () => {
+    const authority = createAuthority()
+    const agent = widgetAgent('not-result')
+    expect(() => authority.authorizePrompt(agent, { resultSeq: 2, text: 'continue' }))
+      .toThrow('does not identify a recorded appended tool result')
+    expect(() => authority.authorizePrompt(agent, { resultSeq: 99, text: 'continue' }))
+      .toThrow('does not identify a recorded appended tool result')
+
+    const result = agent.session.eventAt(SessionSeq(FIRST_RESULT_SEQ))
+    if (result?.type !== 'tool/result') throw new Error('missing result fixture')
+    const replacement = wrapSession(agent, (seq, event) => seq === FIRST_RESULT_SEQ
+      ? { ...result, surfaceOp: { op: 'replace', start: SessionSeq(3), end: SessionSeq(3) } }
+      : event)
+    expect(() => authority.authorizePrompt(replacement, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('does not identify a recorded appended tool result')
+
+    const withoutSource = widgetAgent('missing-source', [{ callId: 'widget-1', omitResultSource: true }])
+    expect(() => authority.authorizePrompt(withoutSource, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('does not identify one exact source call')
+    const withTwoSources = wrapSession(agent, (seq, event) => seq === FIRST_RESULT_SEQ
+      ? { ...result, sourceEventSeqs: [SessionSeq(2), SessionSeq(1)] }
+      : event)
+    expect(() => authority.authorizePrompt(withTwoSources, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('does not identify one exact source call')
+    const withUndefinedSource = wrapSession(agent, (seq, event) => seq === FIRST_RESULT_SEQ
+      ? { ...result, sourceEventSeqs: [undefined as never] }
+      : event)
+    expect(() => authority.authorizePrompt(withUndefinedSource, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('does not identify one exact source call')
+  })
+
+  it('requires exact call provenance, turn, step, tool name, and call ID', () => {
+    const authority = createAuthority()
+    const base = widgetAgent('provenance')
+    const call = base.session.eventAt(SessionSeq(2))
+    const result = base.session.eventAt(SessionSeq(FIRST_RESULT_SEQ))
+    if (call?.type !== 'tool/call' || result?.type !== 'tool/result') throw new Error('missing fixtures')
+
+    const wrongSourceType = wrapSession(base, (seq, event) => seq === 2
+      ? base.session.eventAt(SessionSeq(1))
+      : event)
+    expect(() => authority.authorizePrompt(wrongSourceType, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('is not linked to a show_widget call in the same turn and step')
+    const wrongTool = wrapSession(base, (seq, event) => seq === 2
+      ? { ...call, data: { ...call.data, name: 'other' } }
+      : event)
+    expect(() => authority.authorizePrompt(wrongTool, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('is not linked to a show_widget call in the same turn and step')
+    const wrongTurn = wrapSession(base, (seq, event) => seq === 2
+      ? { ...call, data: { ...call.data, turn: 2 } }
+      : event)
+    expect(() => authority.authorizePrompt(wrongTurn, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('is not linked to a show_widget call in the same turn and step')
+    const wrongStep = wrapSession(base, (seq, event) => seq === 2
+      ? { ...call, data: { ...call.data, step: 2 } }
+      : event)
+    expect(() => authority.authorizePrompt(wrongStep, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('is not linked to a show_widget call in the same turn and step')
+    const mismatchedId = wrapSession(base, (seq, event) => seq === FIRST_RESULT_SEQ
+      ? {
+        ...result,
+        data: {
+          ...result.data,
+          message: createToolResultMessage({ callId: ToolCallId('other'), content: [], isError: false }),
+        },
+      }
+      : event)
+    expect(() => authority.authorizePrompt(mismatchedId, { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }))
+      .toThrow('does not match its source call')
+  })
+
+  it.each([
+    ['malformed arguments', '{'],
+    ['missing title', '{}'],
+    ['non-string title', JSON.stringify({ title: 1 })],
+    ['blank title', JSON.stringify({ title: ' ' })],
+  ])('falls back to the call ID for %s', (_label, argumentsJson) => {
+    const text = createAuthority().authorizePrompt(widgetAgent('fallback', [{
+      callId: 'widget-1', arguments: argumentsJson,
+    }]), { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }, 1)
+    expect(text).toContain('widget "widget-1"')
+  })
+
+  it('requires the exact show_widget result to settle successfully', () => {
+    const authority = createAuthority()
+    expect(() => authority.authorizePrompt(
+      widgetAgent('unsettled', [{ callId: 'widget-1', result: 'none' }]),
+      { resultSeq: FIRST_RESULT_SEQ, text: 'continue' },
+    )).toThrow('does not identify a recorded appended tool result')
+    expect(() => authority.authorizePrompt(
+      widgetAgent('failed', [{ callId: 'widget-1', result: 'error' }]),
+      { resultSeq: FIRST_RESULT_SEQ, text: 'continue' },
+    )).toThrow('did not succeed')
+  })
+
+  it.each([
+    ['missing', { omitResultMeta: true }],
+    ['null', { resultMeta: null }],
+    ['array', { resultMeta: [] }],
+    ['unknown kind', { resultMeta: { kind: 'canvas' } }],
+  ])('rejects %s presentation metadata', (_label, result) => {
+    const authority = createAuthority()
+    expect(() => authority.authorizePrompt(
+      widgetAgent('invalid-meta', [{ callId: 'widget-1', ...result }]),
+      { resultSeq: FIRST_RESULT_SEQ, text: 'continue' },
+    )).toThrow('has invalid presentation metadata')
+  })
+
+  it('rate-limits follow-ups per Agent across exact widget results', () => {
+    const authority = createAuthority()
+    const agent = widgetAgent('agent', [{ callId: 'a' }, { callId: 'b' }])
+    authority.authorizePrompt(agent, { resultSeq: 3, text: 'one' }, 1)
+    authority.authorizePrompt(agent, { resultSeq: 3, text: 'two' }, 2)
+    authority.authorizePrompt(agent, { resultSeq: 5, text: 'three' }, 3)
+    expect(() => authority.authorizePrompt(agent, { resultSeq: 5, text: 'four' }, 4))
+      .toThrow('Agent widget follow-up rate limit reached')
+    expect(() => authority.authorizePrompt(agent, { resultSeq: 99, text: 'x' }, 70_000))
+      .toThrow('does not identify a recorded appended tool result')
+    expect(() => authority.authorizePrompt(widgetAgent('other'), {
+      resultSeq: FIRST_RESULT_SEQ, text: 'independent',
+    }, 4)).not.toThrow()
+  })
+
+  it('expires prompt admissions and bounds the complete labelled prompt before admission', () => {
+    const authority = createAuthority()
+    const agent = widgetAgent('agent')
+    expect(() => authority.authorizePrompt(agent, { resultSeq: FIRST_RESULT_SEQ, text: '' }, 0))
+      .toThrow('widget follow-up must be a non-empty string')
+    const exact = authority.authorizePrompt(agent, { resultSeq: FIRST_RESULT_SEQ, text: '汉'.repeat(15) }, 0)
+    expect(Buffer.byteLength(exact, 'utf8')).toBe(128)
+    expect(() => authority.authorizePrompt(agent, { resultSeq: FIRST_RESULT_SEQ, text: '汉'.repeat(16) }, 0))
+      .toThrow('widget follow-up is 131 UTF-8 bytes; limit is 128')
+    authority.authorizePrompt(agent, { resultSeq: FIRST_RESULT_SEQ, text: 'one' }, 0)
+    authority.authorizePrompt(agent, { resultSeq: FIRST_RESULT_SEQ, text: 'two' }, 60_001)
+    expect(authority.authorizePrompt(agent, {
+      resultSeq: FIRST_RESULT_SEQ, text: 'three',
+    }, 60_002)).toContain('widget "Status"')
+  })
+
+  it('denies follow-ups from successful raw SVG results', () => {
+    const authority = createAuthority()
+    expect(() => authority.authorizePrompt(
+      widgetAgent('static', [{ callId: 'widget-1', resultMeta: { kind: 'svg' } }]),
+      { resultSeq: FIRST_RESULT_SEQ, text: 'continue' },
+    )).toThrow('is static and cannot send follow-ups')
+  })
+})

+ 19 - 0
packages/visualizer/tool-visualizer/tsconfig.json

@@ -0,0 +1,19 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../../vendor/schemastery" },
+    { "path": "../../core/agent" },
+    { "path": "../../core/scope" },
+    { "path": "../../core/session" },
+    { "path": "../../core/system-prompt" },
+    { "path": "../../core/tools" },
+    { "path": "../../llm/llm" },
+    { "path": "../../typert/protocol" }
+  ]
+}

+ 14 - 0
packages/visualizer/tool-visualizer/tsdown.config.ts

@@ -0,0 +1,14 @@
+import { defineConfig } from 'tsdown'
+
+export default defineConfig([
+  {
+    entry: ['lib/types/index.js'],
+    outDir: 'lib',
+    format: ['esm'],
+    platform: 'node',
+    target: 'es2024',
+    fixedExtension: false,
+    dts: false,
+    clean: false,
+  },
+])

+ 46 - 0
pnpm-lock.yaml

@@ -7314,6 +7314,9 @@ importers:
 
   packages/preset/agent-presets:
     dependencies:
+      '@deepseek-ai/dsh-tool-visualizer':
+        specifier: workspace:^
+        version: link:../../visualizer/tool-visualizer
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -10210,6 +10213,46 @@ importers:
         specifier: workspace:^
         version: link:../../../vendor/cordis
 
+  packages/visualizer/tool-visualizer:
+    dependencies:
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-app-boot':
+        specifier: workspace:^
+        version: link:../../boot/app-boot
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
+
   packages/web/tool-web:
     dependencies:
       '@deepseek-ai/dsh-util-values':
@@ -10986,6 +11029,9 @@ importers:
       '@deepseek-ai/dsh-tool-todo':
         specifier: workspace:^
         version: link:../../packages/todo/tool-todo
+      '@deepseek-ai/dsh-tool-visualizer':
+        specifier: workspace:^
+        version: link:../../packages/visualizer/tool-visualizer
       '@deepseek-ai/dsh-tool-web':
         specifier: workspace:^
         version: link:../../packages/web/tool-web

+ 1 - 0
python/sdk-runtime/package.json

@@ -114,6 +114,7 @@
     "@deepseek-ai/dsh-tool-subagent": "workspace:^",
     "@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
+    "@deepseek-ai/dsh-tool-visualizer": "workspace:^",
     "@deepseek-ai/dsh-tool-web": "workspace:^",
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 2 - 0
scripts/gen-cordis-catalog.ts

@@ -115,6 +115,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   tokenMeter: 'token-meter.md',
   toolResultPruner: 'compaction.md',
   tools: 'tools.md',
+  visualizer: 'tools.md',
   typert: 'typert.md',
   typertGateway: 'typert.md',
   userQuestions: 'user-questions.md',
@@ -303,6 +304,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   MessageFeedbackVersion: 'feedback.md',
   MessageFeedbackVersionConflict: 'feedback.md',
   UserMessage: 'session.md',
+  WidgetPromptRequest: 'tools.md',
   ApiSessionAgentResult: 'session.md',
   PreStepDecision: 'core.md',
   PreStepContext: 'core.md',

+ 9 - 2
scripts/gen-doc-graphs.ts

@@ -359,15 +359,22 @@ const SERVICE_ROLES: ServiceRole[] = [
     pkg: 'system-prompt',
     title: 'System prompt assembly registry',
     mode: 'core',
-    consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-terminal', 'tool-web'],
+    consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-terminal', 'tool-visualizer', 'tool-web'],
     note: 'Collects prompt sections and model-facing tool schemas for each step.',
   },
+  {
+    key: 'visualizer',
+    pkg: 'tool-visualizer',
+    title: 'Inline Visualizer authority',
+    mode: 'core',
+    note: 'Owns limits, exact live-Agent/call authorization, follow-up admission, and generated Remote operations; its preset-scoped ./model contribution consumes the authority and owns model-tool and prompt registration lifecycles.',
+  },
   {
     key: 'tools',
     pkg: 'tools',
     title: 'Tool registry and guarded execution pipeline',
     mode: 'core',
-    consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web'],
+    consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-visualizer', 'tool-web'],
     note: 'Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
   },
   {

+ 24 - 0
scripts/gen-tool-catalog.ts

@@ -61,6 +61,8 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
 import type TeamService from '@deepseek-ai/dsh-experimental-agent-team'
 import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team'
 import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
+import VisualizerService from '@deepseek-ai/dsh-tool-visualizer'
+import * as VisualizerModel from '@deepseek-ai/dsh-tool-visualizer/model'
 import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
 import { registerListSubagentModels } from '../packages/subagent/tool-subagent/src/list-models.ts'
 import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
@@ -557,6 +559,28 @@ const TOOL_PACKAGES: ToolPackage[] = [
     note:
       'todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task.',
   },
+  {
+    pkg: '@deepseek-ai/dsh-tool-visualizer',
+    dir: 'tool-visualizer',
+    source: 'packages/visualizer/tool-visualizer/src/tools.ts',
+    requires: ['ctx.visualizer', 'ctx.tools', 'ctx.systemPrompt'],
+    writes: ['tool/call', 'tool/result', 'user/message via agent.followup() for authorized widget follow-ups'],
+    async mount(ctx) {
+      await ctx.plugin(AgentRegistry)
+      await ctx.plugin(VisualizerService)
+      await mountCatalogChildScope(
+        ctx,
+        (childCtx) => {
+          VisualizerModel.apply(childCtx)
+        },
+        { id: SessionId('tool-catalog-visualizer') } as Agent,
+        ['tools', 'systemPrompt', 'visualizer'],
+      )
+    },
+    scope: ctx => catalogChildScopes.get(ctx) as Agent,
+    note:
+      'The standard and cordis presets always mount a recoverable model wrapper, which contributes prompt and tools only while the authority service exists and follows later authority withdrawal or reappearance. Minimal and PTC omit it; PTC nested code-dispatch calls lack the ordinary show_widget call/result identity required by the follow-up bridge.',
+  },
   {
     pkg: '@deepseek-ai/dsh-tool-workflow',
     dir: 'tool-workflow',

+ 2 - 0
scripts/translation-pairing.manifest.json

@@ -3,6 +3,8 @@
     ".agents/notes/AGENTS.md",
     ".agents/notes/implemented/AGENTS.md",
     ".agents/notes/implemented/CLAUDE.md",
+    ".agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md",
+    ".github/review-ownership/README.md",
     "docs/AGENTS.md",
     "docs/cordis-api/inherited.md",
     "docs/i18n/style-samples.md",

+ 3 - 0
tsconfig.base.json

@@ -114,6 +114,8 @@
       "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"],
       "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"],
       "@deepseek-ai/dsh-tool-subagent/model-selection-settings": ["./packages/subagent/tool-subagent/src/model-selection-settings.ts"],
+      "@deepseek-ai/dsh-tool-visualizer/client": ["./packages/visualizer/tool-visualizer/src/client.ts"],
+      "@deepseek-ai/dsh-tool-visualizer/model": ["./packages/visualizer/tool-visualizer/src/model.ts"],
       "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"],
       "@deepseek-ai/dsh-user-questions/types": ["./packages/interaction/user-questions/src/types.ts"],
       "@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"],
@@ -425,6 +427,7 @@
       "@deepseek-ai/dsh-tool-terminal": ["./packages/terminal/tool-terminal/src"],
       "@deepseek-ai/dsh-tool-todo": ["./packages/todo/tool-todo/src"],
       "@deepseek-ai/dsh-tool-todo/invariant": ["./packages/todo/tool-todo/src/invariant.ts"],
+      "@deepseek-ai/dsh-tool-visualizer": ["./packages/visualizer/tool-visualizer/src"],
       "@deepseek-ai/dsh-tool-web": ["./packages/web/tool-web/src"],
       "@deepseek-ai/dsh-tool-workflow": ["./packages/workflow/tool-workflow/src"],
       "@deepseek-ai/dsh-tool-workflow/invariant": ["./packages/workflow/tool-workflow/src/invariant.ts"],

+ 2 - 0
tsconfig.host.json

@@ -52,6 +52,7 @@
     "apps/web/tests/cordis-tool-round.e2e.ts",
     "apps/web/tests/web-search-round.e2e.ts",
     "apps/web/tests/file-upload-round.e2e.ts",
+    "apps/web/tests/visualizer.e2e.ts",
     "apps/web/tests/message-actions.e2e.ts",
     "apps/web/tests/open-in-app-ssh.e2e.ts",
     "apps/web/tests/message-feedback.e2e.ts",
@@ -329,6 +330,7 @@
     { "path": "./packages/guard/repeat-tool-reminder" },
     { "path": "./packages/extensions/cordis-host-runner" },
     { "path": "./packages/extensions/tool-cordis" },
+    { "path": "./packages/visualizer/tool-visualizer" },
     { "path": "./packages/hooks/hook-protocol" },
     { "path": "./packages/hooks/hooks-claude-code" },
     { "path": "./packages/hooks/hooks-codex" },