Răsfoiți Sursa

Merge remote-tracking branch 'origin/master' into xtr/explicit-agent-context

# Conflicts:
#	apps/web/tests/queue-actions.e2e.ts
#	docs/subsystems/tools.i18n.yaml
#	docs/subsystems/tools.md
#	docs/subsystems/tools.zh.md
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	packages/visualizer/tool-visualizer/README.i18n.yaml
#	packages/visualizer/tool-visualizer/README.md
#	packages/visualizer/tool-visualizer/README.zh.md
#	packages/visualizer/tool-visualizer/src/index.ts
#	packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts
#	packages/visualizer/tool-visualizer/tests/model-surface.spec.ts
_Kerman 1 lună în urmă
părinte
comite
3e0e419bd2
100 a modificat fișierele cu 1291 adăugiri și 1384 ștergeri
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml
  2. 0 2
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md
  3. 0 2
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md
  4. 0 35
      .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.md
  5. 0 35
      .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.zh.md
  6. 3 3
      .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml
  7. 43 0
      .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
  8. 43 0
      .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md
  9. 6 0
      .agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml
  10. 35 0
      .agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md
  11. 35 0
      .agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md
  12. 2 0
      THIRD_PARTY_NOTICES.md
  13. 7 0
      apps/web/tests/expected/markdown-images/ui.expected.md
  14. 2 0
      apps/web/tests/feedback-command.e2e.ts
  15. 4 2
      apps/web/tests/github-ready-review.e2e.ts
  16. 8 0
      apps/web/tests/lifecycle-chrome.e2e.ts
  17. 5 1
      apps/web/tests/live-interactions.e2e.ts
  18. 77 15
      apps/web/tests/markdown-images.e2e.ts
  19. 4 0
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  20. 36 31
      apps/web/tests/queue-actions.e2e.ts
  21. 3 0
      apps/web/tests/seeded-history.e2e.ts
  22. 13 2
      apps/web/tests/workflow-run.e2e.ts
  23. 5 0
      apps/web/tests/workspace-management.e2e.ts
  24. 2 2
      docs/capability-seams.i18n.yaml
  25. 2 8
      docs/capability-seams.md
  26. 2 8
      docs/capability-seams.zh.md
  27. 2 2
      docs/config-catalog.i18n.yaml
  28. 2 3
      docs/config-catalog.md
  29. 1 2
      docs/config-catalog.zh.md
  30. 2 2
      docs/module-graph.i18n.yaml
  31. 2 12
      docs/module-graph.md
  32. 2 12
      docs/module-graph.zh.md
  33. 2 2
      docs/subsystems/tools.i18n.yaml
  34. 0 25
      docs/subsystems/tools.md
  35. 0 25
      docs/subsystems/tools.zh.md
  36. 2 2
      docs/tool-catalog.i18n.yaml
  37. 0 64
      docs/tool-catalog.md
  38. 0 64
      docs/tool-catalog.zh.md
  39. 2 2
      packages/README.i18n.yaml
  40. 0 1
      packages/README.md
  41. 0 1
      packages/README.zh.md
  42. 2 2
      packages/api/session-controller/README.i18n.yaml
  43. 8 0
      packages/api/session-controller/README.md
  44. 8 0
      packages/api/session-controller/README.zh.md
  45. 7 2
      packages/api/session-controller/package.json
  46. 2 0
      packages/api/session-controller/src/index.ts
  47. 77 0
      packages/api/session-controller/src/media-references.ts
  48. 224 0
      packages/api/session-controller/tests/media-references.host.spec.ts
  49. 3 0
      packages/api/session-controller/tsconfig.host.json
  50. 25 1
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  51. 74 0
      packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx
  52. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  53. 1 1
      packages/client/ui-conversation/README.md
  54. 1 1
      packages/client/ui-conversation/README.zh.md
  55. 5 3
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  56. 18 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  57. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  58. 2 2
      packages/client/ui-primitives/README.md
  59. 2 2
      packages/client/ui-primitives/README.zh.md
  60. 4 4
      packages/client/ui-primitives/src/ConnectionIndicator.module.css
  61. 1 1
      packages/client/ui-primitives/src/index.ts
  62. 14 7
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  63. 63 7
      packages/client/ui-primitives/src/markdown/render.tsx
  64. 104 0
      packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx
  65. 1 0
      packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx
  66. 0 1
      packages/core/system-prompt/src/index.ts
  67. 2 2
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  68. 45 8
      packages/experimental/agent-team/tests/persistence.spec.ts
  69. 1 0
      packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts
  70. 0 21
      packages/extensions/tool-cordis/src/api-catalog.ts
  71. 7 13
      packages/lsp/lsp-stdio/tests/fixture-server.ts
  72. 37 11
      packages/lsp/lsp-stdio/tests/lifecycle.spec.ts
  73. 0 1
      packages/preset/agent-presets/package.json
  74. 0 5
      packages/preset/agent-presets/presets/cordis/agent.cordis.yml
  75. 0 5
      packages/preset/agent-presets/presets/standard/agent.cordis.yml
  76. 14 6
      packages/session/session-projection-cache/tests/cache.spec.ts
  77. 29 16
      packages/shell/pwsh-local/tests/executor.spec.ts
  78. 43 25
      packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
  79. 23 9
      packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts
  80. 32 15
      packages/subagent/subagent/tests/continuation.spec.ts
  81. 2 2
      packages/test-support/session-snapshot/README.i18n.yaml
  82. 1 0
      packages/test-support/session-snapshot/README.md
  83. 1 0
      packages/test-support/session-snapshot/README.zh.md
  84. 16 11
      packages/test-support/session-snapshot/src/harness.ts
  85. 32 0
      packages/test-support/session-snapshot/tests/harness.spec.ts
  86. 0 6
      packages/visualizer/README.i18n.yaml
  87. 0 41
      packages/visualizer/README.md
  88. 0 41
      packages/visualizer/README.zh.md
  89. 0 6
      packages/visualizer/tool-visualizer/README.i18n.yaml
  90. 0 103
      packages/visualizer/tool-visualizer/README.md
  91. 0 103
      packages/visualizer/tool-visualizer/README.zh.md
  92. 0 76
      packages/visualizer/tool-visualizer/package.json
  93. 0 6
      packages/visualizer/tool-visualizer/src/client.ts
  94. 0 68
      packages/visualizer/tool-visualizer/src/guidelines.ts
  95. 0 94
      packages/visualizer/tool-visualizer/src/index.ts
  96. 0 20
      packages/visualizer/tool-visualizer/src/model.ts
  97. 0 10
      packages/visualizer/tool-visualizer/src/prompt.ts
  98. 0 142
      packages/visualizer/tool-visualizer/src/recorded-calls.ts
  99. 0 104
      packages/visualizer/tool-visualizer/src/tools.ts
  100. 0 9
      packages/visualizer/tool-visualizer/src/types.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md
-2026-08-31-explicit-agent-runtime-identity.md: fe57ea28d7c9661a2bd34d36b487c2963a60d541
-2026-08-31-explicit-agent-runtime-identity.zh.md: 985f52886b22f52f2358fd5628906b7f8dcc30c4
+2026-08-31-explicit-agent-runtime-identity.md: 48c8d0c205b6b8c2331e2373b1e11142e05d715b
+2026-08-31-explicit-agent-runtime-identity.zh.md: a8358f5a8f4629289dc75722ed82d79206db3dc5

+ 0 - 2
.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md

@@ -18,8 +18,6 @@ Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup`
 
 Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset reserves one Cordis cleanup effect for each matching Agent before sampling policy: the event payload supplies the Agent, its Session supplies the policy target, its Context owns the registrations, and the preset effect joins their removal after reparenting or preset unload.
 
-Visualizer model installation likewise uses Context only for scoped registrations. The shipped wrapper selects the preset scope; the installer neither classifies its opaque key nor resolves an Agent from it. Widget follow-up authorization receives its exact live Agent through the Remote.
-
 `SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it.
 
 The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there.

+ 0 - 2
.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md

@@ -18,8 +18,6 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操
 
 感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 在读取策略前,会为每个匹配 Agent 预留一个 Cordis 清理 effect:事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项,而 preset effect 会在重设父级或 preset 卸载后等待其清理完成。
 
-Visualizer 模型贡献的安装同样只使用 Context 管理作用域注册。随产品提供的 wrapper 选择 preset 作用域;安装器既不分类其不透明键,也不从中解析 Agent。Widget follow-up 授权通过 Remote 接收确切的存活 Agent。
-
 `SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。
 
 [Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。

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

@@ -1,35 +0,0 @@
-# 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.

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

@@ -1,35 +0,0 @@
-# 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 重建时重置。

+ 3 - 3
.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.i18n.yaml → .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.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/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
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
+2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd
+2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840

+ 43 - 0
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md

@@ -0,0 +1,43 @@
+# Agent Note: Session prose local media paths display through a same-origin file route
+
+Status: implemented
+
+English | [中文](2026-09-07-session-prose-local-media-display.zh.md)
+
+## Problem
+
+Assistant prose can reference an image by its filesystem path, but browsers cannot read Host files. A renderer limited to absolute HTTP(S) destinations leaves those references as inert alt text. Issue #3662 records this display gap.
+
+## Decision
+
+Local media paths in Session prose render through a same-origin file route. This note owns the renderer vocabulary and its placement; [authenticated filesystem reads](2026-09-08-file-display-through-filesystem.md) owns the current serving policy and supersedes the workspace/media restrictions described below.
+
+`ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again.
+
+`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert.
+
+`session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation.
+
+## Alternatives considered
+
+**Typert gateway or workspace controller ownership.** The gateway owns Remote RPC dispatch, while the workspace controller owns registry lifecycle. Neither owns file-byte presentation; Session Controller is the consumer serving Session prose.
+
+**Session RPC followed by blob/data URLs.** Attachment images can use an asynchronous fetch, but this Markdown vocabulary must synchronously resolve a destination during a memoized render pass.
+
+**Image-only endpoints.** One file route can serve images, audio, and video without separate URL vocabularies. The current implementation returns complete bounded files; Markdown audio/video player nodes remain independent work.
+
+**Byte-signature validation in the route.** The model-facing `read_image` tool owns image admission checks. Display responses describe content by MIME lookup and let browser decoding reject corrupt payloads, avoiding a duplicate signature checker.
+
+**Workspace/media-only access (superseded).** The original policy restricted canonical paths to registered workspace roots and allowed image/video/audio MIME categories except SVG. Regular-file checks before opening rejected pipes and devices; an opened-handle identity comparison narrowed replacement races. These restrictions bounded authenticated access and avoided a per-request interactive authorization flow. They also excluded temporary screenshots and remote files; the successor note records the replacement policy and why those restrictions are not retained.
+
+## Consequences
+
+The Client vocabulary cannot bypass Host authentication or the filesystem provider. The original restricted route distinguished an existing outside-workspace path from an absent path, exposing existence even while refusing its bytes; the successor policy instead permits ordinary provider-readable files.
+
+Windows-style authored paths remain unsupported by the Client vocabulary. Trajectory and tool-card Markdown consumers do not supply this vocabulary, and audio/video Markdown nodes do not render players. These are renderer limitations, independent of the file route's readable MIME types.
+
+The archived [model-readable image paths](../../archived/feature/2026-08-21-model-readable-image-paths.md) note owns the model-facing behavior; this note owns user-facing display and does not supersede it.
+
+## Testing
+
+Renderer tests cover settled and streaming gates, reference-style images, protocol rechecks, failed-load fallback, and replacement sources. Chat tests cover the vocabulary and component wiring. The browser scenario in `apps/web/tests/markdown-images.e2e.ts` boots the shipped Web composition with a seeded Session and checks actual loading and fallback text. A model-driven recorded Session round trip remains separate from this UI expectation; the successor note names current route coverage.

+ 43 - 0
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 会话正文本地媒体路径通过同源文件路由显示
+
+Status: implemented
+
+[English](2026-09-07-session-prose-local-media-display.md) | 中文
+
+## Problem
+
+Assistant 正文可能通过文件系统路径引用图片,但浏览器无法读取 Host 文件。仅允许绝对 HTTP(S) 目标的渲染器会把这些引用保留为静态 alt 文本。Issue #3662 记录了这一展示缺口。
+
+## Decision
+
+Session 正文中的本地媒体路径通过同源文件路由渲染。本记录拥有渲染器词表及其归属;[鉴权文件系统读取](2026-09-08-file-display-through-filesystem.zh.md)拥有当前文件服务策略,并取代下文的工作区与媒体限制。
+
+`ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。
+
+`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。
+
+`session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。
+
+## Alternatives considered
+
+**由 Typert gateway 或 workspace controller 拥有。** gateway 拥有 Remote RPC 分发,workspace controller 拥有注册表生命周期。两者都不拥有文件字节展示;Session Controller 是服务 Session 正文的消费方。
+
+**先经 Session RPC 获取,再使用 blob/data URL。** 附件图片可以异步获取,但此 Markdown 词表必须在记忆化渲染过程中同步解析目标。
+
+**图片专用端点。** 单一文件路由即可服务图片、音频和视频,无需独立 URL 词表。当前实现返回有界完整文件;Markdown 音视频播放器节点仍是独立工作。
+
+**路由中的字节签名校验。** 面向模型的 `read_image` 工具拥有图片准入检查。展示响应通过 MIME 查询描述内容,由浏览器解码拒绝损坏载荷,避免重复实现签名检查器。
+
+**仅限工作区与媒体的访问(已取代)。** 原策略把规范路径限制在已注册工作区根目录内,并允许除 SVG 外的 image/video/audio MIME 类别。打开前的普通文件检查拒绝管道与设备;已打开句柄的身份比较收窄替换竞态。这些限制约束了鉴权后的访问范围,并避免每次请求的交互授权流程。它们也排除了临时截图与远程文件;后续记录说明替代策略及不保留这些限制的理由。
+
+## Consequences
+
+客户端词表无法绕过 Host 鉴权或文件系统提供方。原受限路由区分了工作区外已存在路径与缺失路径,即使拒绝其字节仍暴露存在性;后续策略则允许提供方可读的普通文件。
+
+客户端词表仍不支持作者提供的 Windows 风格路径。轨迹与工具卡片 Markdown 消费方不提供此词表,音视频 Markdown 节点也不渲染播放器。这些属于渲染器限制,与文件路由可读的 MIME 类型无关。
+
+已归档的[模型可读图片路径](../../archived/feature/2026-08-21-model-readable-image-paths.md)记录拥有模型侧行为;本记录拥有用户侧展示,不取代它。
+
+## Testing
+
+渲染器测试覆盖稳定与流式门禁、引用式图片、协议复查、加载失败回退和来源替换。聊天测试覆盖词表与组件连接。`apps/web/tests/markdown-images.e2e.ts` 浏览器场景使用已播种 Session 启动交付的 Web 组合,检查实际加载与回退文本。模型驱动的记录 Session 往返仍独立于此 UI 期望;后续记录说明当前路由覆盖。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml

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

+ 35 - 0
.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md

@@ -0,0 +1,35 @@
+# Agent Note: Authenticated file display reuses filesystem byte reads
+
+Status: implemented
+
+English | [中文](2026-09-08-file-display-through-filesystem.zh.md)
+
+## Problem
+
+Session prose can reference screenshots in temporary directories or files stored by a remote filesystem provider. A Host-local workspace allowlist cannot serve those paths. An image response without a byte limit can also make the browser download a 1 GiB image before attempting to decode it.
+
+## Decision
+
+The authenticated `/api/file` route reads ordinary files through `ctx.fs`. Authentication and the composed provider's read policy govern access; directory and MIME allowlists do not. This supersedes the serving policy in [the local-media display note](2026-09-07-session-prose-local-media-display.md), which retains renderer ownership and its rationale.
+
+GET calls the existing `readBytes(target, signal, maxBytes)`: providers reject known oversized files before content I/O and enforce the limit while reading. HEAD uses metadata without reading content. `FS_TOO_LARGE` becomes 413. MIME lookup supplies response metadata without sniffing file contents; unknown extensions use `application/octet-stream`. A sandbox CSP prevents directly opened HTML/SVG from executing with the authenticated API origin.
+
+All files use the resolved `ctx.attachments.imageLimits.maxImageBytes` limit, normally 20 MiB. The attachment service owns this deployment setting. All responses contain complete files; Range is ignored and no range support is advertised.
+
+## Alternatives considered
+
+**Workspace and media allowlists.** They limit which authenticated bytes can be read, but exclude ordinary screenshot locations and remote files. The chosen policy permits every regular file the composed provider can read.
+
+**A new filesystem byte-stream API.** Efficient large-file delivery and audio/video seeking would require implementations in every provider, including remote range handling. Complete bounded reads satisfy the current display scope without widening that interface. Streaming and Range can be added when those use cases justify the provider work.
+
+**Duplicate size checks in the route.** GET needs no additional stat/read loop: `readBytes` already owns preflight limits, growth detection, and cancellation. HEAD checks size separately because it must not read the body.
+
+## Consequences
+
+Temporary and remote files use the same filesystem provider as `read_image`, without adding model-facing events. The local sandbox provider constrains mutations and permits reads; an authenticated client therefore has broader access than registered workspace roots. Files remain subject to the provider's permissions and the route's byte limits.
+
+Each GET buffers the complete file in Host memory. Audio/video work as complete responses without incremental transfer or guaranteed seeking. Encoded byte limits do not bound decoded pixel dimensions. Failed image loads show authored alt text or the original destination when alt is empty.
+
+## Testing
+
+Route tests cover sparse 1 GiB rejection before content I/O, post-stat growth, the shared attachment byte limit, ordinary MIME types, temporary paths and symlinks, opaque remote targets, provider failures, metadata-only HEAD, ignored Range, and disposal. Browser expectations cover rendered images, 413/404 and corrupt-image fallbacks, and an image outside the workspace. Remote byte transfer remains owned by the existing filesystem provider tests.

+ 35 - 0
.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 鉴权文件展示复用文件系统字节读取
+
+Status: implemented
+
+[English](2026-09-08-file-display-through-filesystem.md) | 中文
+
+## Problem
+
+会话正文可能引用临时目录中的截图或远程文件系统提供方中的文件。Host 本地工作区白名单无法提供这些路径。没有字节上限的图片响应还可能让浏览器先下载一张 1 GiB 图片,再尝试解码。
+
+## Decision
+
+鉴权 `/api/file` 路由通过 `ctx.fs` 读取普通文件。鉴权和所组合提供方的读取策略决定访问权限;目录与 MIME 白名单不参与准入。这取代了[本地媒体展示记录](2026-09-07-session-prose-local-media-display.zh.md)中的文件服务策略;该记录保留渲染器归属及其理由。
+
+GET 调用现有 `readBytes(target, signal, maxBytes)`:提供方在内容 I/O 前拒绝已知超限文件,并在读取过程中执行上限。HEAD 使用元数据,不读取内容。`FS_TOO_LARGE` 转换为 413。MIME 查询提供响应元数据,不嗅探文件内容;未知扩展名使用 `application/octet-stream`。sandbox CSP 阻止直接打开的 HTML/SVG 以鉴权 API 源身份执行脚本。
+
+所有文件均使用已解析的 `ctx.attachments.imageLimits.maxImageBytes` 上限,通常为 20 MiB。附件服务拥有此部署配置。所有响应均包含完整文件;忽略 Range,也不声明支持 Range。
+
+## Alternatives considered
+
+**工作区和媒体白名单。** 它们限制鉴权后能读取哪些字节,却排除了常见截图位置和远程文件。所选策略允许读取所组合提供方可读的任意普通文件。
+
+**新增文件系统字节流 API。** 高效的大文件传输和音视频跳转需要每个提供方实现,包括远端 Range 处理。有界完整读取满足当前展示范围,无需扩展该接口。相关用例足以支持这项提供方工作时,可以加入流式传输与 Range。
+
+**在路由重复实现大小检查。** GET 无需额外的 stat/read 循环:`readBytes` 已经负责读取前上限、增长检测和取消。HEAD 单独检查大小,因为它不能读取正文。
+
+## Consequences
+
+临时与远程文件使用与 `read_image` 相同的文件系统提供方,不增加模型可见事件。本地沙箱提供方约束变更操作并允许读取,因此鉴权客户端的访问范围大于已注册工作区根目录。文件仍受提供方权限和路由字节上限约束。
+
+每个 GET 都会在 Host 内存中缓存完整文件。音视频使用完整响应,不支持增量传输,也不保证跳转播放。编码字节上限不限制解码后的像素尺寸。图片加载失败后展示作者提供的 alt 文本;alt 为空时展示原始目标路径。
+
+## Testing
+
+路由测试覆盖内容 I/O 前拒绝稀疏 1 GiB 文件、stat 后增长、共用附件字节上限、普通 MIME 类型、临时路径与符号链接、不透明远程目标、提供方失败、仅元数据 HEAD、忽略 Range 和释放。浏览器期望覆盖图片渲染、413/404 及损坏图片回退,以及工作区之外的图片。远程字节传输仍由现有文件系统提供方测试负责。

+ 2 - 0
THIRD_PARTY_NOTICES.md

@@ -90,6 +90,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
 | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
 | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
+| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
 | [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
 | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
 | [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
@@ -156,6 +157,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |

+ 7 - 0
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -16,6 +16,13 @@
 - paragraph:
   - img "Remote test image"
 - paragraph: Local test image
+- paragraph:
+  - img "Workspace test image"
+- paragraph: Oversized image
+- paragraph:
+  - img "Outside workspace image"
+- paragraph: Missing image
+- paragraph: {{cwd}}/corrupt.png
 - paragraph: REMOTE_IMAGE_DONE
 - button "Copy":
   - img

+ 2 - 0
apps/web/tests/feedback-command.e2e.ts

@@ -81,6 +81,8 @@ describe('web e2e: /feedback command acknowledgement', () => {
     await input.press('Enter')
     await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
     expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1)
+    await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
+    await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
     const expanded = await captureExpandedTurnProcessAria(

+ 4 - 2
apps/web/tests/github-ready-review.e2e.ts

@@ -140,8 +140,10 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => {
       },
     }
     expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202)
-    await vi.waitFor(() => { expect(scaffold.ctx.agents.list()).toHaveLength(before + 1) })
-    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    // HTTP 202 acknowledges ingress; the model request follows durable
+    // Workspace and Session creation by the asynchronous webhook rule.
+    await expect.poll(() => adapter.requests.length, { timeout: 30_000 }).toBe(1)
+    expect(scaffold.ctx.agents.list()).toHaveLength(before + 1)
 
     const agent = scaffold.ctx.agents.list().find(candidate => candidate.session.header.cwd === scaffold.workspaceCwd)
     expect(agent).toBeDefined()

+ 8 - 0
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -349,6 +349,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       const connectingGeometry = await connectionIndicatorGeometry(connecting)
       expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
+      // Animated dots must remain hidden with their state label during hover.
+      await connecting.evaluate((element) => {
+        for (const animation of element.getAnimations({ subtree: true })) {
+          if (!(animation instanceof CSSAnimation)) continue
+          animation.pause()
+          animation.currentTime = 1_250
+        }
+      })
       await connecting.hover()
       expect(await connecting.innerText()).toBe('Reconnect now')
       expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)

+ 5 - 1
apps/web/tests/live-interactions.e2e.ts

@@ -182,7 +182,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     await queuedRow.getByRole('button', { name: 'Remove queued message' }).click()
     await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0)
 
-    await page.getByRole('button', { name: 'Stop generating' }).click()
+    const stopButton = page.getByRole('button', { name: 'Stop generating' })
+    await stopButton.hover()
+    await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
+    await stopButton.click()
     await settled
     expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
     // Composer recovered; no streaming node lingers. The host settled first
@@ -190,6 +193,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // frozen-partial swap is eventually consistent, so poll rather than count.
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
     // Golden of the aborted end-state: the prompt bubble plus the frozen
     // partial ('partial' is the hang entry's replayed prefix) and no more.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)

+ 77 - 15
apps/web/tests/markdown-images.e2e.ts

@@ -1,8 +1,7 @@
-// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session
-// assembled through the Session API is seeded cold into the real web
-// composition, then a separate image origin proves that the browser receives
-// a real network image while local-path Markdown remains inert alt text.
+// Real browser image loading and failure fallbacks through the shipped Web composition.
+import { open, writeFile } from 'node:fs/promises'
 import { createServer, type Server } from 'node:http'
+import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
@@ -32,6 +31,7 @@ const MODE = webSnapshotMode()
 const SEED_ID = 'markdown-images-web-e2e'
 const REMOTE_ALT = 'Remote test image'
 const LOCAL_ALT = 'Local test image'
+const WORKSPACE_ALT = 'Workspace test image'
 const PNG = Buffer.from(
   'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
   'base64',
@@ -81,7 +81,7 @@ async function stopServer(server: Server): Promise<void> {
 }
 
 /** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
-function markdownImageFixture(remoteUrl: string): string {
+function markdownImageFixture(remoteUrl: string, outsidePath: string): string {
   const session = Session.create(SessionId('markdown-image-source'))
   const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
   session.append('turn/start', { turn: 1 })
@@ -110,6 +110,16 @@ function markdownImageFixture(remoteUrl: string): string {
           '',
           `![${LOCAL_ALT}](./local-image.png)`,
           '',
+          `![${WORKSPACE_ALT}]({{cwd}}/valid.png)`,
+          '',
+          '![Oversized image]({{cwd}}/oversized.png)',
+          '',
+          `![Outside workspace image](${outsidePath})`,
+          '',
+          '![Missing image]({{cwd}}/missing.png)',
+          '',
+          '![]({{cwd}}/corrupt.png)',
+          '',
           'REMOTE_IMAGE_DONE',
         ].join('\n'),
       }],
@@ -142,20 +152,38 @@ function markdownImageFixture(remoteUrl: string): string {
   ].join('\n')
 }
 
-describe('web e2e: remote Markdown image rendering', () => {
+describe('web e2e: Markdown image rendering', () => {
   let scaffold: WebScaffold
   let imageOrigin: ImageOrigin
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
+  const mediaResponses = new Map<string, number>()
 
   beforeAll(async () => {
     imageOrigin = await startImageOrigin()
     scaffold = await launchWebScaffold({})
-    await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID)
+    await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG)
+    await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image')
+    const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w')
+    try {
+      await oversized.truncate(20 * 1024 * 1024 + 1)
+    } finally {
+      await oversized.close()
+    }
+    const outsidePath = join(scaffold.persistenceRoot, 'outside.png')
+    await writeFile(outsidePath, PNG)
+    await writeFile(join(scaffold.workspaceCwd, 'active.html'), '<p>File preview</p><script>document.body.dataset.scriptRan = "yes"</script>')
+    await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
+    page.on('response', (response) => {
+      const url = new URL(response.url())
+      if (url.pathname !== '/api/file') return
+      const path = url.searchParams.get('path')
+      if (path !== null) mediaResponses.set(path, response.status())
+    })
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
@@ -163,17 +191,30 @@ describe('web e2e: remote Markdown image rendering', () => {
   afterAll(async () => {
     await browser?.close()
     await scaffold?.close()
-    await stopServer(imageOrigin.server)
+    if (imageOrigin !== undefined) await stopServer(imageOrigin.server)
+  })
+
+  it('authenticates file requests and isolates directly opened active content', async () => {
+    const path = `/api/file?path=${encodeURIComponent(join(scaffold.workspaceCwd, 'active.html'))}`
+    const unauthenticated = await fetch(new URL(path, scaffold.baseUrl))
+    expect(unauthenticated.status).toBe(401)
+    await unauthenticated.body?.cancel()
+    const preview = await newEnglishPage(browser)
+    await preview.context().addCookies(await page.context().cookies())
+    try {
+      const response = await preview.goto(new URL(path, scaffold.baseUrl).href)
+      expect(response?.status()).toBe(200)
+      await preview.getByText('File preview', { exact: true }).waitFor()
+      expect(await preview.locator('body').getAttribute('data-script-ran')).toBeNull()
+    } finally {
+      await preview.close()
+    }
   })
 
-  it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => {
+  it.skipIf(MODE === 'record')('loads permitted images and shows authored text for failures', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images'))
-    const groupRow = page.locator('[role="treeitem"]').first()
-    await groupRow.waitFor({ timeout: 15_000 })
-    await groupRow.click()
-    const sessionRow = page.locator('[role="treeitem"]').nth(1)
-    await sessionRow.waitFor({ timeout: 10_000 })
-    await sessionRow.click()
+    await page.getByRole('treeitem').first().click()
+    await page.getByRole('treeitem').nth(1).click()
     await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), {
       timeout: 15_000,
     }).toBe(1)
@@ -203,6 +244,27 @@ describe('web e2e: remote Markdown image rendering', () => {
     expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
     expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
 
+    const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT })
+    await expect.poll(() => mediaResponses.get(join(scaffold.workspaceCwd, 'valid.png'))).toBe(200)
+    await expect.poll(() => workspaceImage.evaluate(element => (element as HTMLImageElement).naturalWidth, undefined, {
+      timeout: 1_000,
+    }))
+      .toBe(1)
+    const outsideImage = page.getByRole('img', { name: 'Outside workspace image' })
+    await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
+    for (const alt of ['Oversized image', 'Missing image']) {
+      await page.getByText(alt, { exact: true }).waitFor()
+      expect(await page.getByRole('img', { name: alt }).count()).toBe(0)
+    }
+    await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor()
+    expect(mediaResponses).toEqual(new Map([
+      [join(scaffold.workspaceCwd, 'valid.png'), 200],
+      [join(scaffold.workspaceCwd, 'oversized.png'), 413],
+      [join(scaffold.persistenceRoot, 'outside.png'), 200],
+      [join(scaffold.workspaceCwd, 'missing.png'), 404],
+      [join(scaffold.workspaceCwd, 'corrupt.png'), 200],
+    ]))
+
     const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)

+ 4 - 0
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -212,6 +212,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await settings.getByLabel('上下文窗口 3').fill('131072')
     await settings.getByLabel('最大输出 token 数 3').fill('64K')
 
+    await expect.poll(
+      () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
+      { timeout: 10_000 },
+    ).toBe('已配置——输入新值可替换')
     const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
     await settings.getByRole('button', { name: '保存', exact: true }).click()

+ 36 - 31
apps/web/tests/queue-actions.e2e.ts

@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
+import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
 import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
@@ -147,38 +147,36 @@ describe('web e2e: queue row actions', () => {
     ).toBe(2)
 
     await page.setViewportSize({ width: 640, height: 1000 })
-    // Compare one layout: viewport changes can move the grid between browser round trips.
-    const { queueBox, composerBox, dockInset } = await page.evaluate(() => {
-      const queue = document.querySelector('[data-queue-dock]')
-      const composer = document.querySelector('[data-composer-card]')
-      const box = (element: Element | null) => {
-        const rect = element?.getBoundingClientRect()
-        return rect === undefined ? null : { x: rect.x, width: rect.width }
-      }
-      return {
-        queueBox: box(queue),
-        composerBox: box(composer),
-        dockInset: composer === null ? null : Number.parseFloat(
-          getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset'),
-        ),
-      }
-    })
-    expect(queueBox).not.toBeNull()
-    expect(composerBox).not.toBeNull()
-    expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
-    expect(queueBox!.x + queueBox!.width)
-      .toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
-    const queueLeftInset = queueBox!.x - composerBox!.x
-    const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
-    expect(dockInset).not.toBeNull()
-    expect(queueLeftInset).toBeCloseTo(dockInset!, 1)
-    expect(queueRightInset).toBeCloseTo(dockInset!, 1)
+    await page.locator('[data-sidebar-collapsed="true"]').waitFor()
+    // The responsive sidebar and composer settle independently; sample both
+    // rectangles in one browser task so the comparison uses one layout.
+    await vi.waitFor(async () => {
+      const metrics = await page.evaluate(() => {
+        const queue = document.querySelector('[data-queue-dock]')
+        const composer = document.querySelector('[data-composer-card]')
+        if (queue === null || composer === null) return undefined
+        const queueBox = queue.getBoundingClientRect()
+        const composerBox = composer.getBoundingClientRect()
+        return {
+          leftInset: queueBox.left - composerBox.left,
+          rightInset: composerBox.right - queueBox.right,
+          dockInset: Number.parseFloat(getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset')),
+        }
+      })
+      expect(metrics).toBeDefined()
+      expect(metrics!.leftInset).toBeGreaterThanOrEqual(0)
+      expect(metrics!.rightInset).toBeGreaterThanOrEqual(0)
+      expect(metrics!.leftInset).toBeCloseTo(metrics!.dockInset, 1)
+      expect(metrics!.rightInset).toBeCloseTo(metrics!.dockInset, 1)
+    }, { timeout: 10_000 })
     await page.setViewportSize({ width: 1680, height: 1000 })
 
     const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT })
     await editRow.getByRole('button', { name: 'Edit queued message' }).click()
     const editor = page.getByRole('textbox', { name: 'Edit queued message' })
     await editor.fill(EDITED)
+    await page.getByRole('button', { name: 'Save queued message' }).hover()
+    await page.getByRole('tooltip', { name: 'Save queued message', exact: true }).waitFor()
     const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE)
     await page.getByRole('button', { name: 'Save queued message' }).click()
@@ -187,6 +185,11 @@ describe('web e2e: queue row actions', () => {
     const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE })
     await removeRow.getByRole('button', { name: 'Remove queued message' }).click()
     await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
+    // The queue stream can remove the row before the mutation reply clears busy.
+    const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true })
+    await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true)
+    await remainingEdit.hover()
+    await page.getByRole('tooltip', { name: 'Edit queued message', exact: true }).waitFor()
 
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
@@ -224,16 +227,18 @@ describe('web e2e: queue row actions', () => {
       { timeout: 10_000 },
     ).toBe(2)
 
-    await page.getByRole('button', { name: 'Stop generating' }).click()
+    const stopButton = page.getByRole('button', { name: 'Stop generating' })
+    await stopButton.hover()
+    await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
+    await stopButton.click()
     await firstSettled
     await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
       .toBe(0)
     await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
       .toBe(2)
 
-    // Stop becomes Send under the pointer; dismiss its hover tooltip before capture.
-    await page.mouse.move(0, 0)
-    await expect.poll(() => page.getByRole('tooltip').filter({ hasText: 'Send message' }).count()).toBe(0)
+    // The disabled Send button must dismiss the active Stop tooltip without mouseleave.
+    await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
     const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
     const expanded = await captureExpandedTurnProcessAria(

+ 3 - 0
apps/web/tests/seeded-history.e2e.ts

@@ -501,6 +501,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
       const userId = userLine?.match(/^Anonymous user: ([0-9a-f-]+)/i)?.[1]
       if (userId === undefined) throw new Error('feedback command omitted the user id')
 
+      // command/done can arrive before the submit reply releases the composer.
+      await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
+      await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true)
       const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
         .split(SEED_ID).join('{{seededId}}')
         .split(userId).join('{{userId}}')

+ 13 - 2
apps/web/tests/workflow-run.e2e.ts

@@ -7,7 +7,7 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
 import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -32,6 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
   let prompt: string
+  const releaseChild = Promise.withResolvers<undefined>()
 
   const waitForParentSettlement = (): Promise<SessionId> => new Promise((resolve, reject) => {
     let dispose = (): void => {}
@@ -53,9 +54,14 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     scaffold = await launchWebScaffold({
       replayFixture: PARENT_FIXTURE,
       replayChildFixtures: [CHILD_FIXTURE],
-      paceMs: 50,
       compareReplaySession: false,
     })
+    // Keep the live child available throughout disclosure, layout, and navigation checks.
+    scaffold.ctx.on('llm/stream', async function* (options, next) {
+      const session = options.sessionId === undefined ? undefined : scaffold.ctx.sessions.get(options.sessionId)
+      if (session?.header.origin === 'subagent') await releaseChild.promise
+      yield* next()
+    }, { prepend: true })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
@@ -65,6 +71,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
   }, 120_000)
 
   afterAll(async () => {
+    releaseChild.resolve(undefined)
     await browser?.close()
     await scaffold?.close()
   })
@@ -72,6 +79,9 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
   it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live'))
     const settled = waitForParentSettlement()
+    onTestFinished(() => {
+      releaseChild.resolve(undefined)
+    })
     const input = page.locator('[data-composer-input]').first()
     await input.fill(prompt)
     await input.press('Enter')
@@ -160,6 +170,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
 
     const sessions = page.getByRole('tree', { name: 'Sessions' })
     await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
+    releaseChild.resolve(undefined)
     await settled
     await expandTurnProcesses(page)
     await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()

+ 5 - 0
apps/web/tests/workspace-management.e2e.ts

@@ -77,6 +77,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
       () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)),
       { timeout: 10_000 },
     ).not.toBeUndefined()
+    // Adoption also opens a blank Session. Its selected row must reach the
+    // browser before a later workspace action can depend on the row positions.
+    const row = page.getByRole('treeitem').filter({ hasText: name }).first()
+    const section = row.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
+    await section.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 10_000 })
   }
 
   /**

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

+ 2 - 8
docs/capability-seams.md

@@ -98,9 +98,7 @@ 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,7 +327,6 @@ 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
@@ -442,7 +439,6 @@ 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
@@ -457,7 +453,6 @@ 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
@@ -508,9 +503,8 @@ 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-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.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.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. |

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

@@ -100,9 +100,7 @@ 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"]
@@ -331,7 +329,6 @@ 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
@@ -444,7 +441,6 @@ 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
@@ -459,7 +455,6 @@ 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
@@ -510,9 +505,8 @@ 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-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.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.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: db23152618872b19524415e4b70b2322dd13bff7
-config-catalog.zh.md: f2b6aca0bdf725933d25219ae21b3c343b23f584
+config-catalog.md: 27b0ca8843cc2f65e936a1e8e9fdfd725fc4216d
+config-catalog.zh.md: 63f3dbd4dd5d7edb78d95fa92d0d04e592c71bbe

+ 2 - 3
docs/config-catalog.md

@@ -213,7 +213,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts)
+Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts)
 
 <a id="deepseek-aidsh-api-settings-controller"></a>
 
@@ -2597,7 +2597,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:243`](../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 
@@ -3496,7 +3496,6 @@ 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))

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

@@ -2599,7 +2599,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/core/system-prompt/src/index.ts:243`](../packages/core/system-prompt/src/index.ts)
+来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 
@@ -3498,7 +3498,6 @@ 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/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: 7dba6bfc14dbc7f518b70dfe3dc63a59005896e2
-module-graph.zh.md: 067b54925e01dbd897900bab21982706a68ca543
+module-graph.md: f0ebb388318ed4a0aa8896a8040a5539f5477037
+module-graph.zh.md: 0cc8ff7b9a7601102f8d708b8ebb2d17c7bb0612

+ 2 - 12
docs/module-graph.md

@@ -358,9 +358,6 @@ 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"]
@@ -882,13 +879,6 @@ 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
@@ -1080,6 +1070,7 @@ flowchart TD
   pkg_api_session_controller --> pkg_client_file_upload
   pkg_api_session_controller --> pkg_commands
   pkg_api_session_controller --> pkg_file_reference
+  pkg_api_session_controller --> pkg_fs
   pkg_api_session_controller --> pkg_jobs
   pkg_api_session_controller --> pkg_llm
   pkg_api_session_controller --> pkg_native_command
@@ -1403,7 +1394,6 @@ 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) |
@@ -1430,7 +1420,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 2 - 12
docs/module-graph.zh.md

@@ -360,9 +360,6 @@ 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"]
@@ -884,13 +881,6 @@ 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
@@ -1082,6 +1072,7 @@ flowchart TD
   pkg_api_session_controller --> pkg_client_file_upload
   pkg_api_session_controller --> pkg_commands
   pkg_api_session_controller --> pkg_file_reference
+  pkg_api_session_controller --> pkg_fs
   pkg_api_session_controller --> pkg_jobs
   pkg_api_session_controller --> pkg_llm
   pkg_api_session_controller --> pkg_native_command
@@ -1405,7 +1396,6 @@ 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) |
@@ -1432,7 +1422,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 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: 1d9b28aefc2dbd8b4a3f8b787714884edf4104ad
-tools.zh.md: c266bd21f585055d45f7bcaa02c74f8181ca4984
+tools.md: 9939e8ab9fff9fa5bd23fd370e07f6296a608824
+tools.zh.md: 52e812a35d6932a5ed00a86a3d3fe71460b26cd4

+ 0 - 25
docs/subsystems/tools.md

@@ -573,31 +573,6 @@ 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 through the supplied scoped owner.
- * @param modelCtx - Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope.
- */
-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

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

@@ -573,31 +573,6 @@ 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 through the supplied scoped owner.
- * @param modelCtx - Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope.
- */
-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: 34dc6ebd4f0291fee48d6e8c6d3ac0570cbd1e7b
-tool-catalog.zh.md: 14f7a3a4ab18e632787f1b4462d0bf504c6f3b16
+tool-catalog.md: 1a98079dcf3d30696cf2dae2895372a90377204d
+tool-catalog.zh.md: 41b5f6d4bc8b0e666e74dde5b4bb43bd0064da8e

+ 0 - 64
docs/tool-catalog.md

@@ -38,7 +38,6 @@ 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. |
 
@@ -2079,69 +2078,6 @@ 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`

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

@@ -42,7 +42,6 @@
 | `@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 在更换后端时保持稳定。 |
 
@@ -2086,69 +2085,6 @@ 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: e40089aa8b3e7e1291a01c559fc8443ab9c03849
-README.zh.md: f6e69fae4c02ae176150d79fb77bcc79fd7a4e42
+README.md: 9c298d3f2426bab92a6d602495d836ef8ee9fb44
+README.zh.md: 309a0e678968927b79d527991447847d0fa45f83

+ 0 - 1
packages/README.md

@@ -71,7 +71,6 @@ 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 |

+ 0 - 1
packages/README.zh.md

@@ -71,7 +71,6 @@ 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-*` 插件 |

+ 2 - 2
packages/api/session-controller/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/api/session-controller/README.md
-README.md: 8f7de90c2029117296bca02ce13dc5e769471434
-README.zh.md: b99589c4d7ed0a0105874bcbbd37dfd03927918d
+README.md: 7d0785c77395f9eae55656708a15a842a27f079b
+README.zh.md: b39a6a512b82cef45550d11660b7a87b2cd0b035

+ 8 - 0
packages/api/session-controller/README.md

@@ -13,6 +13,7 @@ English | [中文](README.zh.md)
 ## Table of Contents
 
 - [Use this package](#use-this-package)
+- [Session media references](#session-media-references)
 - [Configuration](#configuration)
 - [Model Experience](#model-experience)
 - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
@@ -31,6 +32,12 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream`
 
 The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 
+
+<a id="session-media-references"></a>
+## Session media references
+
+`SessionMediaReferences` mounts `GET|HEAD /api/file?path=<absolute path>` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work.
+
 -----
 
 <a id="configuration"></a>
@@ -57,6 +64,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages.
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- The image byte cap does not validate decoded dimensions or pixel count.
 - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart.
 - A failed follow resumption remains visible to the caller instead of retrying indefinitely.
 - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero.

+ 8 - 0
packages/api/session-controller/README.zh.md

@@ -13,6 +13,7 @@ kind: "package-reference"
 ## 目录
 
 - [使用本包](#use-this-package)
+- [会话媒体引用](#session-media-references)
 - [配置](#configuration)
 - [模型体验](#model-experience)
 - [已知限制与延期工作](#known-limitations-and-deferred-work)
@@ -31,6 +32,12 @@ Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 
 
 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
 
+
+<a id="session-media-references"></a>
+## 会话媒体引用
+
+当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与 sandbox CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。
+
 -----
 
 <a id="configuration"></a>
@@ -57,6 +64,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- 图片字节上限不校验解码后的尺寸或像素数。
 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。
 - follow 恢复失败会对调用方可见,而不会无限重试。
 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。

+ 7 - 2
packages/api/session-controller/package.json

@@ -71,6 +71,7 @@
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-deque": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
+    "mime-types": "^3.0.2",
     "zod": "^4.4.3"
   },
   "peerDependencies": {
@@ -84,6 +85,7 @@
     "@deepseek-ai/dsh-client-file-upload": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-native-command": "workspace:^",
@@ -125,9 +127,11 @@
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-file-upload": "workspace:^",
-    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
+    "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-native-command": "workspace:^",
@@ -150,6 +154,7 @@
     "@deepseek-ai/dsh-util-time": "workspace:^",
     "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
-    "@deepseek-ai/dsh-workspace": "workspace:^"
+    "@deepseek-ai/dsh-workspace": "workspace:^",
+    "@types/mime-types": "^3.0.1"
   }
 }

+ 2 - 0
packages/api/session-controller/src/index.ts

@@ -22,6 +22,7 @@ import { ApiSessionList } from './list.ts'
 import { buildModelCatalog } from './catalog.ts'
 import { installModelSelectionProjection } from './model-selection-projection.ts'
 import { SessionSkillCatalog } from './skill-catalog.ts'
+import { SessionMediaReferences } from './media-references.ts'
 import type {
   ModelCatalog,
   SessionAttachmentRequest,
@@ -134,6 +135,7 @@ export class SessionController extends TypertRemoteService {
     this.canOpenPath = internals.canOpenPath
       ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
     ctx.plugin(SessionFileReferences)
+    ctx.plugin(SessionMediaReferences)
     ctx.plugin(SessionSkillCatalog)
 
     ctx.on('session/created', (session) => {

+ 77 - 0
packages/api/session-controller/src/media-references.ts

@@ -0,0 +1,77 @@
+/**
+ * Authenticated GET/HEAD /api/file reads bounded file responses through
+ * the composed filesystem provider. Paths and MIME types do not restrict access;
+ * the connection service authenticates requests before this handler.
+ * @module @deepseek-ai/dsh-api-session-controller/media-references
+ */
+
+import { isAbsolute } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-connection'
+import type {} from '@deepseek-ai/dsh-attachment'
+import { FsError, type FileSystem } from '@deepseek-ai/dsh-fs'
+import mime from 'mime-types'
+
+const BASE_HEADERS = {
+  'Cache-Control': 'private, no-store',
+  'X-Content-Type-Options': 'nosniff',
+  // HTML and SVG files may be opened directly on the authenticated API origin.
+  'Content-Security-Policy': "sandbox; default-src 'none'",
+}
+
+async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise<Response> {
+  const fail = (status: number, text: string): Response =>
+    new Response(request.method === 'HEAD' ? null : text, { status, headers: BASE_HEADERS })
+  const path = new URL(request.url).searchParams.get('path')
+  if (path === null || path.length === 0) return fail(400, 'missing path')
+  if (path.includes('\0') || !isAbsolute(path)) return fail(400, 'absolute path required')
+  try {
+    const target = await fs.resolve(path, { signal: request.signal })
+    const mediaType = mime.lookup(target.displayPath) || 'application/octet-stream'
+    const headers: Record<string, string> = {
+      ...BASE_HEADERS,
+      'Content-Type': mediaType,
+    }
+    if (request.method === 'HEAD') {
+      const info = await fs.stat(target, request.signal)
+      if (info === undefined) return fail(404, 'not found')
+      if (info.type !== 'file') return fail(403, 'not a regular file')
+      if (info.size !== undefined) {
+        if (info.size > maxBytes) return fail(413, 'file exceeds byte limit')
+        headers['Content-Length'] = String(info.size)
+      }
+      return new Response(null, { headers })
+    }
+    const bytes = await fs.readBytes(target, request.signal, maxBytes)
+    headers['Content-Length'] = String(bytes.byteLength)
+    return new Response(bytes.slice(), { headers })
+  } catch (error: unknown) {
+    if (!(error instanceof FsError)) throw error
+    const statuses: Partial<Record<FsError['code'], number>> = {
+      FS_NOT_FOUND: 404,
+      FS_NOT_REGULAR_FILE: 403,
+      FS_PERMISSION_DENIED: 403,
+      FS_SANDBOX_DENIED: 403,
+      FS_TOO_LARGE: 413,
+      FS_ABORTED: 499,
+    }
+    return fail(statuses[error.code] ?? 500, error.code)
+  }
+}
+
+/**
+ * File-display contribution. The connection service supplies authentication;
+ * `ctx.fs` supplies the execution world's paths, reads, and access policy.
+ */
+export const SessionMediaReferences = {
+  inject: ['connection', 'fs', 'attachments'],
+  apply(ctx: Context): void {
+    const maxBytes = ctx.attachments.imageLimits.maxImageBytes
+    ctx.effect(() => ctx.connection.fetch.register({
+      path: '/api/file',
+      methods: ['GET', 'HEAD'],
+      requestBody: 'buffered',
+      fetch: request => serveFile(request, ctx.fs, maxBytes),
+    }), 'session-controller: /api/file')
+  },
+}

+ 224 - 0
packages/api/session-controller/tests/media-references.host.spec.ts

@@ -0,0 +1,224 @@
+import { appendFile, mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
+import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { SessionMediaReferences } from '../src/media-references.ts'
+
+const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4])
+const DEFAULT_LIMIT = 20 * 1024 * 1024
+
+async function responseBytes(response: Response): Promise<Uint8Array> {
+  return new Uint8Array(await response.arrayBuffer())
+}
+
+describe('SessionMediaReferences /api/file', () => {
+  let root: string
+  const contexts: Context[] = []
+
+  beforeEach(async () => {
+    root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-')))
+  })
+
+  afterEach(async () => {
+    await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+    await rm(root, { recursive: true, force: true })
+  })
+
+  async function mount(maxBytes = DEFAULT_LIMIT) {
+    const ctx = new Context()
+    contexts.push(ctx)
+    let handler: ((request: Request) => Promise<Response>) | undefined
+    const unregister = vi.fn(() => {})
+    ctx.provide('connection', {
+      fetch: {
+        register: (registered: { fetch: (request: Request) => Promise<Response> }) => {
+          handler = registered.fetch
+          return unregister
+        },
+      },
+    } as never)
+    ctx.provide('attachments', { imageLimits: { maxImageBytes: maxBytes } } as never)
+    await ctx.plugin(LocalFileSystem, { cwd: root }).await()
+    await ctx.plugin(SessionMediaReferences).await()
+    const raw = (url: string, init?: RequestInit) => {
+      if (handler === undefined) throw new Error('route not registered')
+      return handler(new Request(url, init))
+    }
+    return {
+      call: (path: string, init?: RequestInit) => raw(`http://127.0.0.1/api/file?path=${encodeURIComponent(path)}`, init),
+      raw,
+      fs: ctx.fs as LocalFileSystem,
+      unregister,
+      dispose: () => ctx.fiber.dispose(),
+    }
+  }
+
+  it('serves the inclusive image cap and refuses larger images for GET, HEAD and Range', async () => {
+    const route = await mount(PNG_BYTES.length)
+    const path = join(root, 'bounded.png')
+    await writeFile(path, PNG_BYTES)
+    expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES)
+    await appendFile(path, new Uint8Array(1))
+    expect((await route.call(path)).status).toBe(413)
+    expect((await route.call(path, { headers: { range: 'bytes=0-0' } })).status).toBe(413)
+    const head = await route.call(path, { method: 'HEAD' })
+    expect(head.status).toBe(413)
+    expect(head.body).toBeNull()
+  })
+
+  it('rejects a sparse 1 GiB image before content I/O', async () => {
+    const route = await mount()
+    const inspect = vi.fn()
+    route.fs.internals.inspectReadBytesAfterStat = inspect
+    const path = join(root, 'huge.png')
+    const handle = await open(path, 'w')
+    try {
+      await handle.truncate(1024 * 1024 * 1024)
+    } finally {
+      await handle.close()
+    }
+    expect((await route.call(path)).status).toBe(413)
+    expect(inspect).not.toHaveBeenCalled()
+  })
+
+  it('uses the filesystem byte reader to reject post-stat image growth', async () => {
+    const route = await mount(PNG_BYTES.length)
+    const path = join(root, 'growing.png')
+    await writeFile(path, PNG_BYTES)
+    route.fs.internals.inspectReadBytesAfterStat = async () => {
+      await appendFile(path, new Uint8Array(1))
+    }
+    expect((await route.call(path)).status).toBe(413)
+  })
+
+  it.each([
+    ['png', 'image/png'], ['svg', 'image/svg+xml'], ['mp4', 'video/mp4'], ['mp3', 'audio/mpeg'],
+    ['txt', 'text/plain'], ['html', 'text/html'], ['bin', 'application/octet-stream'], ['', 'application/octet-stream'],
+  ])('serves .%s files with their MIME type and response protections', async (extension, mediaType) => {
+    const route = await mount()
+    const path = join(root, `file${extension === '' ? '' : `.${extension}`}`)
+    await writeFile(path, PNG_BYTES)
+    const response = await route.call(path)
+    expect(response.status).toBe(200)
+    expect(response.headers.get('content-type')).toBe(mediaType)
+    expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
+    expect(response.headers.get('cache-control')).toBe('private, no-store')
+    expect(response.headers.get('x-content-type-options')).toBe('nosniff')
+    expect(response.headers.get('content-security-policy')).toBe("sandbox; default-src 'none'")
+    expect(await responseBytes(response)).toEqual(PNG_BYTES)
+  })
+
+  it.each(['mp4', 'mp3', 'bin'])('applies the attachment byte cap to .%s files', async (extension) => {
+    const route = await mount(PNG_BYTES.length)
+    const path = join(root, `file.${extension}`)
+    await writeFile(path, PNG_BYTES)
+    expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES)
+    await appendFile(path, new Uint8Array(1))
+    expect((await route.call(path)).status).toBe(413)
+    expect((await route.call(path, { method: 'HEAD' })).status).toBe(413)
+  })
+
+  it('ignores Range headers and returns complete bodies without advertising ranges', async () => {
+    const route = await mount()
+    const path = join(root, 'clip.mp4')
+    await writeFile(path, PNG_BYTES)
+    for (const range of ['bytes=0-3', 'bytes=-4', 'bytes=999-', 'bytes=abc', 'items=0-0', 'bytes=0-1,3-4']) {
+      const response = await route.call(path, { headers: { range } })
+      expect(response.status).toBe(200)
+      expect(response.headers.get('accept-ranges')).toBeNull()
+      expect(response.headers.get('content-range')).toBeNull()
+      expect(await responseBytes(response)).toEqual(PNG_BYTES)
+    }
+  })
+
+  it('answers HEAD without reading content and reports missing and non-regular files', async () => {
+    const route = await mount()
+    const path = join(root, 'image.png')
+    await writeFile(path, PNG_BYTES)
+    const read = vi.spyOn(route.fs, 'readBytes')
+    const response = await route.call(path, { method: 'HEAD', headers: { range: 'bytes=0-3' } })
+    expect(response.status).toBe(200)
+    expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
+    expect(response.body).toBeNull()
+    expect(read).not.toHaveBeenCalled()
+    expect((await route.call(join(root, 'missing'), { method: 'HEAD' })).status).toBe(404)
+    expect((await route.call(root, { method: 'HEAD' })).status).toBe(403)
+    vi.spyOn(route.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v1') })
+    expect((await route.call(path, { method: 'HEAD' })).headers.get('content-length')).toBeNull()
+  })
+
+  it('rejects malformed paths, absent files, and directories', async () => {
+    const route = await mount()
+    expect((await route.raw('http://127.0.0.1/api/file')).status).toBe(400)
+    for (const path of ['', 'relative.png', '/a\0b.png']) {
+      expect((await route.call(path)).status).toBe(400)
+    }
+    const head = await route.call('', { method: 'HEAD' })
+    expect(head.status).toBe(400)
+    expect(head.body).toBeNull()
+    expect((await route.call(join(root, 'missing.png'))).status).toBe(404)
+    await mkdir(join(root, 'frames.png'))
+    expect((await route.call(join(root, 'frames.png'))).status).toBe(403)
+  })
+
+  it('reads files and symlink targets outside the default cwd without a workspace registry', async () => {
+    const route = await mount()
+    const outside = await mkdtemp(join(tmpdir(), 'dsh-media-outside-'))
+    try {
+      const path = join(outside, 'image.png')
+      await writeFile(path, PNG_BYTES)
+      expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES)
+      const link = join(root, 'linked.png')
+      await symlink(path, link)
+      expect(await responseBytes(await route.call(link))).toEqual(PNG_BYTES)
+    } finally {
+      await rm(outside, { recursive: true, force: true })
+    }
+  })
+
+  it.skipIf(process.platform === 'win32')('rejects a FIFO before opening it', async () => {
+    const route = await mount()
+    const path = join(root, 'stream.png')
+    const { execFile } = await import('node:child_process')
+    const { promisify } = await import('node:util')
+    await promisify(execFile)('mkfifo', [path])
+    expect((await route.call(path)).status).toBe(403)
+  })
+
+  it('reads opaque remote targets through ctx.fs and preserves provider failures', async () => {
+    const route = await mount()
+    const target = { targetKey: FsTargetKey('opaque-remote-id'), displayPath: '/remote/photo.png' }
+    vi.spyOn(route.fs, 'resolve').mockResolvedValue(target)
+    const read = vi.spyOn(route.fs, 'readBytes').mockResolvedValue(PNG_BYTES)
+    expect(await responseBytes(await route.call('/remote/photo.png'))).toEqual(PNG_BYTES)
+    expect(read).toHaveBeenCalledWith(target, expect.any(AbortSignal), DEFAULT_LIMIT)
+    for (const [code, status] of [
+      ['FS_PERMISSION_DENIED', 403], ['FS_SANDBOX_DENIED', 403], ['FS_NOT_FOUND', 404],
+      ['FS_NOT_REGULAR_FILE', 403], ['FS_TOO_LARGE', 413], ['FS_IO_ERROR', 500],
+    ] as const) {
+      read.mockRejectedValueOnce(new FsError('provider rejected read', code))
+      expect((await route.call('/remote/photo.png')).status).toBe(status)
+    }
+    read.mockRejectedValueOnce(new Error('provider bug'))
+    await expect(route.call('/remote/photo.png')).rejects.toThrow('provider bug')
+  })
+
+  it('serves an empty file and respects an aborted request', async () => {
+    const route = await mount()
+    const path = join(root, 'empty.png')
+    await writeFile(path, '')
+    const response = await route.call(path)
+    expect(response.headers.get('content-length')).toBe('0')
+    expect(await response.text()).toBe('')
+    expect((await route.call(path, { signal: AbortSignal.abort() })).status).toBe(499)
+  })
+
+  it('unregisters the route on disposal', async () => {
+    const route = await mount()
+    await route.dispose()
+    expect(route.unregister).toHaveBeenCalledTimes(1)
+  })
+})

+ 3 - 0
packages/api/session-controller/tsconfig.host.json

@@ -17,6 +17,7 @@
     "src/file-references.ts",
     "src/history.ts",
     "src/list.ts",
+    "src/media-references.ts",
     "src/model-selection-projection.ts",
     "src/skill-catalog.ts"
   ],
@@ -30,8 +31,10 @@
     { "path": "../../context/file-reference" },
     { "path": "../../attachment/attachment" },
     { "path": "../../client/file-upload/tsconfig.host.json" },
+    { "path": "../../client/connection/tsconfig.host.json" },
     { "path": "../../interaction/permission-presets" },
     { "path": "../../jobs/jobs" },
+    { "path": "../../fs/fs" },
     { "path": "../../llm/llm" },
     { "path": "../../util/deque" },
     { "path": "../../util/native-command" },

+ 25 - 1
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -1,7 +1,7 @@
 import { Fragment, memo, useMemo } from 'react'
 import type { ReactNode } from 'react'
 import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { AssistantBlock } from '../contract/snapshot.ts'
 import { markdownLabels } from '../markdown-labels.ts'
@@ -9,6 +9,22 @@ import { ReasoningRow } from './ReasoningRow.tsx'
 import { useSearchableHidden } from './searchable-hidden.ts'
 import css from './AssistantMarkdown.module.css'
 
+/**
+ * Map one authored media destination to the same-origin workspace-file URL.
+ * @param protocol - `window.location.protocol` at render time.
+ * @param origin - `window.location.origin` at render time.
+ * @param value - The authored markdown destination, exactly as written.
+ * @returns The API URL for an absolute POSIX path on an HTTP(S) page, or
+ * undefined when the destination cannot be a Host-served local file
+ * (non-HTTP transport such as Electron `file://`, protocol-relative or
+ * relative destinations).
+ */
+export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined {
+  if (protocol !== 'http:' && protocol !== 'https:') return undefined
+  if (value.length === 0 || !value.startsWith('/') || value.startsWith('//')) return undefined
+  return `${origin}/api/file?path=${encodeURIComponent(value)}`
+}
+
 export interface AssistantMarkdownProps {
   blocks: readonly AssistantBlock[]
   streaming: boolean
@@ -34,6 +50,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
   // Stable per locale revision (t identity changes on switch): a fresh object
   // per render would rebuild MarkdownText's component table every chunk.
   const labels = useMemo(() => markdownLabels(t), [t])
+  // Local media paths in the closing prose rewrite to the same-origin file
+  // API (policy re-validation lives host-side). The vocabulary identity is
+  // stable per page load because MarkdownText memoizes on it.
+  const pathImages = useMemo<MarkdownPathImages>(() => {
+    const { protocol, origin } = window.location
+    return { resolve: value => localPathMediaUrl(protocol, origin, value) }
+  }, [])
   const last = blocks.length - 1
   // Tool-call heads render as tool rows in the chat view's grouping pass, so
   // a node that is only those heads (or empty) would paint an empty root
@@ -55,6 +78,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
             streaming={streaming}
             labels={labels}
             fileMentions={mentions}
+            pathImages={pathImages}
           />,
         )
         break

+ 74 - 0
packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx

@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx'
+import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts'
+import type { AssistantBlock } from '../src/client/contract/snapshot.ts'
+
+afterEach(cleanup)
+
+const t = ((_key: string) => 'label') as unknown as ChatViewSlotProps['t']
+const renderMessageImages = (() => null) as unknown as ChatNodeOwnerProps['renderMessageImages']
+
+function textBlock(text: string): AssistantBlock {
+  return { kind: 'text', text }
+}
+
+const ORIGIN = 'http://127.0.0.1:3080'
+
+describe('localPathMediaUrl', () => {
+  it('maps an absolute POSIX path on an HTTP page to the file API', () => {
+    expect(localPathMediaUrl('http:', ORIGIN, '/tmp/graph.png'))
+      .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/graph.png')}`)
+    expect(localPathMediaUrl('https:', 'https://127.0.0.1:3080', '/tmp/graph.png'))
+      .toBe(`https://127.0.0.1:3080/api/file?path=${encodeURIComponent('/tmp/graph.png')}`)
+  })
+
+  it('keeps non-HTTP transports inert', () => {
+    expect(localPathMediaUrl('file:', 'file:///app', '/tmp/graph.png')).toBeUndefined()
+    expect(localPathMediaUrl('ws:', ORIGIN, '/tmp/graph.png')).toBeUndefined()
+  })
+
+  it('keeps destinations that cannot be Host-served local files inert', () => {
+    expect(localPathMediaUrl('http:', ORIGIN, '')).toBeUndefined()
+    expect(localPathMediaUrl('http:', ORIGIN, '//cdn.example.com/x.png')).toBeUndefined()
+    expect(localPathMediaUrl('http:', ORIGIN, 'relative.png')).toBeUndefined()
+    expect(localPathMediaUrl('http:', ORIGIN, 'C:\\tmp\\x.png')).toBeUndefined()
+  })
+
+  it('encodes the full path including spaces', () => {
+    expect(localPathMediaUrl('http:', ORIGIN, '/tmp/my graph.png'))
+      .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/my graph.png')}`)
+  })
+})
+
+describe('AssistantMarkdown local-path images', () => {
+  it('renders a local image path in closing prose through the same-origin API', () => {
+    const { container } = render(
+      <AssistantMarkdown
+        blocks={[textBlock('See ![diagram](/tmp/graph.png) for the layout.')]}
+        streaming={false}
+        renderMessageImages={renderMessageImages}
+        t={t}
+      />,
+    )
+    const image = container.querySelector('img')
+    expect(image?.getAttribute('alt')).toBe('diagram')
+    const url = new URL(image?.getAttribute('src') ?? '')
+    expect(url.pathname).toBe('/api/file')
+    expect(url.searchParams.get('path')).toBe('/tmp/graph.png')
+  })
+
+  it('keeps non-absolute destinations inert', () => {
+    const { container } = render(
+      <AssistantMarkdown
+        blocks={[textBlock('See ![diagram](relative.png).')]}
+        streaming={false}
+        renderMessageImages={renderMessageImages}
+        t={t}
+      />,
+    )
+    expect(container.querySelector('img')).toBeNull()
+    expect(container.textContent).toContain('diagram')
+  })
+})

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: e06270a339a23fa17fb9d51642317fcf2aec6c0f
-README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024
+README.md: 06667e15dd44c4bcaaea9212d52bb0eda8867c6e
+README.zh.md: ce2b183e6fffcf00485856c8eed777368b446ac6

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a
 
 Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
 
-While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
+Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
 
 <a id="temporary-composer-entries"></a>
 ## Temporary composer entries

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
 
-普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
+Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
 
 <a id="temporary-composer-entries"></a>
 ## 临时 composer entry

+ 5 - 3
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -344,6 +344,8 @@ export const InputBar = memo(function InputBar({
   // state keeps plain Send. A continuable child keeps Send primary and
   // exposes Stop independently.
   const primaryStops = running && subagent === null && (empty || blocked !== undefined)
+  // Disabled native buttons may omit mouseleave; their tooltip must close from state.
+  const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending
   const interruptible = running && continuable
   const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)
   const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/')
@@ -526,7 +528,7 @@ export const InputBar = memo(function InputBar({
             {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
             <ContextMeter useProjection={useProjection} t={t} />
             {interruptible && (
-              <Tooltip label={t('input.stop')} side="top" delayMs={500}>
+              <Tooltip label={t('input.stop')} side="top" delayMs={500} disabled={stop === undefined}>
                 <button
                   type="button"
                   className={css.primary}
@@ -541,12 +543,12 @@ export const InputBar = memo(function InputBar({
                 </button>
               </Tooltip>
             )}
-            <Tooltip label={primaryLabel} side="top" delayMs={500}>
+            <Tooltip label={primaryLabel} side="top" delayMs={500} disabled={primaryDisabled}>
               <button
                 type="button"
                 className={css.primary}
                 aria-label={primaryLabel}
-                disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending}
+                disabled={primaryDisabled}
                 onMouseDown={keepFocus}
                 onClick={onPrimary}
               >

+ 18 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -738,6 +738,24 @@ describe('Enter semantics', () => {
 })
 
 describe('running and lock semantics', () => {
+  it('dismisses the Stop tooltip when an empty composer becomes idle', () => {
+    vi.useFakeTimers()
+    try {
+      const { button, view, session } = bench({ running: true })
+      fireEvent.mouseEnter(button)
+      act(() => { vi.advanceTimersByTime(500) })
+      expect(view.getByRole('tooltip').textContent).toBe('停止生成')
+
+      // Disabling a hovered native button need not deliver mouseleave.
+      act(() => { session.set(snapshotOf({ running: false })) })
+      expect(button.disabled).toBe(true)
+      expect(button.getAttribute('aria-label')).toBe('发送消息')
+      expect(view.queryByRole('tooltip')).toBeNull()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it('running switches the primary between Stop and Queue Send with the draft', async () => {
     const { textarea, button, stop, sink, shell } = bench({ running: true })
     expect(textarea.getAttribute('aria-disabled')).not.toBe('true')

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 6e4473893fa9e9a32cdec36125b3c0e7692dd038
-README.zh.md: 228ae6f9430d1ae403843123aa279e71147680c5
+README.md: 18b136c78539d8ba6da3b17239112967faf63602
+README.zh.md: f91435e44a5ca79b245fde3dd025a732d35e54e9

+ 2 - 2
packages/client/ui-primitives/README.md

@@ -64,11 +64,11 @@ Writing your own component in your own package is fine when the need is genuinel
 
 ### Controls and icons
 
-The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order.
+The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order.
 
 ### Rendering agent output
 
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
 
 
 ### Localizing copy

+ 2 - 2
packages/client/ui-primitives/README.zh.md

@@ -64,11 +64,11 @@ kind: "package-library"
 
 ### 控件与图标
 
-上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。
+上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。
 
 ### 渲染 agent 输出
 
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
 
 
 ### 本地化文案

+ 4 - 4
packages/client/ui-primitives/src/ConnectionIndicator.module.css

@@ -90,13 +90,13 @@
 }
 
 @keyframes reveal-second-dot {
-  0%, 33.32% { visibility: hidden; }
-  33.33%, 100% { visibility: visible; }
+  0%, 33.32% { opacity: 0; }
+  33.33%, 100% { opacity: 1; }
 }
 
 @keyframes reveal-third-dot {
-  0%, 66.65% { visibility: hidden; }
-  66.66%, 100% { visibility: visible; }
+  0%, 66.65% { opacity: 0; }
+  66.66%, 100% { opacity: 1; }
 }
 
 @media (prefers-reduced-motion: reduce) {

+ 1 - 1
packages/client/ui-primitives/src/index.ts

@@ -64,7 +64,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx'
 export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
-export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx'
+export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'
 export { extractMarkdownPlainText } from './markdown/plain-text.ts'
 export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
 export * from './icons/index.tsx'

+ 14 - 7
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -19,17 +19,18 @@ import {
   collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
   wrapBlockChildren,
 } from './render.tsx'
-import type { MarkdownFileMentions, MarkdownLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
+import type { MarkdownFileMentions, MarkdownLabels, MarkdownPathImages, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
 import 'katex/dist/katex.min.css'
 import css from './MarkdownText.module.css'
 
-export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './render.tsx'
+export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './render.tsx'
 
 /** One settled full render: parse with math, resolve references, append the footnote section. */
 function renderSettled(
   text: string,
   labels: MarkdownLabels,
   fileMentions: MarkdownFileMentions | undefined,
+  pathImages: MarkdownPathImages | undefined,
 ): ReactNode[] {
   const root = parseGfmWithMath(text)
   const targets = createReferenceTargets()
@@ -38,6 +39,7 @@ function renderSettled(
     streaming: false,
     labels,
     fileMentions,
+    pathImages,
     targets,
     footnoteOrder: [],
     footnoteCounts: new Map(),
@@ -106,6 +108,7 @@ class StreamingRenderer {
         streaming: true,
         labels: this.labels,
         fileMentions: undefined,
+        pathImages: undefined,
         targets: frameTargets,
         footnoteOrder: this.frozenFootnoteOrder,
         footnoteCounts: this.frozenFootnoteCounts,
@@ -124,6 +127,7 @@ class StreamingRenderer {
       streaming: true,
       labels: this.labels,
       fileMentions: undefined,
+      pathImages: undefined,
       targets: frameTargets,
       footnoteOrder: [...this.frozenFootnoteOrder],
       footnoteCounts: new Map(this.frozenFootnoteCounts),
@@ -150,32 +154,35 @@ class StreamingRenderer {
  * `labels` forwards localized fence and footnote chrome — pass a
  * reference-stable object (memoized per locale revision), because a new
  * identity discards the streaming render cache mid-message. `fileMentions`
- * links inline-code tokens its resolver recognizes as real files; this is
- * the single streaming gate — it applies to settled renders only, because a
+ * links inline-code tokens its resolver recognizes as real files, and
+ * `pathImages` rewrites image destinations that are local file paths into
+ * displayable URLs its resolver vouches for; both vocabularies are the
+ * single streaming gate — they apply to settled renders only, because a
  * streaming message's vocabulary is not final and frozen cached elements
  * must not bake in handlers that could go stale.
  * @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
  * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
  * images render directly.
  */
-export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions }: {
+export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions, pathImages }: {
   text: string
   streaming?: boolean
   labels: MarkdownLabels
   fileMentions?: MarkdownFileMentions | undefined
+  pathImages?: MarkdownPathImages | undefined
 }) {
   const streamRef = useRef<StreamingRenderer | null>(null)
   const streamLabelsRef = useRef<MarkdownLabels>(labels)
   const children = useMemo(() => {
     if (!streaming) {
       streamRef.current = null
-      return renderSettled(text, labels, fileMentions)
+      return renderSettled(text, labels, fileMentions, pathImages)
     }
     if (streamRef.current === null || streamLabelsRef.current !== labels) {
       streamRef.current = new StreamingRenderer(labels)
       streamLabelsRef.current = labels
     }
     return streamRef.current.render(text)
-  }, [text, streaming, labels, fileMentions])
+  }, [text, streaming, labels, fileMentions, pathImages])
   return <div className={css.markdown}>{children}</div>
 })

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

@@ -16,7 +16,7 @@
  * may add node types this renderer has no mapping for.
  */
 
-import { Fragment, createElement } from 'react'
+import { Fragment, createElement, useState } from 'react'
 import type { Key, ReactNode } from 'react'
 import clsx from 'clsx'
 import type * as Md from 'mdast'
@@ -69,6 +69,35 @@ function remoteImageUrl(url: string): string | undefined {
   }
 }
 
+/** Protocols a vocabulary-rewritten image destination may carry. */
+function vocabularyImageUrl(url: string): string | undefined {
+  try {
+    const protocol = new URL(url).protocol
+    return protocol === 'http:' || protocol === 'https:' || protocol === 'blob:' || protocol === 'data:'
+      ? url
+      : undefined
+  } catch {
+    // A vocabulary result must be an absolute URL; anything else stays a miss.
+    return undefined
+  }
+}
+
+/**
+ * The displayable source for one image destination: absolute HTTP(S) as
+ * authored, otherwise the context's local-path vocabulary when it vouches for
+ * the destination. Either miss leaves the authored fallback (alt text) to the
+ * caller.
+ * @param url - The authored markdown destination.
+ * @param pathImages - Rewriting vocabulary, when the render pass has one.
+ * @returns The displayable image URL, or undefined.
+ */
+function imageSource(url: string, pathImages: MarkdownPathImages | undefined): string | undefined {
+  const remote = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
+  if (remote !== undefined) return remote
+  const rewritten = pathImages?.resolve(url)
+  return rewritten === undefined ? undefined : vocabularyImageUrl(rewritten)
+}
+
 /** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */
 export interface ReferenceTargets {
   /** Link/image definitions keyed by upper-cased identifier. */
@@ -107,6 +136,24 @@ export function collectReferenceTargets(
   }
 }
 
+/**
+ * Local-path image vocabulary for image destinations: the owner maps an
+ * authored destination that fails the remote-URL allowlist (an absolute local
+ * file path, for example) to a displayable URL it can vouch for. Absent
+ * wherever no such vocabulary exists, authored local destinations keep their
+ * documented fallback (the image's alt text). Rewritten destinations must be
+ * absolute; the renderer re-checks their protocol before emitting them.
+ */
+export interface MarkdownPathImages {
+  /**
+   * Resolve one authored image destination.
+   * @param value - The destination exactly as the markdown author wrote it.
+   * @returns A displayable absolute URL, or undefined when the destination
+   * names no displayable image — it then stays inert alt text.
+   */
+  resolve(value: string): string | undefined
+}
+
 /**
  * File-mention affordance for inline code: the owner resolves an authored
  * token to the file it names, using its own vocabulary of real files — the
@@ -135,6 +182,8 @@ export interface MarkdownRenderContext {
   readonly inBlockquote?: boolean
   /** Inline-code file mentions; absent wherever no opener vocabulary exists. */
   readonly fileMentions: MarkdownFileMentions | undefined
+  /** Local-path image vocabulary; absent wherever no rewriting owner exists. */
+  readonly pathImages: MarkdownPathImages | undefined
   /** Inside an anchor's children: interactive mentions must not nest there. */
   readonly inLink?: boolean
   /** Reference targets visible to this pass. */
@@ -293,7 +342,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
     case 'linkReference':
       return renderLinkReference(node, key, context)
     case 'image':
-      return renderImage(node.url, node.alt ?? '', key)
+      return renderImage(node.url, node.alt ?? '', key, context)
     case 'imageReference':
       return renderImageReference(node, key, context)
     case 'footnoteReference':
@@ -513,17 +562,24 @@ function inlineCodeHttpUrl(value: string): string | undefined {
   }
 }
 
-function renderImage(url: string, alt: string, key: Key): ReactNode {
-  const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
+function renderImage(url: string, alt: string, key: Key, context: MarkdownRenderContext): ReactNode {
+  const imageSrc = imageSource(url, context.pathImages)
   if (imageSrc === undefined) {
     return <span key={key} className={css.imageAlt}>{alt}</span>
   }
+  return <MarkdownImage key={`${key}:${imageSrc}`} src={imageSrc} alt={alt} destination={url} />
+}
+
+/** Failed loads retain the authored alt or destination; a new source remounts the image. */
+function MarkdownImage({ src, alt, destination }: { src: string; alt: string; destination: string }): ReactNode {
+  const [failed, setFailed] = useState(false)
+  if (failed) return <span className={css.imageAlt}>{alt || destination}</span>
   return (
     <img
-      key={key}
       className={css.image}
-      src={imageSrc}
+      src={src}
       alt={alt}
+      onError={() => { setFailed(true) }}
       loading="lazy"
       decoding="async"
       referrerPolicy="no-referrer"
@@ -562,7 +618,7 @@ function renderImageReference(
 ): ReactNode {
   const definition = context.targets.definitions.get(node.identifier.toUpperCase())
   if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}`
-  return renderImage(definition.url, node.alt ?? '', key)
+  return renderImage(definition.url, node.alt ?? '', key, context)
 }
 
 function renderFootnoteReference(

+ 104 - 0
packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx

@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { MarkdownText } from './markdown-test-components.tsx'
+import type { MarkdownPathImages } from '../src/markdown/MarkdownText.tsx'
+
+afterEach(cleanup)
+
+const LOCAL_IMAGE = '![diagram](/tmp/graph.png)'
+
+const mapping = (value: string): string | undefined =>
+  value === '/tmp/graph.png' ? 'https://cdn.example.com/graph.png' : undefined
+
+describe('MarkdownText local-path images', () => {
+  it('renders authored alt text when no path vocabulary exists', () => {
+    const { container } = render(<MarkdownText text={LOCAL_IMAGE} />)
+    expect(container.querySelector('img')).toBeNull()
+    expect(screen.getByText('diagram')).toBeTruthy()
+  })
+
+  it('rewrites a local image path through the vocabulary', () => {
+    const pathImages: MarkdownPathImages = { resolve: mapping }
+    const { container } = render(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
+    const image = container.querySelector('img')
+    expect(image?.getAttribute('src')).toBe('https://cdn.example.com/graph.png')
+    expect(image?.getAttribute('alt')).toBe('diagram')
+  })
+
+  it.each(['diagram', ''])('shows authored text after an image fails with alt %j', (alt) => {
+    const pathImages: MarkdownPathImages = { resolve: mapping }
+    const { container } = render(
+      <MarkdownText text={`![${alt}](/tmp/graph.png)`} pathImages={pathImages} />,
+    )
+    fireEvent.error(screen.getByAltText(alt))
+    expect(container.querySelector('img')).toBeNull()
+    expect(container.textContent).toBe(alt || '/tmp/graph.png')
+  })
+
+  it('loads a replacement destination after the preceding image failed', () => {
+    const pathImages: MarkdownPathImages = { resolve: value => `https://example.com${value}` }
+    const { container, rerender } = render(
+      <MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />,
+    )
+    fireEvent.error(screen.getByRole('img'))
+    rerender(<MarkdownText text="![](/tmp/replacement.png)" pathImages={pathImages} />)
+    expect(container.querySelector('img')?.getAttribute('src'))
+      .toBe('https://example.com/tmp/replacement.png')
+  })
+
+  it('keeps the alt fallback when the vocabulary misses', () => {
+    const pathImages: MarkdownPathImages = { resolve: () => undefined }
+    const { container } = render(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
+    expect(container.querySelector('img')).toBeNull()
+    expect(screen.getByText('diagram')).toBeTruthy()
+  })
+
+  it('accepts data and blob vocabulary results', () => {
+    const data: MarkdownPathImages = { resolve: () => 'data:image/png;base64,AAAA' }
+    const blob: MarkdownPathImages = { resolve: () => 'blob:https://example.com/id' }
+    const dataRender = render(<MarkdownText text={LOCAL_IMAGE} pathImages={data} />)
+    const blobRender = render(<MarkdownText text={LOCAL_IMAGE} pathImages={blob} />)
+    expect(dataRender.container.querySelector('img')?.getAttribute('src'))
+      .toBe('data:image/png;base64,AAAA')
+    expect(blobRender.container.querySelector('img')?.getAttribute('src'))
+      .toBe('blob:https://example.com/id')
+  })
+
+  it('rejects non-absolute vocabulary results', () => {
+    for (const result of ['relative.png', '/api/image?path=%2Ftmp%2Fx.png', 'ftp://host/x.png']) {
+      const pathImages: MarkdownPathImages = { resolve: () => result }
+      const { container, unmount } = render(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
+      expect(container.querySelector('img')).toBeNull()
+      unmount()
+    }
+  })
+
+  it('leaves remote images untouched even when the vocabulary maps them', () => {
+    const remote = '![remote](https://example.com/a.png)'
+    const pathImages: MarkdownPathImages = { resolve: () => 'https://cdn.example.com/b.png' }
+    const { container } = render(<MarkdownText text={remote} pathImages={pathImages} />)
+    expect(container.querySelector('img')?.getAttribute('src')).toBe('https://example.com/a.png')
+  })
+
+  it('rewrites reference-style local image destinations', () => {
+    const reference = ['![diagram][fig]', '', '[fig]: /tmp/graph.png'].join('\n')
+    const pathImages: MarkdownPathImages = { resolve: mapping }
+    const { container } = render(<MarkdownText text={reference} pathImages={pathImages} />)
+    expect(container.querySelector('img')?.getAttribute('src'))
+      .toBe('https://cdn.example.com/graph.png')
+  })
+
+  it('applies the vocabulary only to settled renders, never while streaming', () => {
+    const pathImages: MarkdownPathImages = { resolve: mapping }
+    const { container, rerender } = render(
+      <MarkdownText text={LOCAL_IMAGE} streaming pathImages={pathImages} />,
+    )
+    // Streaming messages may still grow, so their prose keeps the inert alt
+    // fallback until the settled pass self-heals it.
+    expect(container.querySelector('img')).toBeNull()
+    rerender(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
+    expect(container.querySelector('img')?.getAttribute('src'))
+      .toBe('https://cdn.example.com/graph.png')
+  })
+})

+ 1 - 0
packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx

@@ -22,6 +22,7 @@ function makeContext(): MarkdownRenderContext {
     streaming: false,
     labels: markdownLabels,
     fileMentions: undefined,
+    pathImages: undefined,
     targets: createReferenceTargets(),
     footnoteOrder: [],
     footnoteCounts: new Map(),

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

@@ -136,7 +136,6 @@ 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', 'show_widget', 'skill', 'spawn_teammate',
+      'session_event_trace', 'session_search', 'session_trace', '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', 'widget_guidelines', 'workflow', 'write',
+      'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'write',
     ])
     // Every tool carries a JSON-Schema `parameters` object (what the model sees).
     for (const entry of catalog) {

+ 45 - 8
packages/experimental/agent-team/tests/persistence.spec.ts

@@ -4,7 +4,7 @@ import { rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -14,6 +14,7 @@ import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-su
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import TeamService, { TeamId, TeamMessageId } from '../src/index.ts'
+import type { TeamMailbox } from '../src/mailbox.ts'
 import { teamProjectionDefinition } from '../src/projection.ts'
 import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts'
 import { TestSessionQuery } from './test-session-query.ts'
@@ -50,6 +51,12 @@ async function storedEvents(ctx: Context, id: SessionId): Promise<readonly Sessi
   }
 }
 
+/** Await mailbox acknowledgements through their flush and dispatch completion. */
+async function settleMailbox(ctx: Context): Promise<void> {
+  const { mailbox } = ctx.agentTeams as unknown as { readonly mailbox: TeamMailbox }
+  await Promise.all(mailbox.pendingDispatches())
+}
+
 async function disposeContext(ctx: Context): Promise<void> {
   try {
     await ctx.fiber.dispose()
@@ -360,9 +367,8 @@ for (const backend of backends) {
         },
       }), { surfaceOp: 'append' })
       await first.ctx.sessions.flush(targetHandle.agent.session)
-      // Let the pre-queue acknowledgement observer prove there is no mailbox
-      // row yet before authoring the simulated crash prefix below.
-      await new Promise<void>((resolve) => { setTimeout(resolve, 0) })
+      // Finish the target observer before writing the crash-only queued prefix.
+      await settleMailbox(first.ctx)
       await targetHandle.dispose()
 
       const queued: TeamMessageSnapshot = {
@@ -382,11 +388,41 @@ for (const backend of backends) {
       await first.dispose()
 
       const second = await stack(backend, storageRoot, [])
-      const rootHandle = await second.ctx.agents.resume({
-        resumeSessionId: rootId,
-        agentOptions: { provider: 'mock', model: 'mock' },
+      const { mailbox } = second.ctx.agentTeams as unknown as { readonly mailbox: TeamMailbox }
+      const flush = second.ctx.sessions.flush.bind(second.ctx.sessions)
+      const checkpointEntered = Promise.withResolvers<undefined>()
+      const releaseCheckpoint = Promise.withResolvers<undefined>()
+      const delayedCheckpoint = vi.spyOn(second.ctx.sessions, 'flush').mockImplementation(async (session) => {
+        if (session.id === rootId && session.snapshotEvents().some(event =>
+          event.type === 'team/message/delivered' && event.data.messageId === messageId)) {
+          checkpointEntered.resolve(undefined)
+          await releaseCheckpoint.promise
+        }
+        return await flush(session)
       })
-      await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) })
+      let rootHandle: AgentHandle
+      try {
+        rootHandle = await second.ctx.agents.resume({
+          resumeSessionId: rootId,
+          agentOptions: { provider: 'mock', model: 'mock' },
+        })
+        await checkpointEntered.promise
+        expect(durable(rootHandle.agent).pendingMessages).toEqual([])
+        expect(mailbox.pendingDispatches().length).toBeGreaterThan(0)
+        let settled = false
+        const settlement = settleMailbox(second.ctx).then(() => { settled = true })
+        await Promise.resolve()
+        expect(settled).toBe(false)
+        releaseCheckpoint.resolve(undefined)
+        await settlement
+      } finally {
+        releaseCheckpoint.resolve(undefined)
+        try {
+          await settleMailbox(second.ctx)
+        } finally {
+          delayedCheckpoint.mockRestore()
+        }
+      }
       expect(second.ctx.agents.get(started.member.id)).toBeUndefined()
       expect(second.adapter.requests).toEqual([])
 
@@ -461,6 +497,7 @@ for (const backend of backends) {
       await vi.waitFor(() => {
         expect(durable(rootHandle.agent).pendingMessages).toEqual([])
       })
+      await settleMailbox(second.ctx)
       expect(second.adapter.requests).toEqual([])
       expect(second.ctx.agents.get(childId)).toBeUndefined()
       const stored = await storedEvents(second.ctx, childId)

+ 1 - 0
packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts

@@ -34,6 +34,7 @@ const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)
  * import re-registers koffi's type names and fails as the second load.
  */
 const BASELINE_EXEMPT: ReadonlyMap<string, string> = new Map([
+  ['packages/client/ui-dockkit/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/client/web/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/subprocess/win32-process/lib/index.js', 'koffi type-name collision on a second load'],

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

@@ -2736,23 +2736,6 @@ 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 through the supplied scoped owner.',
-        parameters: [{ name: 'modelCtx', description: 'Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope.' }],
-      },
-      {
-        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.',
@@ -6320,10 +6303,6 @@ 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}',

+ 7 - 13
packages/lsp/lsp-stdio/tests/fixture-server.ts

@@ -12,7 +12,6 @@
  * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
  * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
  *   simulating a server that dies while idle so the pool holds a dead instance (eviction test).
- * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
  * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
  * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
  * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
@@ -34,7 +33,6 @@ const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(
 const hang = process.env.LSP_FAKE_HANG === '1'
 const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
 const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
-const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
 const openMarker = process.env.LSP_FAKE_OPEN_MARKER
 const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
 const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
@@ -153,18 +151,14 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
   if (method === 'textDocument/didClose') return
   if (method?.startsWith('textDocument/')) {
     if (hang) return
-    const reply = (): void => {
-      if (errorReply) {
-        send({ id, error: { code: -32000, message: 'server refused the request' } })
-      } else {
-        send({ id, result: resultFor(method) })
-      }
-      // Simulate an idle death: answer this request, then exit before the next one arrives so the
-      // pool is left holding a dead instance.
-      if (exitAfterReply) setTimeout(() => process.exit(0), 20)
+    if (errorReply) {
+      send({ id, error: { code: -32000, message: 'server refused the request' } })
+    } else {
+      send({ id, result: resultFor(method) })
     }
-    if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
-    else reply()
+    // Simulate an idle death: answer this request, then exit before the next one arrives so the
+    // pool is left holding a dead instance.
+    if (exitAfterReply) setTimeout(() => process.exit(0), 20)
     return
   }
   // Unknown request with an id: answer null so the client never stalls.

+ 37 - 11
packages/lsp/lsp-stdio/tests/lifecycle.spec.ts

@@ -299,21 +299,47 @@ describe('lsp-stdio end to end over a fake server', () => {
 
   it('reads a queued query source only when its lifecycle starts', async () => {
     const marker = join(root, 'opened.jsonl')
+    let provider: LspProvider | undefined
     const ctx = await mount({
       LSP_FAKE_DEF: 'null',
-      LSP_FAKE_REPLY_DELAY_MS: '300',
       LSP_FAKE_OPEN_MARKER: marker,
+    }, {}, (registered) => { provider = registered })
+    const release = Promise.withResolvers<undefined>()
+    const request = Object.getOwnPropertyDescriptor(LspConnection.prototype, 'request')?.value as LspConnection['request']
+    let holdFirst = true
+    const requestSpy = vi.spyOn(LspConnection.prototype, 'request').mockImplementation(async function (this: LspConnection, method, params) {
+      const hold = method === 'textDocument/definition' && holdFirst
+      if (hold) holdFirst = false
+      const result = await request.call(this, method, params)
+      if (hold) await release.promise
+      return result
     })
-    const first = ctx.lsp.query(query('goToDefinition'))
-    await waitFor(async () => (await markerLines(marker)).length === 1)
-    const second = ctx.lsp.query(query('goToDefinition'))
-    await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
-    await Promise.all([first, second])
-    expect(await markerLines(marker)).toEqual([
-      'const x = 1\nconst y = x\n',
-      'const changed = 2\n',
-    ])
-    await ctx.fiber.dispose()
+    const pending: Promise<unknown>[] = []
+    try {
+      const first = ctx.lsp.query(query('goToDefinition'))
+      pending.push(Promise.allSettled([first]))
+      await waitFor(async () => (await markerLines(marker)).length === 1)
+      // The changed tail proves the second query entered the provider queue
+      // while the first response is held, before the source rewrite starts.
+      const queues = (provider as unknown as { queues: ReadonlyMap<unknown, Promise<void>> }).queues
+      const firstTail = [...queues.values()][0]
+      expect(firstTail).toBeDefined()
+      const second = ctx.lsp.query(query('goToDefinition'))
+      pending.push(Promise.allSettled([second]))
+      await vi.waitFor(() => { expect([...queues.values()][0]).not.toBe(firstTail) }, { timeout: 3000 })
+      await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
+      release.resolve(undefined)
+      await Promise.all([first, second])
+      expect(await markerLines(marker)).toEqual([
+        'const x = 1\nconst y = x\n',
+        'const changed = 2\n',
+      ])
+    } finally {
+      release.resolve(undefined)
+      await Promise.all(pending)
+      requestSpy.mockRestore()
+      await ctx.fiber.dispose()
+    }
   })
 
   it('aborts an in-flight query when the signal fires', async () => {

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

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

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

@@ -239,11 +239,6 @@
     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

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

@@ -250,8 +250,3 @@
   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'

+ 14 - 6
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -665,6 +665,14 @@ describe('SessionProjectionCache cold-read seeding', () => {
     })
     const { cache, ctx } = await harness({ root })
     const apply = vi.fn((_state: number, _event: SessionEvent) => 1)
+    const whenWritten = (id: SessionId): Promise<void> => new Promise((resolve) => {
+      const dispose = ctx.on('domain/changed', (change) => {
+        if (change.domain !== projectionCacheDomainSpec.name
+          || change.table !== 'sessions' || change.key !== id || change.operation !== 'put') return
+        dispose()
+        resolve()
+      })
+    })
     ctx.sessionProjections.register({
       key: 'cache-test/count',
       stateSchema: z.number().int().nonnegative(),
@@ -676,6 +684,7 @@ describe('SessionProjectionCache cold-read seeding', () => {
     const events = Array.from({ length: 5 }, (_, seq) => ({
       type: 'cache-test/mark', seq: SessionSeq(seq), time: seq, data: { marks: [`m${seq}`] },
     })) as SessionEvent[]
+    const refreshed = whenWritten(meta.id)
     const snapshot = cache.coldSnapshot(meta, SessionLogOffset(0), events)
     // The full log was traversed, but the fold applied only seqs 3 and 4.
     expect(apply).toHaveBeenCalledTimes(2)
@@ -684,17 +693,16 @@ describe('SessionProjectionCache cold-read seeding', () => {
     // Host-only unit: folded but not served; the refreshed row is written
     // back (fail-soft, fire-and-forget) once the write lands.
     expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
-    await vi.waitFor(async () => {
-      expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
-    })
+    await refreshed
+    expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
     // No cached row yet: the first cold read folds from init over the full
     // log and creates the cache row (the `?? {}` seed path).
     const fresh = headerOf(SessionId('cold-fresh'), 10)
+    const created = whenWritten(fresh.id)
     cache.coldSnapshot(fresh, SessionLogOffset(0), events)
     expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
-    await vi.waitFor(async () => {
-      expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
-    })
+    await created
+    expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
   })
 
   it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {

+ 29 - 16
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -13,7 +13,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSyn
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { spawnSync } from 'node:child_process'
-import { afterAll, afterEach, describe, expect, it } from 'vitest'
+import { afterAll, afterEach, describe, expect, it, onTestFinished } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -64,15 +64,13 @@ async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1]
  * `expected`; returns the accumulation (reads never re-deliver, so the caller
  * gets everything produced up to the match).
  */
-async function readUntil(proc: ShellProcess, expected: string, timeoutMs = 5_000): Promise<string> {
-  const deadline = Date.now() + timeoutMs
+async function readUntil(proc: ShellProcess, expected: string, timeoutMs: number): Promise<string> {
   let all = ''
-  while (Date.now() < deadline) {
+  await expect.poll(() => {
     all += proc.readOutput().delta
-    if (lf(all).includes(expected)) return lf(all)
-    await new Promise(resolve => setTimeout(resolve, 20))
-  }
-  throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`)
+    return lf(all)
+  }, { timeout: timeoutMs }).toContain(expected)
+  return lf(all)
 }
 
 describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => {
@@ -414,7 +412,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
     expect(proc.exitCode).toBe(0)
   })
 
-  it('threads stdin and extra env into a background process', async () => {
+  it('threads stdin and extra env into a background process', async ({ task }) => {
     const { bash } = await setup()
     const proc = bash.start(bash.resolve({
       command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"',
@@ -422,19 +420,34 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
       env: { BG_VAR: 'bg-env' },
       dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
     }))
-    const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]')
+    const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]', task.timeout)
     await proc.done
     const output = partialOutput + lf(proc.readOutput().delta)
     expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
     expect(proc.exitCode).toBe(0)
   })
 
-  it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
-    const { bash } = await setup()
-    const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' }))
-    const first = await readUntil(proc, 'first\n')
+  it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async ({ task }) => {
+    const { ctx, bash } = await setup()
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-read-'))
+    onTestFinished(async () => {
+      await ctx.fiber.dispose()
+      rmSync(dir, { recursive: true, force: true })
+    })
+    const releasePath = join(dir, 'release')
+    // The second write cannot race the host's first consuming read.
+    const proc = bash.start(bash.resolve({
+      command: 'Write-Output first; [Console]::Out.Flush(); while (-not [System.IO.File]::Exists($env:DSH_PWSH_RELEASE)) { Start-Sleep -Milliseconds 20 }; Write-Output second',
+      env: { DSH_PWSH_RELEASE: releasePath },
+    }))
+    const first = await readUntil(proc, 'first\n', task.timeout)
     expect(lf(first)).toBe('first\n')
+    expect(proc.status).toBe('running')
+    expect(proc.readOutput().delta).toBe('')
+    writeFileSync(releasePath, '')
     await proc.done
+    expect(proc.status).toBe('completed')
+    expect(proc.exitCode).toBe(0)
     // Read-after-exit returns the remaining buffered output — once.
     const second = proc.readOutput()
     expect(lf(second.delta)).toBe('second\n')
@@ -530,7 +543,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 })
 
 describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => {
-  it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
+  it('a background process survives executor-fiber disposal and dies with the subprocess service', async ({ task }) => {
     const ctx = new Context()
     const managerFiber = await ctx.plugin(LocalSubprocessRuntime)
     ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
@@ -540,7 +553,7 @@ describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service,
     // The child prints its own pid so the test can probe liveness through the
     // public read surface alone.
     const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' }))
-    const pid = Number((await readUntil(proc, '\n')).trim())
+    const pid = Number((await readUntil(proc, '\n', task.timeout)).trim())
     expect(Number.isInteger(pid) && pid > 0).toBe(true)
 
     // Executor reload/disposal leaves background work running — the

+ 43 - 25
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -74,9 +74,10 @@ function expectedPermission(policy: 'allow' | 'reject', requestKind: string, dec
  * Poll until `file` exists (the mock touches it once its prompt is in flight),
  * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
  * subprocess cold-start is variable, and a fixed sleep both flakes and
- * slows the suite. Fails loud if the child never signals readiness.
+ * slows the suite. The caller supplies its effective test budget so child
+ * startup retains the lane's allowance under concurrent coverage.
  */
-async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
+async function waitForFile(file: string, timeoutMs: number): Promise<void> {
   const deadline = Date.now() + timeoutMs
   while (!existsSync(file)) {
     if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`)
@@ -724,7 +725,7 @@ describe('dsh-subagent-acp', () => {
     await run.dispose()
   })
 
-  it('aborting the required signal cancels a running child', async () => {
+  it('aborting the required signal cancels a running child', async ({ task }) => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
     const readyFile = join(tmp, 'ready')
     try {
@@ -733,7 +734,7 @@ describe('dsh-subagent-acp', () => {
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
       // Wait until the child's prompt is in flight (condition, not a sleep),
       // then cancel — so we exercise the mid-run session/cancel path.
-      await waitForFile(readyFile)
+      await waitForFile(readyFile, task.timeout)
       controller.abort('test')
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
@@ -900,7 +901,7 @@ describe('dsh-subagent-acp', () => {
     await realChild?.done
   })
 
-  it('reports only the safe teardown failure when cancelled startup rollback fails', async () => {
+  it('reports only the safe teardown failure when cancelled startup rollback fails', async ({ task }) => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-cancelled-rollback-'))
     const ready = join(tmp, 'ready')
     const go = join(tmp, 'go')
@@ -923,7 +924,7 @@ describe('dsh-subagent-acp', () => {
         },
         onError: (error) => { errors.push(error.message) },
       })
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       controller.abort()
       writeFileSync(go, 'go')
       const error = await starting.catch((cause: unknown) => cause)
@@ -940,7 +941,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
+  it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async ({ task }) => {
     // The child traps SIGTERM and keeps its event loop alive, so a graceful
     // term alone would hang dispose forever. With a short grace, dispose must
     // escalate to SIGKILL and return once the process is actually gone.
@@ -964,7 +965,7 @@ describe('dsh-subagent-acp', () => {
       // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
       // sleep) — otherwise SIGTERM races the trap install and the default handler
       // terminates the child, never exercising the escalation.
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       // Don't await result (the child hangs). Dispose must still return promptly
       // via the SIGKILL escalation — bound it so a regression (no escalation)
       // fails loud instead of hanging the suite.
@@ -977,7 +978,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
+  it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async ({ task }) => {
     // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
     // down on connection close, NOT on a signal) — and it has no SIGTERM handler.
     // Its EOF teardown can itself await a signal-trapping grandchild (a bash
@@ -1011,7 +1012,7 @@ describe('dsh-subagent-acp', () => {
       const run = await startAcpRun(request(), spec)
       // Wait until the child is fully booted with its prompt in flight (its ACP
       // stdin reader is attached), so dispose's stdin EOF reaches a live child.
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       await run.dispose()
       // dispose returned via the natural-exit tier — the EOF-driven flush landed
       // despite taking longer than the SIGTERM grace.
@@ -1021,7 +1022,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('terminates a child that ignores EOF using the host platform semantics', async () => {
+  it('terminates a child that ignores EOF using the host platform semantics', async ({ task }) => {
     // POSIX uses the catchable SIGTERM tier and records the marker. Windows has
     // no distinct graceful signal, so disposal skips directly to forced exit.
     const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
@@ -1048,7 +1049,7 @@ describe('dsh-subagent-acp', () => {
         },
       }
       run = await startAcpRun(request(), spec)
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       await run.dispose()
       const outcome = await child!.done
       expect(outcome.signal).toBeNull()
@@ -1071,7 +1072,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('rejects after cleanup when the signal aborts during newSession', async () => {
+  it('rejects after cleanup when the signal aborts during newSession', async ({ task }) => {
     // Gate the child at newSession: it signals `ready` and blocks until `go`.
     // We cancel WHILE newSession is pending (sessionId still undefined, so the
     // backend cannot send session/cancel) — the `cancelled` flag alone must
@@ -1083,23 +1084,40 @@ describe('dsh-subagent-acp', () => {
       const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
       const controller = new AbortController()
       const starting = ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(ready) // newSession is now in flight, sessionId undefined
-      controller.abort('early')
-      writeFileSync(go, 'go') // let newSession resolve
-      await expect(starting).rejects.toThrow('aborted before the ACP child started')
+      // Observe rejection before readiness; cleanup still owns an unpublished
+      // child if readiness or an assertion fails.
+      const settled = starting.then(
+        run => ({ kind: 'started' as const, run }),
+        (error: unknown) => ({ kind: 'failed' as const, error }),
+      )
+      try {
+        await waitForFile(ready, task.timeout) // newSession is now in flight, sessionId undefined
+        controller.abort('early')
+        writeFileSync(go, 'go') // let newSession resolve
+        await expect(starting).rejects.toThrow('aborted before the ACP child started')
+      } finally {
+        controller.abort('test cleanup')
+        writeFileSync(go, 'go')
+        try {
+          const outcome = await settled
+          if (outcome.kind === 'started') await outcome.run.dispose()
+        } finally {
+          await ctx.fiber.dispose()
+        }
+      }
     } finally {
       rmSync(tmp, { recursive: true, force: true })
     }
   })
 
-  it('bridges the request signal to a session/cancel mid-run', async () => {
+  it('bridges the request signal to a session/cancel mid-run', async ({ task }) => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-'))
     const readyFile = join(tmp, 'ready')
     try {
       const controller = new AbortController()
       const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(readyFile)
+      await waitForFile(readyFile, task.timeout)
       controller.abort()
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
@@ -1357,7 +1375,7 @@ describe('dsh-subagent-acp', () => {
     expect(errors).toEqual([rawMessage])
   })
 
-  it.skipIf(process.platform === 'win32')('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
+  it.skipIf(process.platform === 'win32')('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async ({ task }) => {
     // Same trap scenario as the direct startAcpRun escalation test, but the
     // graces arrive via the PLUGIN CONFIG through the registered provider — so a
     // regression that stops threading config into AcpRunSpec (falling back to
@@ -1379,7 +1397,7 @@ describe('dsh-subagent-acp', () => {
         disposeGraceMs: 150,
       })
       const run = await ctx.subagents.start('acp', request())
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       await expect(Promise.race([
         run.dispose(),
         new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }),
@@ -1587,7 +1605,7 @@ describe('dsh-subagent-acp', () => {
     await run.dispose()
   })
 
-  it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
+  it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async ({ task }) => {
     // The child hangs, we cancel, and instead of answering the child exits hard
     // — the pending prompt RPC rejects. With a cancel already requested, the
     // backend's catch path must settle `aborted` (the failure is the cancel
@@ -1598,7 +1616,7 @@ describe('dsh-subagent-acp', () => {
       const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
       const controller = new AbortController()
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       controller.abort('crash it')
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
@@ -1609,7 +1627,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('settles aborted on signal even when the child IGNORES session/cancel', async () => {
+  it('settles aborted on signal even when the child IGNORES session/cancel', async ({ task }) => {
     // The signal contract requires `result` to settle `aborted`. A child that hangs
     // its prompt AND ignores session/cancel must not wedge the parent — the
     // backend's own cancel-settle path resolves `aborted` without the child's
@@ -1620,7 +1638,7 @@ describe('dsh-subagent-acp', () => {
       const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
       const controller = new AbortController()
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       controller.abort('test')
       // Bound it: a regression (cancel only notifies the child, which ignores it)
       // would hang result forever — fail loud instead of stalling the suite.

+ 23 - 9
packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts

@@ -8,7 +8,7 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { existsSync, mkdtempSync, rmSync } from 'node:fs'
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, relative } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -113,9 +113,9 @@ function expectedFailure(fields: string): string {
 /**
  * Poll until `file` exists (the fake touches it once the probed state is
  * reached), so cancel tests wait on a CONDITION rather than an arbitrary
- * timeout. Fails loud if the child never signals readiness.
+ * timeout. The caller supplies the lane's effective test budget.
  */
-async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
+async function waitForFile(file: string, timeoutMs: number): Promise<void> {
   const deadline = Date.now() + timeoutMs
   while (!existsSync(file)) {
     if (Date.now() > deadline) throw new Error(`fake runtime never became ready (${file})`)
@@ -524,7 +524,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     await ctx.fiber.dispose()
   })
 
-  it('cancelling between handshake and publish rejects start after reap', async () => {
+  it('cancelling between handshake and publish rejects start after reap', async ({ task }) => {
     // The abort lands while the child is INSIDE initialize (ready-file
     // handshake window): the fake touches READY, we abort, then GO lets the
     // handshake complete — so the post-race `flags.cancelled` recheck must
@@ -532,6 +532,8 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-midcancel-'))
     const ready = join(tmp, 'ready')
     const go = join(tmp, 'go')
+    const createHarness = runInternals.createHarness.bind(runInternals)
+    runInternals.createHarness = options => createHarness({ ...options, initializeTimeoutMs: task.timeout })
     try {
       const controller = new AbortController()
       const spec: SdkRunSpec = {
@@ -547,12 +549,24 @@ describe('dsh-subagent-dsh-sdk provider', () => {
         disposeGraceMs: 200,
       }
       const pending = startSdkRun(request('p', controller.signal), spec)
-      await waitForFile(ready)
-      controller.abort('mid-handshake')
-      const { writeFileSync } = await import('node:fs')
-      writeFileSync(go, 'go\n')
-      await expect(pending).rejects.toThrow('aborted before the SDK child started')
+      // Observe failed startup while readiness is pending; rollback owns the child.
+      const settled = pending.then(
+        run => ({ kind: 'started' as const, run }),
+        (error: unknown) => ({ kind: 'failed' as const, error }),
+      )
+      try {
+        await waitForFile(ready, task.timeout)
+        controller.abort('mid-handshake')
+        writeFileSync(go, 'go\n')
+        await expect(pending).rejects.toThrow('aborted before the SDK child started')
+      } finally {
+        controller.abort('test cleanup')
+        writeFileSync(go, 'go\n')
+        const outcome = await settled
+        if (outcome.kind === 'started') await outcome.run.dispose()
+      }
     } finally {
+      runInternals.createHarness = createHarness
       rmSync(tmp, { recursive: true, force: true })
     }
   })

+ 32 - 15
packages/subagent/subagent/tests/continuation.spec.ts

@@ -661,17 +661,26 @@ describe('continuable image Queue prompts', () => {
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     const capability = Promise.withResolvers<{ inputModalities: string[] }>()
-    const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo').mockReturnValue(capability.promise as never)
+    const readingCapability = Promise.withResolvers<undefined>()
+    vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation(() => {
+      readingCapability.resolve(undefined)
+      return capability.promise as never
+    })
 
     const delivery = queuePrompt(ctx, parent, started.childId, [imageBlock])
-    delivery.catch(() => undefined)
-    await vi.waitFor(() => { expect(resolve).toHaveBeenCalled() })
-    releaseFirst.resolve(undefined)
-    const draining = drainManager(ctx)
-    capability.resolve({ inputModalities: ['text', 'image'] })
+    try {
+      await Promise.race([readingCapability.promise, delivery])
+      releaseFirst.resolve(undefined)
+      const draining = drainManager(ctx)
+      capability.resolve({ inputModalities: ['text', 'image'] })
 
-    await expect(delivery).rejects.toMatchObject({ code: 'DRAINING' })
-    await draining
+      await expect(delivery).rejects.toMatchObject({ code: 'DRAINING' })
+      await draining
+    } finally {
+      releaseFirst.resolve(undefined)
+      capability.resolve({ inputModalities: ['text', 'image'] })
+      await Promise.allSettled([delivery, drainManager(ctx)])
+    }
   })
 
   it('rejects a materialized image follow-up whose capability read raced a drain', async () => {
@@ -679,16 +688,24 @@ describe('continuable image Queue prompts', () => {
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     await waitNoActivation(ctx, started.childId)
     const capability = Promise.withResolvers<{ inputModalities: string[] }>()
-    const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo').mockReturnValue(capability.promise as never)
+    const readingCapability = Promise.withResolvers<undefined>()
+    vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation(() => {
+      readingCapability.resolve(undefined)
+      return capability.promise as never
+    })
 
     const delivery = queuePrompt(ctx, parent, started.childId, [imageBlock])
-    delivery.catch(() => undefined)
-    await vi.waitFor(() => { expect(resolve).toHaveBeenCalled() })
-    const draining = drainManager(ctx)
-    capability.resolve({ inputModalities: ['text', 'image'] })
+    try {
+      await Promise.race([readingCapability.promise, delivery])
+      const draining = drainManager(ctx)
+      capability.resolve({ inputModalities: ['text', 'image'] })
 
-    await expect(delivery).rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
-    await draining
+      await expect(delivery).rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
+      await draining
+    } finally {
+      capability.resolve({ inputModalities: ['text', 'image'] })
+      await Promise.allSettled([delivery, drainManager(ctx)])
+    }
     const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
     expect(loaded.events.some(event => event.type === 'user/message'
       && event.data.content.some(block => block.type === 'image'))).toBe(false)

+ 2 - 2
packages/test-support/session-snapshot/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/test-support/session-snapshot/README.md
-README.md: f1126920969cead7844712ad3b2c7213cce19ed9
-README.zh.md: 65e04f252a13c22351b450da2df44f244d484811
+README.md: 6e88270dc3be31b135534097d30fb2b67a37ebde
+README.zh.md: 57cf86dcda198ac939c6e82c56cd3c9c3b473baa

+ 1 - 0
packages/test-support/session-snapshot/README.md

@@ -88,6 +88,7 @@ A scenario requiring a non-Windows host declares `posixOnly`, which skips its ru
 
 ### What can go wrong
 
+- **A child turn wait fails** — `waitForSubagentTurnEnd` identifies the child, requested turn, and deadline even when the first log harvest exceeds that deadline, and retains the underlying failure as the error cause.
 - **A fixture guard rejects the committed files** — orphan scenario dirs, missing files, multiple pins for one header class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers all fail the suite before comparisons run.
 - **The session harvest needs raw JSONL mode** — snapshot configs set the JSONL backend's `compression: 'none'`; compressed JSONL has no snapshot-harvest path.
 - **Built mode needs current artifacts** — run `pnpm run build` before selecting `DSH_EXAMPLE_MODE=lib`; source mode remains the zero-build path.

+ 1 - 0
packages/test-support/session-snapshot/README.zh.md

@@ -88,6 +88,7 @@ Spill 场景通过真实本地 provider 保存到私有临时根目录。夹具
 
 ### 可能出什么问题
 
+- **子会话轮次等待失败**——即使首次日志收集就超过期限,`waitForSubagentTurnEnd` 也会指出子会话、目标轮次与等待期限,并通过错误的 cause 保留底层失败。
 - **fixture 保护拒绝已提交文件**——遗留场景目录、缺失文件、一个 header 类别包含多个 pin、重复的伴随文件内容、未擦除的 JSONL header 与格式错误的 pin header 都会在比较运行前使套件失败。
 - **会话收集需要原始 JSONL mode**——快照配置使用 JSONL 后端的 `compression: 'none'`;压缩 JSONL 没有快照收集路径。
 - **构建 mode 需要当前产物**——选择 `DSH_EXAMPLE_MODE=lib` 前先运行 `pnpm run build`;源 mode 仍是零构建路径。

+ 16 - 11
packages/test-support/session-snapshot/src/harness.ts

@@ -67,7 +67,8 @@ const WAIT_POLL_INTERVAL_MS = 10
  * `waitForInboxMessage` waits for inserted inbox text containing a scenario marker.
  * `waitForSubagentTurnEnd` waits until one background child has persisted a
  * closed model-work turn after its own descriptor; child progress has no ACP
- * update to wait on.
+ * update to wait on. Failures identify the child, turn, and deadline even if
+ * the first log read is still pending; the underlying failure is retained as cause.
  * `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
  * `waitForEventAfterTurnEnd` waits until a complete record of the given event
  * type follows the latest closed turn — for scenarios whose asserted state
@@ -608,16 +609,20 @@ async function waitForPersistedChildTurnEnd(
   timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
   minimumTurn = 1,
 ): Promise<void> {
-  await vi.waitFor(async () => {
-    const log = (await harvestSessionLogs(root))[child]
-    if (log === undefined || !latestTurnIsClosed(log.content)
-      || !hasRequestHeaderAfterDescriptor(log.content)
-      || !hasClosedTurn(log.content, minimumTurn)) {
-      throw new Error(
-        `snapshot-harness: subagent child #${child} did not persist closed turn ${minimumTurn} within ${timeoutMs}ms`,
-      )
-    }
-  }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
+  const message = `snapshot-harness: subagent child #${child} did not persist closed turn ${minimumTurn} within ${timeoutMs}ms`
+  try {
+    await vi.waitFor(async () => {
+      const log = (await harvestSessionLogs(root))[child]
+      if (log === undefined || !latestTurnIsClosed(log.content)
+        || !hasRequestHeaderAfterDescriptor(log.content)
+        || !hasClosedTurn(log.content, minimumTurn)) {
+        throw new Error(message)
+      }
+    }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
+  } catch (cause) {
+    // The deadline can precede the first harvest, before the callback names the missing turn.
+    throw new Error(message, { cause })
+  }
 }
 
 /** Whether a raw session log contains the requested closed turn. */

+ 32 - 0
packages/test-support/session-snapshot/tests/harness.spec.ts

@@ -1,4 +1,5 @@
 import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
+import * as fsPromises from 'node:fs/promises'
 import { once } from 'node:events'
 import { tmpdir } from 'node:os'
 import { delimiter, join, relative, sep } from 'node:path'
@@ -984,6 +985,37 @@ describe('runScenario', () => {
     )).rejects.toThrow(/did not persist goal phase "blocked" within 20ms/)
   })
 
+  it('identifies the child wait when its first log harvest outlasts the deadline', async () => {
+    const { fixtureFile } = await scenario({})
+    const reading = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    let pendingRead: Promise<unknown> | undefined
+    const originalReaddir = readdir
+    const spy = vi.spyOn(fsPromises, 'readdir').mockImplementation(async (...args) => {
+      if (pendingRead === undefined && String(args[0]).includes('acp-snap-sessions-')) {
+        const read = release.promise.then(() => originalReaddir(...args))
+        pendingRead = read
+        reading.resolve(undefined)
+        return await read
+      }
+      return await originalReaddir(...args)
+    })
+    const run = runScenario(
+      { steps: [...boot, { op: 'waitForSubagentTurnEnd', child: 2, timeoutMs: 20 }] },
+      { agent: AGENT, mode: 'replay', fixtureFile },
+    )
+    const rejected = expect(run).rejects.toThrow(/subagent child #2 did not persist closed turn 1 within 20ms/)
+    try {
+      await Promise.race([reading.promise, rejected])
+      expect(pendingRead).toBeDefined()
+      await rejected
+    } finally {
+      release.resolve(undefined)
+      await Promise.allSettled([pendingRead, run, rejected])
+      spy.mockRestore()
+    }
+  })
+
   it('waitForSubagentTurnEnd requires a closed child work turn', { timeout: 20_000 }, async () => {
     const closed = await scenario({
       prompt: 'hang-until-cancel',

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

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

+ 0 - 41
packages/visualizer/README.md

@@ -1,41 +0,0 @@
----
-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.

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

@@ -1,41 +0,0 @@
----
-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。

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

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

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

@@ -1,103 +0,0 @@
----
-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.
-
-Programmatic model installation requires a scoped Context and uses it only to own effects; shipped wrappers supply the preset Context. Follow-up authorization receives the Agent explicitly through the Remote.
-
-### 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.

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

@@ -1,103 +0,0 @@
----
-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` 限制在整个生命周期中都会继续生效。
-
-通过程序接口安装模型贡献时必须提供有作用域的 Context,它只负责 effect 所有权;随产品提供的 wrapper 传入 preset Context。follow-up 授权通过 Remote 显式接收 Agent。
-
-### 权威与 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 身份。

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

@@ -1,76 +0,0 @@
-{
-  "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:^"
-  }
-}

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

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

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

@@ -1,68 +0,0 @@
-/** 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')
-}

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

@@ -1,94 +0,0 @@
-/** 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 through the supplied scoped owner.
-   * @param modelCtx - Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope.
-   */
-  installModelSurface(modelCtx: Context): void {
-    if (scopeOf(modelCtx) === undefined) {
-      throw new Error('tool-visualizer: the model contribution requires a scoped context')
-    }
-    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

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

@@ -1,20 +0,0 @@
-/** 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)
-  })
-}

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

@@ -1,10 +0,0 @@
-/** 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
-}

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

@@ -1,142 +0,0 @@
-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
-  }
-}

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

@@ -1,104 +0,0 @@
-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}` }),
-  }))
-}

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

@@ -1,9 +0,0 @@
-/** 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
-}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff