Selaa lähdekoodia

Merge remote-tracking branch 'origin/master' into turtle/weighted-approval

# Conflicts:
#	.github/review-ownership/README.md
Turtle 1 kuukausi sitten
vanhempi
sitoutus
f830cfd66a
100 muutettua tiedostoa jossa 1505 lisäystä ja 314 poistoa
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml
  2. 4 2
      .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md
  3. 4 2
      .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
  5. 5 3
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
  6. 5 3
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
  8. 5 5
      .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md
  9. 5 5
      .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml
  11. 47 0
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md
  12. 47 0
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml
  14. 29 0
      .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md
  15. 29 0
      .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md
  16. 6 0
      .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml
  17. 43 0
      .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
  18. 43 0
      .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md
  19. 6 0
      .agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml
  20. 35 0
      .agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md
  21. 35 0
      .agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md
  22. 8 4
      .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md
  23. 4 2
      .github/review-ownership/README.md
  24. 54 1
      .github/review-ownership/request-review.mjs
  25. 121 0
      .github/review-ownership/request-review.test.mjs
  26. 2 0
      THIRD_PARTY_NOTICES.md
  27. 7 0
      apps/web/tests/expected/markdown-images/ui.expected.md
  28. 2 0
      apps/web/tests/feedback-command.e2e.ts
  29. 4 2
      apps/web/tests/github-ready-review.e2e.ts
  30. 8 0
      apps/web/tests/lifecycle-chrome.e2e.ts
  31. 5 1
      apps/web/tests/live-interactions.e2e.ts
  32. 77 15
      apps/web/tests/markdown-images.e2e.ts
  33. 4 0
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  34. 36 22
      apps/web/tests/queue-actions.e2e.ts
  35. 3 0
      apps/web/tests/seeded-history.e2e.ts
  36. 13 2
      apps/web/tests/workflow-run.e2e.ts
  37. 5 0
      apps/web/tests/workspace-management.e2e.ts
  38. 2 2
      docs/config-catalog.i18n.yaml
  39. 4 4
      docs/config-catalog.md
  40. 2 2
      docs/config-catalog.zh.md
  41. 2 2
      docs/module-graph.i18n.yaml
  42. 2 1
      docs/module-graph.md
  43. 2 1
      docs/module-graph.zh.md
  44. 2 2
      docs/subsystems/core.i18n.yaml
  45. 11 9
      docs/subsystems/core.md
  46. 11 9
      docs/subsystems/core.zh.md
  47. 2 2
      docs/subsystems/subagent.i18n.yaml
  48. 2 2
      docs/subsystems/subagent.md
  49. 2 2
      docs/subsystems/subagent.zh.md
  50. 1 4
      packages/acp/acp/src/session.ts
  51. 3 8
      packages/api/gateway/src/index.ts
  52. 3 1
      packages/api/gateway/src/types.ts
  53. 15 85
      packages/api/gateway/tests/gateway-stream.host.spec.ts
  54. 0 1
      packages/api/gateway/tests/gateway.host.spec.ts
  55. 2 2
      packages/api/remotes/README.i18n.yaml
  56. 1 1
      packages/api/remotes/README.md
  57. 1 1
      packages/api/remotes/README.zh.md
  58. 1 0
      packages/api/remotes/package.json
  59. 7 6
      packages/api/remotes/src/index.ts
  60. 11 3
      packages/api/remotes/tests/remote-events.host.spec.ts
  61. 2 2
      packages/api/session-controller/README.i18n.yaml
  62. 8 0
      packages/api/session-controller/README.md
  63. 8 0
      packages/api/session-controller/README.zh.md
  64. 7 2
      packages/api/session-controller/package.json
  65. 6 6
      packages/api/session-controller/src/agent.ts
  66. 2 0
      packages/api/session-controller/src/index.ts
  67. 77 0
      packages/api/session-controller/src/media-references.ts
  68. 1 4
      packages/api/session-controller/tests/agent.host.spec.ts
  69. 224 0
      packages/api/session-controller/tests/media-references.host.spec.ts
  70. 2 2
      packages/api/session-controller/tests/session-fork.host.spec.ts
  71. 2 3
      packages/api/session-controller/tests/session-presets.host.spec.ts
  72. 3 0
      packages/api/session-controller/tsconfig.host.json
  73. 1 5
      packages/bundle/headless/tests/headless.spec.ts
  74. 25 1
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  75. 74 0
      packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx
  76. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  77. 1 1
      packages/client/ui-conversation/README.md
  78. 1 1
      packages/client/ui-conversation/README.zh.md
  79. 5 3
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  80. 18 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  81. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  82. 2 2
      packages/client/ui-primitives/README.md
  83. 2 2
      packages/client/ui-primitives/README.zh.md
  84. 4 4
      packages/client/ui-primitives/src/ConnectionIndicator.module.css
  85. 1 1
      packages/client/ui-primitives/src/index.ts
  86. 14 7
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  87. 63 7
      packages/client/ui-primitives/src/markdown/render.tsx
  88. 104 0
      packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx
  89. 1 0
      packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx
  90. 2 2
      packages/core/agent-loop/README.i18n.yaml
  91. 2 2
      packages/core/agent-loop/README.md
  92. 2 2
      packages/core/agent-loop/README.zh.md
  93. 1 1
      packages/core/agent-loop/src/agent.ts
  94. 9 5
      packages/core/agent-loop/src/index.ts
  95. 3 2
      packages/core/agent-loop/tests/agent-initiator.spec.ts
  96. 3 3
      packages/core/agent-loop/tests/resume.spec.ts
  97. 5 7
      packages/core/agent-loop/tests/scope-lifecycle.spec.ts
  98. 2 2
      packages/core/agent/README.i18n.yaml
  99. 3 3
      packages/core/agent/README.md
  100. 3 3
      packages/core/agent/README.zh.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.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-07-08-agent-scope-contexts.md
-2026-07-08-agent-scope-contexts.md: 6a1fd4aed49cb8edef061c8fb6f0edcd0a09c30f
-2026-07-08-agent-scope-contexts.zh.md: 8408c4afff6075c129c6a96c47393c9c812b04b7
+2026-07-08-agent-scope-contexts.md: 45e635b7bc3138d4e90a25a06ff23b3b57a9415e
+2026-07-08-agent-scope-contexts.zh.md: aac860734e744843c3b9e7d55e5bc7a150763090

+ 4 - 2
.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md

@@ -16,6 +16,8 @@ The mechanism also needs a publication boundary. An agent must not become visibl
 
 Every live agent owns one flat registration layer exposed as `agent.ctx`. Code registers through the context that owns a contribution; scope-aware services combine deployment-global registrations with exactly one matching agent layer; operations choose that layer from their real agent; and the layer exists for the agent's complete published lifetime.
 
+`agent.ctx` carries registration ownership and the scope key; it does not expose a reverse `agent` property. Code that needs the domain subject receives it explicitly: `AgentSetup` receives `(agentCtx, agent)`, and scoped events carry their subject in the payload.
+
 Cordis is the plugin framework underneath the SDK. A Cordis **context** is the object plugins use to access services and register effects whose cleanup follows that context. The [Cordis primer](../../../../docs/cordis-primer.md) explains the framework in more detail.
 
 For most contributors, the complete contract is four rules:
@@ -45,7 +47,7 @@ flowchart LR
 
 The missing cross-edges are the isolation rule: Agent A's local registrations do not enter Agent B's view, and a parent's registrations do not enter a child merely because the parent owns the child's lifetime.
 
-The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature.
+The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [explicit runtime-identity Agent Note](2026-08-31-explicit-agent-runtime-identity.md) owns why lifecycle, event, and transport interfaces pass Agent identity instead of exposing it through Context. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature.
 
 ### Registration origin chooses visibility and cleanup
 
@@ -88,7 +90,7 @@ await handle.dispose()
 ctx.tools.get('review_summary', handle.agent)  // undefined: scope is gone
 ```
 
-Setup receives a full trusted Cordis context so it can compose ordinary plugins and services. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported.
+Setup receives the full trusted Cordis context and unpublished Agent so it can compose ordinary plugins and services while reading the exact child Session when needed. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported.
 
 ### The operation chooses the view
 

+ 4 - 2
.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md

@@ -16,6 +16,8 @@ Status: implemented
 
 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。
 
+`agent.ctx` 携带注册所有权和作用域键,不暴露反向的 `agent` 属性。需要领域主体的代码会显式接收它:`AgentSetup` 接收 `(agentCtx, agent)`,作用域事件则在 payload 中携带主体。
+
 Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。
 
 对大多数贡献者而言,完整约定是四条规则:
@@ -45,7 +47,7 @@ flowchart LR
 
 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。
 
-配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。
+配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md)阐述实现与正确性推理。[显式运行时身份 Agent Note](2026-08-31-explicit-agent-runtime-identity.zh.md)说明生命周期、事件和传输接口为何显式传递 Agent 身份,而不通过 Context 暴露该身份。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。
 
 ### 注册来源决定可见性与清理
 
@@ -88,7 +90,7 @@ await handle.dispose()
 ctx.tools.get('review_summary', handle.agent)  // undefined: scope is gone
 ```
 
-setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插件和服务。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。
+setup 接收完整的受信 Cordis 上下文和未发布的 Agent,因此既可以组合普通插件和服务,也能在需要时读取确切的子 Session。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。
 
 ### 操作选择视图
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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-07-12-agent-scope-runtime-design.md
-2026-07-12-agent-scope-runtime-design.md: b6001a5ef9f2dc69ec21908f8350b765dd00acf1
-2026-07-12-agent-scope-runtime-design.zh.md: be12c53ffa9b89e007888935002a5c484c038fd7
+2026-07-12-agent-scope-runtime-design.md: ca300d4eeeab878a4e41b8e68a669be418617181
+2026-07-12-agent-scope-runtime-design.zh.md: 870690d6ace9fefd859557a2e73e88b9b1da6206

+ 5 - 3
.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md

@@ -42,6 +42,8 @@ All agents share one Cordis service graph. A derived context does not clone `Too
 
 `agent.ctx` is such a derived context. Service calls still reach the shared instances, while a registration can inspect its calling context and store a contribution under the nearest scope key. Ordinary plugin contexts carry no scope key and therefore register globally.
 
+The Agent context is exactly the context returned by `createScope`; it carries no second reverse association to the Agent. Subject-bearing APIs pass the Agent explicitly, leaving one formal scope mechanism for registration ownership and routing.
+
 ### Fibers and effects make cleanup structural
 
 A Cordis fiber is the live instance created when a plugin or child context is activated. Its state records whether that lifecycle is active, unloading, failed, or disposed. `ctx.effect()` and `ctx.on()` return disposers and also attach those disposers to the registering fiber, so unloading a plugin or agent scope removes everything registered through that context without a separate inventory.
@@ -68,7 +70,7 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live
 
 `createScope(parent, key)` returns a scope whose `ctx` shares the parent's services and whose effects are tagged with that key. `scopeOf(ctx)` reads the nearest registration key. `scopeTarget(base, key)` creates the event receiver whose filter preserves the base receiver's Cordis service filter, then admits unscoped listeners and listeners with that exact key.
 
-The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`.
+The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives an explicit setup parameter or event argument; code that needs registration ownership receives `agent.ctx`.
 
 ### Registry reads overlay one exact layer
 
@@ -100,11 +102,11 @@ The transaction is installed under both the calling Cordis context and the concr
 
 Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm.
 
-The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. This preserves dependency origin and caller ownership without stacking trace proxies.
+The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator sets `parentAgent` in the create or resume options, and AgentRegistry forwards those options without deriving a parent from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) owns this separation and the continuable-child ownership rule that follows from it.
 
 ### Setup is trusted composition inside a private world
 
-Setup receives the full child context and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, but the public contract does not support driving or publishing the in-flight agent through casts or internal registry calls.
+Setup receives the full child context and the exact unpublished Agent, and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, and consumers that need the child's Session read it from the Agent parameter. The public contract does not support driving or publishing the in-flight agent through casts or internal registry calls.
 
 The transaction races asynchronous load and setup against deactivation rather than waiting forever for a promise owned by external code. If cancellation or owner unload wins, public creation rejects after transaction-owned cleanup even when the external promise never settles.
 

+ 5 - 3
.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md

@@ -42,6 +42,8 @@ Status: implemented
 
 `agent.ctx` 就是这样一个派生上下文。服务调用仍然到达共享实例,而注册操作可以检查其调用上下文并将贡献存储在最近的作用域键下。普通的插件上下文不携带作用域键,因此注册到全局。
 
+Agent 上下文就是 `createScope` 返回的上下文,不携带第二份指回 Agent 的关联。需要主体的 API 显式传递 Agent,因此注册所有权与路由只依赖一种正式的作用域机制。
+
 ### Fiber 与 effect 使清理成为结构性的
 
 Cordis fiber 是插件或子上下文被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该上下文注册的一切,无需单独的清单。
@@ -70,7 +72,7 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个
 
 `createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建事件接收器,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。
 
-Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。
+Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的 setup 参数或事件参数;需要注册所有权的代码接收 `agent.ctx`。
 
 ### 注册表读取叠加一个精确 layer
 
@@ -102,11 +104,11 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册
 
 创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。
 
-工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。这保留了依赖来源和调用方所有权,而不堆叠 trace 代理。
+工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方在 create 或 resume options 中设置 `parentAgent`,AgentRegistry 转交这些 options,不从调用方 Context 推导父级。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)拥有这项分离原则及由此确定的可续跑子级归属规则。
 
 ### Setup 是私有世界内的可信组合
 
-Setup 接收完整的子上下文,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect,但公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。
+Setup 接收完整的子上下文和确切的未发布 Agent,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect;需要子 Session 的消费者从 Agent 参数读取它。公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。
 
 事务将异步加载和 setup 与停用进行竞争,而非无限等待外部代码拥有的 promise。如果取消或所有者卸载获胜,即使外部 promise 永不结算,公开创建也会在事务拥有的清理之后拒绝。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.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-07-15-agent-initiator-scope.md
-2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277
-2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac
+2026-07-15-agent-initiator-scope.md: ab11da116a463cd706418e797eb58f1bc4ab9b1c
+2026-07-15-agent-initiator-scope.zh.md: 343acba5f99379b6d2c3af41368e8fbe90b20611

+ 5 - 5
.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md

@@ -6,7 +6,7 @@ English | [中文](2026-07-15-agent-initiator-scope.zh.md)
 
 ## Problem
 
-The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently.
+The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. A dynamic `ctx.agent` meaning “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently.
 
 Deep process-local infrastructure sometimes needs a trusted initiating Agent below explicit loop, tool, and request parameters—for example, a host-aware transport, tracing helper, logger, or gateway client. Requiring every private helper to forward `agent` adds repetition, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are unsuitable because a model must not choose a trusted Session or routing header. The carrier belongs to the Agent service rather than optional model-visible context.
 
@@ -18,9 +18,9 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in
 
 `AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from `ctx.agents`, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or `Session` through shallow interfaces. A leaf helper keeps a narrow `Session` parameter when that is its actual interface rather than accepting a broader `Context` only for an ambient lookup.
 
-Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child.
+Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx, childAgent)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while the explicit `childAgent` parameter identifies the child.
 
-Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, job ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
+Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, the Agent parameter of `AgentSetup`, `GenerateOptions.sessionId`, job ownership, parent/child requests, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
 
 `AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering.
 
@@ -28,7 +28,7 @@ Initiator scope does not own detached work: registry drain tracks only the Promi
 
 A host-aware transport may derive a deployment-owned header such as `X-Harness-Session-Id` from `ctx.agents.requireInitiator().session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam.
 
-This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning.
+This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) keeps initiator scope limited to private asynchronous chains while lifecycle, ownership, event, and wire interfaces carry their subjects directly.
 
 ## Verification
 
@@ -40,7 +40,7 @@ A test-double host-aware transport derives `X-Harness-Session-Id` internally and
 
 **Pass Agent through every function.** Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds repetitive forwarding without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries.
 
-**Make `ctx.agent` dynamic.** `ctx.agent` already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising.
+**Expose a dynamic `ctx.agent`.** Context carries registration ownership, not a domain subject. Adding an accessor for the executing Agent would mix registration and execution scopes and make concurrent behavior surprising.
 
 **Add a separate `ctx.agentExecution` service.** The carrier has no independent backend, configuration, or identity type: it stores the same `Agent` that `ctx.agents` already owns, and AgentLoop already depends on that service. A second mandatory provider would add package, composition, lifecycle, generated-catalog, and test-harness wiring without separating a real capability.
 

+ 5 - 5
.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md

@@ -6,7 +6,7 @@ Status: implemented
 
 ## 问题
 
-harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。
+harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若提供表示「当前正在运行的 Agent」的动态 `ctx.agent`,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。
 
 进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。
 
@@ -18,9 +18,9 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
 
 `AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 `ctx.agents` 恢复同一个 Agent,一次推导 `agent.session`,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 `Session`。若 `Session` 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 `Session` 参数,而不会只为隐式查找而接收更宽泛的 `Context`。
 
-因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
+因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx, childAgent)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而显式的 `childAgent` 参数标识子 Agent。
 
-隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
+隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`AgentSetup` 的 Agent 参数、`GenerateOptions.sessionId`、任务归属、父子请求、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
 
 `AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose(资源释放)后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。
 
@@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
 
 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。
 
-本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。
+本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)把发起方作用域限制在私有异步调用链内,同时让生命周期、归属、事件和协议接口直接携带各自的主体。
 
 ## 验证
 
@@ -40,7 +40,7 @@ Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise 
 
 **在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。
 
-**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。
+**暴露动态的 `ctx.agent`。** Context 携带注册所有权,而非领域主体。为正在执行的 Agent 新增 accessor 会混合注册作用域与执行作用域,并让并发行为变得意外。
 
 **新增独立的 `ctx.agentExecution` 服务。** 该载体没有独立后端、配置或身份类型:它存储的是 `ctx.agents` 已经管理的同一个 `Agent`,而 AgentLoop 本就依赖该服务。第二个必需提供方会增加包、组合、生命周期、生成目录及测试 harness 接线,却没有拆出真实能力。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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/architecture/2026-08-31-explicit-agent-runtime-identity.md
+2026-08-31-explicit-agent-runtime-identity.md: f52b8ec116c312a27306fe73dc0bd5b99fcd9039
+2026-08-31-explicit-agent-runtime-identity.zh.md: 6b6fd2f2ea1f1645069264f09fd53c3f71e1d02f

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

@@ -0,0 +1,47 @@
+# Agent Note: Explicit Agent identity at runtime boundaries
+
+Status: implemented
+
+English | [中文](2026-08-31-explicit-agent-runtime-identity.zh.md)
+
+## Problem
+
+An Agent's Cordis Context owns registrations and their cleanup. Agent identity instead selects the Session, runtime owner, event subject, authority decision, or wire identity for one operation. A reverse Agent property on Context made those two facts appear interchangeable: a caller could choose a Context for effect ownership and accidentally let that choice determine domain identity.
+
+The reverse association also required compensating mechanisms after type erasure. Host Remote forwarding inspected a routed subject for its Context, creation inferred runtime parentage from the caller Context, and adapters maintained reverse identity scans. These mechanisms duplicated identity already present in typed requests and obscured which caller owned an Agent at runtime.
+
+Without an explicit owner, `SubagentContinuationManager` creates and resumes children through its private plugin Context, so Context-based inference classifies every continuable child as a runtime root even though the manager holds its exact parent. Root-only consumers could then attach scheduling tools, grant direct-human goal authority, or route user questions as if the child were top-level.
+
+## Decision
+
+Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property.
+
+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. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations.
+
+`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.
+
+## Verification
+
+Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child.
+
+Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance.
+
+## Alternatives considered
+
+**Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup.
+
+**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options.
+
+**Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs.
+
+**Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary.
+
+**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent.
+
+## Consequences
+
+Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator.
+
+Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime.

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

@@ -0,0 +1,47 @@
+# Agent Note: 运行时边界显式携带 Agent 身份
+
+Status: implemented
+
+[English](2026-08-31-explicit-agent-runtime-identity.md) | 中文
+
+## 问题
+
+Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操作选择会话、运行时所属方、事件主体、权限决策或协议身份。Context 上反向的 Agent 属性让这两个事实看起来可以互换:调用方选择用于管理 effect 所有权的 Context 时,可能意外地让该选择决定领域身份。
+
+类型信息被擦除后,这项反向关联还需要补偿机制。Host Remote 转发会从已路由主体检查其 Context,创建流程会从调用方 Context 推断运行时父级,适配器则维护反向身份扫描。这些机制重复类型化请求中已有的身份,也掩盖了哪个调用方在运行时拥有 Agent。
+
+若没有显式所属方,`SubagentContinuationManager` 会通过私有插件 Context 创建和恢复子级,因此基于 Context 的推断会把每个可续跑子级归类为 runtime root,尽管管理器持有其确切父级。仅限根级的消费方随后可能附加调度工具、授予直接人类输入对应的 Goal 权限,或像处理顶层 Agent 一样路由用户问题。
+
+## 决策
+
+运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。
+
+感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。
+
+`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 关联和隐式运行时所属方推导。
+
+## 验证
+
+Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。
+
+Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。
+
+## 考虑过的替代方案
+
+**保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。
+
+**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。
+
+**分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。
+
+**使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。
+
+**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。
+
+## 后果
+
+生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。
+
+可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md
+2026-09-07-typert-package-local-forwarding-imports.md: f50dc7bfc8c9d83c2b6f2b584e1d1119b8df817b
+2026-09-07-typert-package-local-forwarding-imports.zh.md: 7e012d220df3e7356b35a105784f89e4df148802

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md

@@ -0,0 +1,29 @@
+# Agent Note: Follow package-local forwarding modules in Typert references
+
+Status: implemented
+
+English | [中文](2026-09-07-typert-package-local-forwarding-imports.zh.md)
+
+## Problem
+
+`WorkspaceAnalyzer` resolves every type reference to its original declaration before classifying it, then reads only the referencing file's own `import` statement to decide whether the reference crossed a package through a public export. A package that re-exports another package's type from one of its own modules, and imports that module by relative path elsewhere, therefore fails with `crosses a package without an explicit package import` although the package import exists one hop away. The failure is deterministic for every batch size and package order; it surfaces in whichever analysis selects the referencing package as a root, which is why [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) observed it as batch-dependent.
+
+## Decision
+
+[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) resolves a relative specifier through the face's shared compiler host and module-resolution cache and follows it only while the resolved file stays inside the referencing package. In each forwarding module it collects the `export` edges that carry the requested name: a named re-export with a specifier, an `export { local }` backed by that module's `import`, and star re-exports whose module exports the same symbol. Explicit edges are tried before star edges, matching TypeScript's shadowing of star exports, and each resolved module and requested export-name pair is entered once, so circular star re-exports terminate while distinct renamed routes through one module remain available. The walk stops at the first package specifier and feeds that identity and export name to the existing `packageExportName` check, so a forwarded type must still be public at the package subpath the forwarding module names, and a package name without a registration is refused there. The reference model is unchanged: the target remains `declaration` for a same-face owner and `cross-face` for another face.
+
+The walk yields no package import, and the reference fails as before, when a relative specifier resolves outside the referencing package, when the only edge carrying the name is a namespace re-export or a re-exported namespace import, or when every edge loops back to a module and requested-name pair already entered.
+
+## Alternatives considered
+
+**Treat a relative import whose alias chain ends in another package as implicitly public.** Rejected: it would accept `../../other/src/file.ts` and any forwarding module that itself reaches the other package by relative path, removing the public-export check the generated Remote declarations rely on to name an importable subpath.
+
+**Record the forwarding module as the reference target.** Rejected: emitters and cross-face links need the original declaration's package and public subpath; a package-local module has no public identity of its own.
+
+**Select edges in source order without symbol checks.** Rejected: a star re-export that loops back to an earlier module can precede the explicit re-export that actually carries the type, and TypeScript itself lets explicit exports shadow star exports; ordering explicit edges first and continuing past an entered module and requested-name pair keeps such modules accepted without an unbounded walk.
+
+**Make batched and whole-workspace analysis select the same roots.** Rejected as a fix: root selection does not change the verdict on a reference, only whether the reference is visited, so aligning the callers would hide the incorrect classification rather than remove it.
+
+## Consequences
+
+Packages may keep one forwarding module for foreign types and import it relatively, matching how their own modules are organized. Each cross-package relative reference costs one module resolution per hop through the face's shared resolution cache; `reachableFiles` now resolves through the same cache. [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) pins named, renamed multi-hop, import-then-export, star, and namespace-import forwarding, an explicit re-export beside a looping star edge, distinct renamed routes through one shared module, a forwarded private export, a forwarding module that crosses by relative path, a cycle whose only exit crosses by relative path, a namespace re-export, a re-exported namespace import, cross-face forwarding, and equality of whole and batched analysis for the forwarding fixture across batch sizes and package orders.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Typert 引用追踪包内转发模块
+
+Status: implemented
+
+[English](2026-09-07-typert-package-local-forwarding-imports.md) | 中文
+
+## Problem
+
+`WorkspaceAnalyzer` 先把每个类型引用解析到原始声明再分类,然后只读引用所在文件自己的 `import` 语句来判断该引用是否经由公开导出跨包。一个包若在自己的某个模块里重新导出另一个包的类型,并在别处用相对路径导入该模块,就会报 `crosses a package without an explicit package import`,尽管包导入只隔一跳。这个失败在任何批次大小和包顺序下都会稳定出现;它出现在哪次分析里,取决于哪次分析把引用方的包选为根,因此 [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) 观察到的现象像是与批次相关。
+
+## Decision
+
+[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) 通过该 face 共享的编译器宿主及其模块解析缓存来解析相对说明符,且只在解析到的文件仍位于引用方包内时继续追踪。在每个转发模块里,它收集承载所请求名字的 `export` 边:带说明符的具名重新导出、由该模块自身 `import` 支撑的 `export { local }`,以及导出同一符号的星号重新导出。显式边先于星号边尝试,与 TypeScript 中显式导出遮蔽星号导出的规则一致;解析后的模块与请求导出名组成的每个组合只进入一次,因此循环的星号重新导出能够终止,经同一模块转发的不同改名路径仍可继续尝试。追踪在遇到第一个包说明符时停止,并把该包身份和导出名交给现有的 `packageExportName` 检查,因此被转发的类型仍必须在转发模块所写的包子路径上公开,没有登记的包名也在此被拒绝。引用模型不变:同 face 的所有者仍是 `declaration`,另一 face 仍是 `cross-face`。
+
+当相对说明符解析到引用方包之外、承载该名字的唯一边是命名空间重新导出或被重新导出的命名空间导入,或所有边都回到已进入的模块与请求名组合时,追踪得不到包导入,引用照旧失败。
+
+## Alternatives considered
+
+**把别名链终点在另一个包的相对导入视为隐式公开。** 已拒绝:这会接受 `../../other/src/file.ts`,也会接受自身用相对路径抵达另一个包的转发模块,从而取消公开导出检查,而生成的 Remote 声明依赖该检查来命名可导入的子路径。
+
+**把转发模块记为引用目标。** 已拒绝:发射器和跨 face 链接需要原始声明的包和公开子路径,包内模块没有自己的公开身份。
+
+**按源码顺序选边且不校验符号。** 已拒绝:回到更早模块的星号重新导出可能排在真正承载该类型的显式重新导出之前,而 TypeScript 本身允许显式导出遮蔽星号导出;显式边优先并跳过已进入的模块与请求名组合,既能接受这类模块,又不会无限追踪。
+
+**让分批分析与全工作区分析选择相同的根。** 作为修复方案已拒绝:根的选择不改变对一个引用的判定,只决定该引用是否被访问,对齐调用方只会掩盖错误分类,不能消除它。
+
+## Consequences
+
+包可以为外部类型保留一个转发模块并用相对路径导入它,与自身模块的组织方式一致。每个跨包相对引用每跳付出一次经该 face 共享解析缓存的模块解析;`reachableFiles` 现在也通过同一缓存解析。[`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 固定了具名、改名多跳、先导入再导出、星号和命名空间导入这几种转发,与回环星号边并存的显式重新导出,经同一模块转发的不同改名路径,被转发的私有导出,用相对路径跨包的转发模块,唯一出口用相对路径跨包的循环,命名空间重新导出,被重新导出的命名空间导入,跨 face 转发,以及转发 fixture 在不同批次大小和包顺序下全量分析与分批分析相等。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.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-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 及损坏图片回退,以及工作区之外的图片。远程字节传输仍由现有文件系统提供方测试负责。

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

@@ -20,13 +20,15 @@ The scanner fetches every changed-file record before deciding. It fails if the p
 
 The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions.
 
-The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it removes the pull-request author and users who remain requested from the matched individual owners, then keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores.
+The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, approved owners omitted from new requests, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it fetches the complete chronological review list and reduces each owner's undismissed `APPROVED` and `CHANGES_REQUESTED` reviews to the latest decisive state; `COMMENTED` and `PENDING` reviews leave that state unchanged. It removes the pull-request author, owners with an active approval, and users who remain requested from the matched individual owners. An active approval remains sufficient after later synchronize events, while a later changes-requested review makes the owner eligible again. The review-list operation fails before mutation at 3,000 entries or on an invalid record.
+
+The workflow keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores.
 
 When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation.
 
 ## Verification
 
-[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, the 3,000-file limits, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
+[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, file and review limits, approval-state reduction, approved-owner suppression and next-owner selection, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
 
 ## Alternatives considered
 
@@ -40,12 +42,14 @@ When current review requests exist, the workflow reads the complete review-reque
 
 **Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation.
 
+**Treat an empty current request as an owner who still needs review.** GitHub removes the pending request when the reviewer submits a review. Requesting an owner with an active approval again adds no ownership coverage and creates repeated notifications after later synchronize events.
+
 **Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch.
 
 ## Consequences
 
 Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, rule and changed-file updates remove obsolete workflow-authored requests on the next run, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
 
-The workflow requests at most one reviewer per run, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
+The workflow requests at most one reviewer per run, does not repeat a request while that owner has an active approval, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
 
-Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file or timeline API limit fail without applying a partial reviewer mutation.
+Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file, review, or timeline API limit fail without applying a partial reviewer mutation.

+ 4 - 2
.github/review-ownership/README.md

@@ -21,9 +21,11 @@ Pull requests run the workflow when opened, synchronized, reopened, marked ready
 
 For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties.
 
+Before selecting a new reviewer, a non-draft run fetches the pull request's complete chronological review list. An owner's latest undismissed decisive review is `APPROVED` or `CHANGES_REQUESTED`; comments and pending reviews do not replace that decision. An approved owner remains omitted after later synchronize events, while a later changes-requested review makes the owner eligible again. The workflow fails before mutation when the list reaches the supported 3,000-review limit or contains an invalid record.
+
 On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events.
 
-The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author and users who remain requested are omitted from new requests.
+The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; approved owners omitted from new requests; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author, approved owners, and users who remain requested are omitted from new requests.
 
 The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
 
@@ -63,7 +65,7 @@ Ownership and approval policy changes take effect only after they merge into the
 
 ## Verification
 
-Run `pnpm run test:request-review` for routing behavior and `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI.
+Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI.
 
 <a id="dev-note"></a>
 

+ 54 - 1
.github/review-ownership/request-review.mjs

@@ -7,9 +7,11 @@ import { pathToFileURL } from 'node:url'
 const API_VERSION = '2026-03-10'
 const MAX_OWNERS_PER_RULE = 2
 const MAX_PULL_REQUEST_FILES = 3_000
+const MAX_PULL_REQUEST_REVIEWS = 3_000
 const MAX_COUNTED_REQUESTED_REVIEWERS = 1
 const MAX_TIMELINE_EVENTS = 3_000
 const PAGE_SIZE = 100
+const PULL_REQUEST_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
 const UNCOUNTED_REVIEWER = 'turtle1999'
 const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]'
 const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests'])
@@ -384,6 +386,47 @@ export async function listPullRequestFiles(api, repository, pullNumber, expected
   return files
 }
 
+/**
+ * Fetch the complete chronological pull-request review list.
+ * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
+ * @param {string} repository Owner/name repository identifier.
+ * @param {number} pullNumber Pull-request number.
+ * @returns {Promise<unknown[]>} Complete review list within the supported limit.
+ */
+export async function listPullRequestReviews(api, repository, pullNumber) {
+  const reviews = []
+  for (let page = 1; ; page++) {
+    const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`)
+    if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array')
+    reviews.push(...response)
+    if (response.length < PAGE_SIZE) return reviews
+    if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) {
+      throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} entries`)
+    }
+  }
+}
+
+/**
+ * Return users whose latest undismissed decisive review approves the pull request.
+ * @param {unknown[]} reviews Chronological GitHub pull-request review records.
+ * @returns {string[]} Approved reviewer logins in stable order.
+ */
+export function approvedReviewerLogins(reviews) {
+  const approved = new Map()
+  for (const review of reviews) {
+    if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') {
+      throw new Error('pull-request reviews response contains an invalid reviewer')
+    }
+    if (typeof review.state !== 'string' || !PULL_REQUEST_REVIEW_STATES.has(review.state)) {
+      throw new Error('pull-request reviews response contains an invalid state')
+    }
+    const key = review.user.login.toLowerCase()
+    if (review.state === 'APPROVED') approved.set(key, review.user.login)
+    else if (review.state === 'CHANGES_REQUESTED') approved.delete(key)
+  }
+  return [...approved.values()].sort((left, right) => left.localeCompare(right, 'en'))
+}
+
 /**
  * Fetch the pull request timeline used to identify workflow-authored review requests.
  * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
@@ -472,7 +515,7 @@ export async function requestReviews({ event, ownershipSource, api, write = line
     plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`),
   )
 
-  const candidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
+  const ownerCandidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
   if (pull.draft) {
     const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
     const requestedReviewers = requestedReviewerLogins(existing)
@@ -494,6 +537,16 @@ export async function requestReviews({ event, ownershipSource, api, write = line
     return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers }
   }
 
+  const approvedReviewerKeys = new Set(
+    (ownerCandidates.length === 0
+      ? []
+      : approvedReviewerLogins(await listPullRequestReviews(api, pull.repository, pull.number)))
+      .map(login => login.toLowerCase()),
+  )
+  const approvedOwners = ownerCandidates.filter(({ login }) => approvedReviewerKeys.has(login.toLowerCase()))
+  const candidates = ownerCandidates.filter(({ login }) => !approvedReviewerKeys.has(login.toLowerCase()))
+  writeList(write, 'Approved owners omitted from review requests', approvedOwners.map(({ login }) => `@${login}`))
+
   const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
   const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en'))
   const workflowReviewers = currentReviewers.length === 0

+ 121 - 0
.github/review-ownership/request-review.test.mjs

@@ -4,12 +4,14 @@ import { existsSync, readFileSync } from 'node:fs'
 import test from 'node:test'
 
 import {
+  approvedReviewerLogins,
   classifyChangedFiles,
   createGitHubApi,
   isCommentOnlyChange,
   isDocumentationPath,
   isTestPath,
   listPullRequestFiles,
+  listPullRequestReviews,
   listPullRequestTimeline,
   normalizeRepositoryPath,
   parseOwnership,
@@ -333,6 +335,57 @@ test('fails closed when GitHub cannot provide the complete file list', async ()
   )
 })
 
+test('fetches pull-request reviews across pages', async () => {
+  const calls = []
+  const pageOne = Array.from({ length: 100 }, (_, index) => ({
+    user: { login: `reviewer-${index}` },
+    state: 'COMMENTED',
+  }))
+  const pageTwo = [{ user: { login: 'approver' }, state: 'APPROVED' }]
+  const reviews = await listPullRequestReviews(async (path) => {
+    calls.push(path)
+    return calls.length === 1 ? pageOne : pageTwo
+  }, 'owner/repo', 42)
+
+  assert.equal(reviews.length, 101)
+  assert.deepEqual(calls, [
+    '/repos/owner/repo/pulls/42/reviews?per_page=100&page=1',
+    '/repos/owner/repo/pulls/42/reviews?per_page=100&page=2',
+  ])
+})
+
+test('tracks each reviewer\'s latest undismissed approval decision', () => {
+  assert.deepEqual(approvedReviewerLogins([
+    { user: { login: 'commented-after' }, state: 'APPROVED' },
+    { user: { login: 'commented-after' }, state: 'COMMENTED' },
+    { user: { login: 'changes-after' }, state: 'APPROVED' },
+    { user: { login: 'changes-after' }, state: 'CHANGES_REQUESTED' },
+    { user: { login: 'dismissed' }, state: 'DISMISSED' },
+    { user: { login: 'approved-after' }, state: 'CHANGES_REQUESTED' },
+    { user: { login: 'approved-after' }, state: 'APPROVED' },
+    { user: { login: 'pending-after' }, state: 'APPROVED' },
+    { user: { login: 'pending-after' }, state: 'PENDING' },
+  ]), ['approved-after', 'commented-after', 'pending-after'])
+
+  assert.throws(
+    () => approvedReviewerLogins([{ user: { login: 'reviewer' }, state: 'UNKNOWN' }]),
+    /invalid state/u,
+  )
+  assert.throws(() => approvedReviewerLogins([{ state: 'APPROVED' }]), /invalid reviewer/u)
+})
+
+test('fails closed when the pull-request review list exceeds its limit', async () => {
+  let calls = 0
+  await assert.rejects(
+    listPullRequestReviews(async () => {
+      calls++
+      return Array.from({ length: 100 }, () => ({ user: { login: 'reviewer' }, state: 'COMMENTED' }))
+    }, 'owner/repo', 42),
+    /exceed 3000 entries/u,
+  )
+  assert.equal(calls, 30)
+})
+
 test('fails closed when the review-request timeline exceeds its limit', async () => {
   let calls = 0
   await assert.rejects(
@@ -358,6 +411,7 @@ test('prints changed code files and requests the highest-ranked counted owner',
   const api = async (path, options = {}) => {
     trace.push({ type: 'api', path, options })
     if (path.endsWith('/files?per_page=100&page=1')) return files
+    if (path.endsWith('/reviews?per_page=100&page=1')) return []
     if (path.endsWith('/requested_reviewers') && options.method !== 'POST') {
       return { users: [], teams: [] }
     }
@@ -409,6 +463,67 @@ test('prints changed code files and requests the highest-ranked counted owner',
   })
 })
 
+test('does not request an owner again after that owner approves', async () => {
+  const calls = []
+  const output = []
+  const result = await requestReviews({
+    event: pullRequestEvent(),
+    ownershipSource: '/packages/typert/ @imccyu\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/typert/generator/src/analyzer.ts', additions: 150, deletions: 47 }]
+      }
+      if (path.endsWith('/reviews?per_page=100&page=1')) {
+        return [
+          { user: { login: 'imccyu' }, state: 'APPROVED' },
+          { user: { login: 'imccyu' }, state: 'COMMENTED' },
+        ]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [], teams: [] }
+      }
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => output.push(line),
+  })
+
+  assert.deepEqual(result.requestedReviewers, [])
+  assert.equal(calls.some(call => call.options.method === 'POST'), false)
+  const approvedHeading = output.indexOf('Approved owners omitted from review requests:')
+  assert.ok(approvedHeading >= 0)
+  assert.equal(output[approvedHeading + 1], '- @imccyu')
+})
+
+test('fills the counted slot with the next owner after omitting an approved owner', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent(),
+    ownershipSource: '/packages/core/ @imccyu @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
+      }
+      if (path.endsWith('/reviews?per_page=100&page=1')) {
+        return [{ user: { login: 'imccyu' }, state: 'APPROVED' }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [], teams: [] }
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
+  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
+  })
+})
+
 test('does not add another counted owner when one is already requested', async () => {
   const calls = []
   const output = []
@@ -420,6 +535,7 @@ test('does not add another counted owner when one is already requested', async (
       if (path.endsWith('/files?per_page=100&page=1')) {
         return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
       }
+      if (path.endsWith('/reviews?per_page=100&page=1')) return []
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
         return { users: [{ login: 'first' }], teams: [] }
       }
@@ -455,6 +571,7 @@ test('requests at most one owner per run when turtle ranks first', async () => {
           { filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 },
         ]
       }
+      if (path.endsWith('/reviews?per_page=100&page=1')) return []
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
         return { users: [], teams: [] }
       }
@@ -481,6 +598,7 @@ test('does not add turtle when one counted reviewer is already requested', async
       if (path.endsWith('/files?per_page=100&page=1')) {
         return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
       }
+      if (path.endsWith('/reviews?per_page=100&page=1')) return []
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
         return { users: [{ login: 'first' }], teams: [] }
       }
@@ -504,6 +622,7 @@ test('keeps the counted slot available when turtle is already requested', async
       if (path.endsWith('/files?per_page=100&page=1')) {
         return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
       }
+      if (path.endsWith('/reviews?per_page=100&page=1')) return []
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
         return { users: [{ login: 'turtle1999' }], teams: [] }
       }
@@ -531,6 +650,7 @@ test('replaces a workflow reviewer that no longer matches current ownership', as
       if (path.endsWith('/files?per_page=100&page=1')) {
         return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
       }
+      if (path.endsWith('/reviews?per_page=100&page=1')) return []
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
         return { users: [{ login: 'Dudu-0223' }], teams: [] }
       }
@@ -583,6 +703,7 @@ test('removes excess workflow reviewers using current relevance order', async ()
           { filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 },
         ]
       }
+      if (path.endsWith('/reviews?per_page=100&page=1')) return []
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
         return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] }
       }

+ 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 - 22
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,29 +147,36 @@ describe('web e2e: queue row actions', () => {
     ).toBe(2)
 
     await page.setViewportSize({ width: 640, height: 1000 })
-    const queueBox = await page.locator('[data-queue-dock]').boundingBox()
-    const composerBox = await page.locator('[data-composer-card]').boundingBox()
-    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
-    const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
-      const style = getComputedStyle(element)
-      return {
-        dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
-      }
-    })
-    expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
-    expect(queueRightInset).toBeCloseTo(composerMetrics.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()
@@ -178,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)
@@ -215,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/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: 90390cab85be57485ad5446e1897f4f11be49ef5
-config-catalog.zh.md: 053ca6d1e357c07524b4b993447a59ed69761a88
+config-catalog.md: 27b0ca8843cc2f65e936a1e8e9fdfd725fc4216d
+config-catalog.zh.md: 63f3dbd4dd5d7edb78d95fa92d0d04e592c71bbe

+ 4 - 4
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>
 
@@ -2990,8 +2990,8 @@ export interface Config {
    */
   toolName?: string
   /**
-   * Sample the Host `subagent-model-selection` user setting for each new
-   * top-level session and inherit that decision in its child sessions.
+   * Sample the Host `subagent-model-selection` setting for each new top-level
+   * Session and inherit that decision in its child Sessions.
    */
   modelSelectionSettings?: boolean
   /**
@@ -3041,7 +3041,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](subsystems/core.md)
 
-Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts)
+Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts)
 
 <a id="deepseek-aidsh-tool-terminal"></a>
 

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

@@ -2992,8 +2992,8 @@ export interface Config {
    */
   toolName?: string
   /**
-   * Sample the Host `subagent-model-selection` user setting for each new
-   * top-level session and inherit that decision in its child sessions.
+   * Sample the Host `subagent-model-selection` setting for each new top-level
+   * Session and inherit that decision in its child Sessions.
    */
   modelSelectionSettings?: boolean
   /**

+ 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: ecb72f02bc7a27a75318e8abf56c20f9915831ea
-module-graph.zh.md: 5c94c866d10e81948dd69089210cd4b08ded7531
+module-graph.md: f0ebb388318ed4a0aa8896a8040a5539f5477037
+module-graph.zh.md: 0cc8ff7b9a7601102f8d708b8ebb2d17c7bb0612

+ 2 - 1
docs/module-graph.md

@@ -1070,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
@@ -1419,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 - 1
docs/module-graph.zh.md

@@ -1072,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
@@ -1421,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/core.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/core.md
-core.md: 4af3a22478f324655dea1b132f0c8a8528f4b883
-core.zh.md: 7359e5aaa75e9f750d44f1386acab388665e1e1e
+core.md: d2ebe7a3d8e6ecebfd1c3fd5a1dacc376956ea08
+core.zh.md: 439155e3b2dcadd4697cf6029a8c4ca399439cba

+ 11 - 9
docs/subsystems/core.md

@@ -46,9 +46,9 @@ interface AgentHandle {
 }
 ```
 
-`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id.
+`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: an optional live `parentAgent`, session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `parentAgent`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` while both ids are still unpublished: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id.
 
-`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below.
+`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator sets `options.parentAgent`; the registry passes the options and caller Context to the factory without deriving one from the other. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below.
 
 ## The agent handle
 
@@ -463,7 +463,7 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader
 /**
  * Create an owned agent on a caller-supplied session id.
  * @param ownerCtx - caller context that structurally owns the lifecycle.
- * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
+ * @param options - identities, optional live parent, session seed/metadata, loop options, setup, and cancellation.
  * @returns the published handle.
  */
 async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
@@ -471,7 +471,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
 /**
  * Resume an owned agent from the configured persistence service.
  * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
- * @param options - persisted identity, loop options, setup, and cancellation.
+ * @param options - persisted identity, optional live parent, loop options, setup, and cancellation.
  * @returns the published handle.
  */
 async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
@@ -736,7 +736,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence
  * Read the Agent that initiated the inherited asynchronous driver chain.
  * Use this optional form for logging, tracing, metrics, or host attribution
  * that also supports agentless calls. When a parent creates a child, setup
- * reports the causal parent while `agentCtx.agent` identifies the child.
+ * reports the causal parent while the setup callback's Agent parameter
+ * identifies the child.
  * @returns the inherited Agent, or `undefined` outside an initiator boundary
  *   and inside an explicit clearing boundary.
  * @throws when this service instance has been disposed.
@@ -801,7 +802,7 @@ setFactory(factory: AgentFactory): () => void
  * agent): this constructs the agent and its session. Rejects if no factory is
  * registered or creation/setup fails. The resolved {@link AgentHandle} lets
  * the owner tear down exactly this agent.
- * @param options - shared identity, session seed/metadata, and agent options.
+ * @param options - shared identity, optional live parent, session seed/metadata, and agent options.
  * @returns the handle after setup, rollback-covered publication, and loop start complete.
  */
 async create(options: CreateAgentOptions): Promise<AgentHandle>
@@ -810,7 +811,7 @@ async create(options: CreateAgentOptions): Promise<AgentHandle>
  * Load a persisted session and resume an agent on it through the registered
  * factory. Rejects if no factory is registered; the factory rejects if
  * session persistence is not configured or persistence/setup fails.
- * @param options - persisted identity, configuration, and optional setup.
+ * @param options - persisted identity, optional live parent, configuration, and setup.
  * @returns the handle after setup, rollback-covered publication, and loop start complete.
  */
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
@@ -822,7 +823,8 @@ async resume(options: ResumeAgentOptions): Promise<AgentHandle>
  * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
  * emits are scope-filtered regardless of which context invoked `register`
  * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
- * requires passing the carrier). Returns the disposer.
+ * requires passing the carrier). The entry is a runtime root; factory-backed
+ * creation uses `options.parentAgent` for child ownership. Returns the disposer.
  * @param agent - the already-constructed agent to record in the store.
  * @returns the EXACT Cordis effect disposer (single-shot; a repeat call
  *   returns undefined without awaiting an in-flight teardown). Exact
@@ -842,7 +844,7 @@ register(agent: Agent): () => void
  * returned detach closure into its pre-installed composite teardown before
  * calling {@link announce}. Ordinary callers use {@link register}.
  * @param agent - the prepared, unpublished agent.
- * @param owner - live agent whose scoped context created this agent, or
+ * @param owner - explicitly supplied live runtime owner, or
  *   undefined for a top-level runtime root. This is runtime ownership, not
  *   the resumed session's durable parent lineage.
  * @returns an idempotent closure that removes this exact entry and emits

+ 11 - 9
docs/subsystems/core.zh.md

@@ -48,9 +48,9 @@ interface AgentHandle {
 }
 ```
 
-`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。
+`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:可选的存活 `parentAgent`、会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`parentAgent`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 均未发布时接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。
 
-`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。
+`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方设置 `options.parentAgent`;注册表把 options 与调用方 Context 传给工厂,不从其中一项推导另一项。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。
 
 <a id="the-agent-handle"></a>
 
@@ -473,7 +473,7 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader
 /**
  * Create an owned agent on a caller-supplied session id.
  * @param ownerCtx - caller context that structurally owns the lifecycle.
- * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
+ * @param options - identities, optional live parent, session seed/metadata, loop options, setup, and cancellation.
  * @returns the published handle.
  */
 async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
@@ -481,7 +481,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
 /**
  * Resume an owned agent from the configured persistence service.
  * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
- * @param options - persisted identity, loop options, setup, and cancellation.
+ * @param options - persisted identity, optional live parent, loop options, setup, and cancellation.
  * @returns the published handle.
  */
 async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
@@ -746,7 +746,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence
  * Read the Agent that initiated the inherited asynchronous driver chain.
  * Use this optional form for logging, tracing, metrics, or host attribution
  * that also supports agentless calls. When a parent creates a child, setup
- * reports the causal parent while `agentCtx.agent` identifies the child.
+ * reports the causal parent while the setup callback's Agent parameter
+ * identifies the child.
  * @returns the inherited Agent, or `undefined` outside an initiator boundary
  *   and inside an explicit clearing boundary.
  * @throws when this service instance has been disposed.
@@ -811,7 +812,7 @@ setFactory(factory: AgentFactory): () => void
  * agent): this constructs the agent and its session. Rejects if no factory is
  * registered or creation/setup fails. The resolved {@link AgentHandle} lets
  * the owner tear down exactly this agent.
- * @param options - shared identity, session seed/metadata, and agent options.
+ * @param options - shared identity, optional live parent, session seed/metadata, and agent options.
  * @returns the handle after setup, rollback-covered publication, and loop start complete.
  */
 async create(options: CreateAgentOptions): Promise<AgentHandle>
@@ -820,7 +821,7 @@ async create(options: CreateAgentOptions): Promise<AgentHandle>
  * Load a persisted session and resume an agent on it through the registered
  * factory. Rejects if no factory is registered; the factory rejects if
  * session persistence is not configured or persistence/setup fails.
- * @param options - persisted identity, configuration, and optional setup.
+ * @param options - persisted identity, optional live parent, configuration, and setup.
  * @returns the handle after setup, rollback-covered publication, and loop start complete.
  */
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
@@ -832,7 +833,8 @@ async resume(options: ResumeAgentOptions): Promise<AgentHandle>
  * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
  * emits are scope-filtered regardless of which context invoked `register`
  * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
- * requires passing the carrier). Returns the disposer.
+ * requires passing the carrier). The entry is a runtime root; factory-backed
+ * creation uses `options.parentAgent` for child ownership. Returns the disposer.
  * @param agent - the already-constructed agent to record in the store.
  * @returns the EXACT Cordis effect disposer (single-shot; a repeat call
  *   returns undefined without awaiting an in-flight teardown). Exact
@@ -852,7 +854,7 @@ register(agent: Agent): () => void
  * returned detach closure into its pre-installed composite teardown before
  * calling {@link announce}. Ordinary callers use {@link register}.
  * @param agent - the prepared, unpublished agent.
- * @param owner - live agent whose scoped context created this agent, or
+ * @param owner - explicitly supplied live runtime owner, or
  *   undefined for a top-level runtime root. This is runtime ownership, not
  *   the resumed session's durable parent lineage.
  * @returns an idempotent closure that removes this exact entry and emits

+ 2 - 2
docs/subsystems/subagent.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/subagent.md
-subagent.md: cf711185d02808f70a71a46c6d6c1af4da4a7334
-subagent.zh.md: e97b4ab965d279f04ddda5e8ae66621b0198a5cd
+subagent.md: 55e8f8af23cb71c1103c017c09e9dfd2ef365b75
+subagent.zh.md: 71a71b33771ff83ccaa6802358b8534c11930181

+ 2 - 2
docs/subsystems/subagent.md

@@ -471,11 +471,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig`
 
-Singleton settings owner read by delegation tools when an Agent is published.
+Singleton settings owner read when delegation tools are composed for a Session.
 
 ```ts cordis-catalog
 /**
- * Read a detached selection preference for the next eligible Agent publication.
+ * Read a detached selection preference for the next eligible Session composition.
  * @returns the enabled state and exact allowed routes.
  */
 current(): SubagentModelSelectionSettings

+ 2 - 2
docs/subsystems/subagent.zh.md

@@ -475,11 +475,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig`
 
-Singleton settings owner read by delegation tools when an Agent is published.
+Singleton settings owner read when delegation tools are composed for a Session.
 
 ```ts cordis-catalog
 /**
- * Read a detached selection preference for the next eligible Agent publication.
+ * Read a detached selection preference for the next eligible Session composition.
  * @returns the enabled state and exact allowed routes.
  */
 current(): SubagentModelSelectionSettings

+ 1 - 4
packages/acp/acp/src/session.ts

@@ -150,10 +150,7 @@ export class AcpSession {
       resumeSessionId: options.sessionId,
       agentOptions: options.agentOptions,
       signal: options.signal,
-      setup: async (agentCtx) => {
-        const agent = agentCtx.agent
-        /* v8 ignore next -- Agent factory setup always carries its unpublished Agent. */
-        if (agent === undefined) throw new Error('acp: resumed Agent is absent during setup')
+      setup: async (agentCtx, agent) => {
         modelControl = new AcpModelControl(
           ctx.llm,
           selectionFor(agent.session.requestHeader(), options.fallbackSelection),

+ 3 - 8
packages/api/gateway/src/index.ts

@@ -457,12 +457,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
   private startRemoteEvent(source: TypertRemoteEventInvocation): void {
     try {
       assertRemoteEventName(source)
-      const context = this.ctx.typert.contexts.identifyHost(source.context.value)
-      if (context === undefined) {
-        source.resolve({ kind: 'next' })
-        return
-      }
-      if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) {
+      if (!isRemoteEventAgentId(source.context.agentId)) {
         throw new TypeError(
           'typert gateway: scoped Remote events require a non-empty Agent identity',
         )
@@ -476,7 +471,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
           () => () => {
             this.cancelRemoteEvent(
               pending,
-              new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`),
+              new Error('typert gateway: Remote event Agent Context was released'),
             )
           },
           `api-gateway: Remote event ${JSON.stringify(source.event)}`,
@@ -500,7 +495,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
           type: 'waterfall',
           event: source.event,
           eventId: id,
-          agentId: context.identity,
+          agentId: source.context.agentId,
           request: projected.request,
         },
         deliveries: new Set(),

+ 3 - 1
packages/api/gateway/src/types.ts

@@ -28,10 +28,12 @@ export interface TypertRemoteEventFrame {
 
 /** Live Host values used to project one scoped Remote Event. */
 export interface TypertRemoteEventContext {
-  /** Live Host Context identified by the registered Host adapters. */
+  /** Live Agent Context that owns cancellation of the forwarded waterfall. */
   readonly value: Context
   /** Agent object carried directly by the waterfall request. */
   readonly subject: object
+  /** Agent identity read directly from the scoped event subject. */
+  readonly agentId: string
 }
 
 /** Result returned from a Client waterfall, or delegation back to the Host chain. */

+ 15 - 85
packages/api/gateway/tests/gateway-stream.host.spec.ts

@@ -188,6 +188,7 @@ function pendingInvocation(
   context: Context,
   signal?: AbortSignal,
   prompt = 'ship',
+  identity: unknown = agentId('agent-1'),
 ): PendingInvocationProbe {
   const subject = { ctx: context }
   const settled = Promise.withResolvers<TypertRemoteEventOutcome>()
@@ -201,7 +202,7 @@ function pendingInvocation(
     dispatch: {
       event: 'fixture/approval',
       request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) },
-      context: { value: context, subject },
+      context: { value: context, subject, agentId: identity as string },
       resolve,
       reject,
     },
@@ -466,13 +467,7 @@ describe('Typert Remote streams', () => {
   it('cancels a pending waterfall when its source rejects during removal', async () => {
     const { ctx } = await setup(true)
     const agent = ctx.extend()
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === agent ? agentId('agent-removal') : undefined,
-      resolve: id => id === 'agent-removal' ? agent : undefined,
-    })
-    const pending = pendingInvocation(agent)
+    const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-removal'))
     const rejected = expect(pending.outcome).rejects.toThrow(
       'forwarded Remote event source was removed',
     )
@@ -497,7 +492,7 @@ describe('Typert Remote streams', () => {
     client.socket.close()
   })
 
-  it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => {
+  it('rejects malformed scoped invocations and delegates a released Context', async () => {
     const { ctx } = await setup(false)
     const source = new RemoteEventSourceProbe()
     const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
@@ -514,28 +509,15 @@ describe('Typert Remote streams', () => {
       await rejected
     }
 
-    const unavailable = pendingInvocation(ctx)
-    source.push(unavailable.dispatch)
-    await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' })
-    expect(unavailable.reject).not.toHaveBeenCalled()
-
     let selected = ctx.extend()
-    let identity: unknown = 1n
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === selected ? identity as AgentWireId : undefined,
-      resolve: () => selected,
-    })
-    const nonJsonIdentity = pendingInvocation(selected)
+    const nonJsonIdentity = pendingInvocation(selected, undefined, 'ship', 1n)
     const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow(
       'require a non-empty Agent identity',
     )
     source.push(nonJsonIdentity.dispatch)
     await nonJsonRejected
 
-    identity = 'agent-invalid-request'
-    const invalidRequest = pendingInvocation(selected)
+    const invalidRequest = pendingInvocation(selected, undefined, 'ship', agentId('agent-invalid-request'))
     const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow(
       'must carry its scoped Agent directly',
     )
@@ -548,18 +530,16 @@ describe('Typert Remote streams', () => {
     const staleFiber = ctx.plugin(() => {})
     await staleFiber
     selected = staleFiber.ctx
-    identity = 'agent-stale'
     await staleFiber.dispose()
-    const stale = pendingInvocation(selected)
+    const stale = pendingInvocation(selected, undefined, 'ship', agentId('agent-stale'))
     source.push(stale.dispatch)
     await expect(stale.outcome).resolves.toEqual({ kind: 'next' })
     expect(stale.reject).not.toHaveBeenCalled()
 
     selected = ctx.extend()
-    identity = 'agent-cancelled'
     const abort = new AbortController()
     abort.abort('fixture non-error cancellation')
-    const cancelled = pendingInvocation(selected, abort.signal)
+    const cancelled = pendingInvocation(selected, abort.signal, 'ship', agentId('agent-cancelled'))
     const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({
       message: 'typert gateway: Remote event was cancelled',
       cause: 'fixture non-error cancellation',
@@ -597,19 +577,13 @@ describe('Typert Remote streams', () => {
     const source = new RemoteEventSourceProbe()
     const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
     const agent = ctx.extend()
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === agent ? agentId('agent-collision') : undefined,
-      resolve: id => id === 'agent-collision' ? agent : undefined,
-    })
     const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType<typeof randomUUID>
     const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType<typeof randomUUID>
     randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
     const firstAbort = new AbortController()
     const secondAbort = new AbortController()
-    const first = pendingInvocation(agent, firstAbort.signal, 'first')
-    const second = pendingInvocation(agent, secondAbort.signal, 'second')
+    const first = pendingInvocation(agent, firstAbort.signal, 'first', agentId('agent-collision'))
+    const second = pendingInvocation(agent, secondAbort.signal, 'second', agentId('agent-collision'))
 
     source.push(first.dispatch)
     await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) })
@@ -651,12 +625,6 @@ describe('Typert Remote streams', () => {
     const source = new RemoteEventSourceProbe()
     const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
     const agent = ctx.extend()
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
-      resolve: id => id === 'agent-1' ? agent : undefined,
-    })
     const first = await openEventClient(ctx, 'events-a')
     const second = await openEventClient(ctx, 'events-b')
     const pending = pendingInvocation(agent)
@@ -705,14 +673,8 @@ describe('Typert Remote streams', () => {
     const source = new RemoteEventSourceProbe()
     const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
     const agent = ctx.extend()
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined,
-      resolve: id => id === 'agent-rejected' ? agent : undefined,
-    })
     const client = await openEventClient(ctx, 'events-rejected')
-    const pending = pendingInvocation(agent)
+    const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-rejected'))
     source.push(pending.dispatch)
     await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
     const frame = deliveredInvocation(client)!
@@ -745,12 +707,6 @@ describe('Typert Remote streams', () => {
     const source = new RemoteEventSourceProbe()
     const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
     const agent = ctx.extend()
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
-      resolve: id => id === 'agent-1' ? agent : undefined,
-    })
     const first = await openEventClient(ctx, 'events-next-a')
     const second = await openEventClient(ctx, 'events-next-b')
     const pending = pendingInvocation(agent)
@@ -778,13 +734,7 @@ describe('Typert Remote streams', () => {
     const source = new RemoteEventSourceProbe()
     const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
     const agent = ctx.extend()
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === agent ? agentId('agent-late-client') : undefined,
-      resolve: id => id === 'agent-late-client' ? agent : undefined,
-    })
-    const pending = pendingInvocation(agent, undefined, 'before-connect')
+    const pending = pendingInvocation(agent, undefined, 'before-connect', agentId('agent-late-client'))
 
     source.push(pending.dispatch)
     await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) })
@@ -811,12 +761,6 @@ describe('Typert Remote streams', () => {
     const source = new RemoteEventSourceProbe()
     const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
     const agent = ctx.extend()
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
-      resolve: id => id === 'agent-1' ? agent : undefined,
-    })
     const original = await openEventClient(ctx, 'events-original')
     const pending = pendingInvocation(agent)
     source.push(pending.dispatch)
@@ -848,24 +792,10 @@ describe('Typert Remote streams', () => {
     const contextFiber = ctx.plugin(() => {})
     await contextFiber
     const contextAgent = contextFiber.ctx
-    ctx.typert.contexts.registerHost('agent', {
-      wire: 'agentId',
-      wireTypeSymbol: '@fixture#AgentId',
-      identity: (candidate) => {
-        if (candidate === signalAgent) return agentId('agent-signal')
-        if (candidate === contextAgent) return agentId('agent-context')
-        return undefined
-      },
-      resolve: (id) => {
-        if (id === 'agent-signal') return signalAgent
-        if (id === 'agent-context') return contextAgent
-        return undefined
-      },
-    })
     const client = await openEventClient(ctx, 'events-cancel')
 
     const abort = new AbortController()
-    const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal')
+    const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal', agentId('agent-signal'))
     source.push(signalPending.dispatch)
     await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
     const signalFrame = deliveredInvocation(client)!
@@ -886,7 +816,7 @@ describe('Typert Remote streams', () => {
       })
     })
 
-    const contextPending = pendingInvocation(contextAgent, undefined, 'context')
+    const contextPending = pendingInvocation(contextAgent, undefined, 'context', agentId('agent-context'))
     source.push(contextPending.dispatch)
     let contextFrame: RemoteEventInvocationFrame | undefined
     await vi.waitFor(() => {
@@ -899,7 +829,7 @@ describe('Typert Remote streams', () => {
           && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined
       expect(contextFrame).toBeDefined()
     })
-    const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released')
+    const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Agent Context was released')
     await contextFiber.dispose()
     await contextOutcome
     await vi.waitFor(() => {

+ 0 - 1
packages/api/gateway/tests/gateway.host.spec.ts

@@ -1344,7 +1344,6 @@ function contextProvider(context: Context) {
   return {
     wire: 'agentId',
     wireTypeSymbol: '@fixture/domain#AgentId',
-    identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined,
     resolve: (id: string) => id === 'agent-1' ? context : undefined,
   }
 }

+ 2 - 2
packages/api/remotes/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/remotes/README.md
-README.md: 1a6c311db9d456db17d942b56cc133799f355a88
-README.zh.md: cba94598868db8401ea512bdb6c274fa2fe098e5
+README.md: 52ef6223f4d7770224df1b1c5d783962c55f73f6
+README.zh.md: eab5d2c6642a7899fac60d8c666ee55057eaea05

+ 1 - 1
packages/api/remotes/README.md

@@ -42,7 +42,7 @@ This package owns no physical transport or Host service discovery. It projects t
 
 The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback.
 
-The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams.
+The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams.
 
 <a id="build-boundary"></a>
 ## Build boundary

+ 1 - 1
packages/api/remotes/README.zh.md

@@ -42,7 +42,7 @@ Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件
 
 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。
 
-Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。
+Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 Agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。
 
 <a id="build-boundary"></a>
 ## 构建边界

+ 1 - 0
packages/api/remotes/package.json

@@ -60,6 +60,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-api-settings-controller": "workspace:^",

+ 7 - 6
packages/api/remotes/src/index.ts

@@ -2,6 +2,7 @@
 
 import { homedir } from 'node:os'
 import type { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
 import type {
   TypertRemoteEventDispatch,
   TypertRemoteEventInvocation,
@@ -57,17 +58,17 @@ function remoteEventSource(ctx: Context): TypertRemoteEventSource {
         request: object,
         next: () => unknown,
       ) {
-        const subject = carrierKeyOf(this)
-        if (subject === undefined) return next()
-        const value = Reflect.get(subject, 'ctx') as unknown
-        if (typeof value !== 'object' || value === null) {
-          throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`)
+        const carrierAgent = carrierKeyOf(this)
+        if (carrierAgent === undefined) return next()
+        const agent = (request as { readonly agent?: Agent }).agent
+        if (agent === undefined || agent !== carrierAgent) {
+          throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} must carry its Agent directly`)
         }
         return forwardWaterfall(
           queue,
           event,
           request,
-          { value: value as Context, subject },
+          { value: agent.ctx, subject: agent, agentId: agent.id },
           next,
         )
       }) as never)

+ 11 - 3
packages/api/remotes/tests/remote-events.host.spec.ts

@@ -173,10 +173,18 @@ describe('Remote event Host source', () => {
     const abort = new AbortController()
     const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
     const agentCtx = ctx.extend()
-    const agent = { ctx: agentCtx }
+    const agent = { id: 'agent-1', ctx: agentCtx }
     const target = scopeTarget(ctx, agent)
     const request = { questions: [], agent }
 
+    await expect(async () => waterfallRaw(
+      ctx,
+      target,
+      'user-questions/request',
+      [{ questions: [], agent: { id: 'agent-2', ctx: ctx.extend() } }],
+      () => Promise.resolve('host fallback'),
+    )).rejects.toThrow('must carry its Agent directly')
+
     const claimed = waterfallRaw(
       ctx,
       target,
@@ -188,7 +196,7 @@ describe('Remote event Host source', () => {
     expect(claimedDispatch).toMatchObject({
       event: 'user-questions/request',
       request,
-      context: { value: agentCtx, subject: agent },
+      context: { value: agentCtx, subject: agent, agentId: 'agent-1' },
     })
     claimedDispatch.resolve({ kind: 'result', value: 'client answer' })
     await expect(claimed).resolves.toBe('client answer')
@@ -230,7 +238,7 @@ describe('Remote event Host source', () => {
     const abort = new AbortController()
     const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
     const delivery = iterator.next()
-    const agent = { ctx: ctx.extend() }
+    const agent = { id: 'agent-1', ctx: ctx.extend() }
     const reason = new Error('forwarded event source removed')
     const pending = waterfallRaw(
       ctx,

+ 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"
   }
 }

+ 6 - 6
packages/api/session-controller/src/agent.ts

@@ -376,12 +376,14 @@ export class ApiSessionAgentController {
     readonly setup: AgentSetup
   }> {
     const presets = this.ctx.get('agentPresets')
-    if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } }
+    if (presets === undefined) {
+      return { setup: (_agentCtx, agent) => { this.installSelection(agent) } }
+    }
     const resolvedId = (await presets.resolve(presetId)).id
     return {
       agentPreset: resolvedId,
-      setup: async (agentCtx) => {
-        this.installSelection(agentCtx)
+      setup: async (agentCtx, agent) => {
+        this.installSelection(agent)
         await presets.mount(agentCtx, resolvedId)
       },
     }
@@ -490,9 +492,7 @@ export class ApiSessionAgentController {
     return { provider, model }
   }
 
-  private installSelection(agentCtx: Context): void {
-    const agent = agentCtx.agent
-    if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent')
+  private installSelection(agent: Agent): void {
     this.selectionFor(agent)
   }
 

+ 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')
+  },
+}

+ 1 - 4
packages/api/session-controller/tests/agent.host.spec.ts

@@ -443,7 +443,7 @@ describe('ApiSession create or adoption', () => {
       .rejects.toBeInstanceOf(ApiSessionCwdConflict)
   })
 
-  it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => {
+  it('surfaces directory creation failure', async () => {
     const { agents } = await harness()
     const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
     tempDirs.push(parent)
@@ -451,8 +451,5 @@ describe('ApiSession create or adoption', () => {
     writeFileSync(file, 'not a directory')
     await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))
       .rejects.toThrow('failed to ensure project directory')
-
-    const composition = await agents.composeAgent(undefined)
-    expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent')
   })
 })

+ 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)
+  })
+})

+ 2 - 2
packages/api/session-controller/tests/session-fork.host.spec.ts

@@ -37,9 +37,9 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
           : { inheritedEventCount: options.inheritedEventCount },
       })
       const agent = {} as Agent
-      const agentCtx = ownerCtx.extend({ agent })
+      const agentCtx = ownerCtx
       Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
-      await options.setup?.(agentCtx)
+      await options.setup?.(agentCtx, agent)
       ctx.agents.register(agent)
       return { agent, dispose: () => Promise.resolve() }
     },

+ 2 - 3
packages/api/session-controller/tests/session-presets.host.spec.ts

@@ -66,9 +66,8 @@ async function harness(presets?: readonly string[]) {
         options.meta === undefined ? {} : { meta: options.meta },
       )
       const agent = stubAgent(session)
-      const agentCtx = ctx.extend({ agent })
-      ;(agent as { ctx?: Context }).ctx = agentCtx
-      await options.setup?.(agentCtx)
+      ;(agent as { ctx?: Context }).ctx = ctx
+      await options.setup?.(ctx, agent)
       const unregister = ctx.agents.register(agent)
       return { agent, dispose: () => { unregister(); return Promise.resolve() } }
     },

+ 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" },

+ 1 - 5
packages/bundle/headless/tests/headless.spec.ts

@@ -112,11 +112,7 @@ async function bench(script: Script): Promise<{
         inject: () => {},
         whenIdle: () => idle,
       }
-      const agentCtx = ownerCtx.extend({ agent })
-      Object.assign(agent, {
-        ctx: agentCtx,
-      })
-      await options.setup?.(agentCtx)
+      await options.setup?.(ownerCtx, agent)
       script.before?.(session)
       ctx.agents.register(agent)
       return { agent, dispose: () => Promise.resolve() }

+ 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(),

+ 2 - 2
packages/core/agent-loop/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/core/agent-loop/README.md
-README.md: 03a80b9ffd27953aef49432dcdf7cbb8c90d675f
-README.zh.md: 8ceb282082b1a563d8118eda6f668d9796314bca
+README.md: cd60cfbbc4e6b761f796ffb5b94d2765aa0fc680
+README.zh.md: f7f1881b870a8e5b117382cb96d1f1653548fbfc

+ 2 - 2
packages/core/agent-loop/README.md

@@ -64,7 +64,7 @@ Plugins and hosts create agents through `ctx.agents.create()` and resume persist
 const handle = await ctx.agents.create({
   sessionId,
   agentOptions: { provider: 'deepseek', model: 'deepseek-chat' },
-  setup: (agentCtx) => { /* scoped tools, prompt sections, listeners */ },
+  setup: (agentCtx, agent) => { /* scoped registrations plus explicit unpublished Agent */ },
 })
 ```
 
@@ -108,7 +108,7 @@ The loop deep-freezes each derived message identity on its first request and reu
 
 ### Creation and teardown
 
-Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, closes the session's write path, unwinds the scope, detaches the agent, then detaches the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement.
+Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup with the context and Agent passed separately; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, closes the session's write path, unwinds the scope, detaches the agent, then detaches the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement.
 
 ### Persistence integration
 

+ 2 - 2
packages/core/agent-loop/README.zh.md

@@ -64,7 +64,7 @@ kind: "package-reference"
 const handle = await ctx.agents.create({
   sessionId,
   agentOptions: { provider: 'deepseek', model: 'deepseek-chat' },
-  setup: (agentCtx) => { /* scoped tools, prompt sections, listeners */ },
+  setup: (agentCtx, agent) => { /* scoped registrations plus explicit unpublished Agent */ },
 })
 ```
 
@@ -108,7 +108,7 @@ const handle = await ctx.agents.create({
 
 ### 创建与拆除
 
-创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、关闭会话的写路径、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。
+创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待分别传入上下文与 Agent 的可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活 handle。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、关闭会话的写路径、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。
 
 ### 持久化集成
 

+ 1 - 1
packages/core/agent-loop/src/agent.ts

@@ -99,7 +99,7 @@ export class ReactLoopAgent implements Agent {
   ) {
     this.dispatch = agentEvents(loopCtx, this)
     this.scope = createScope(loopCtx, this)
-    this.ctx = this.scope.ctx.extend({ agent: this })
+    this.ctx = this.scope.ctx
     this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch)
     /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */
     const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0

+ 9 - 5
packages/core/agent-loop/src/index.ts

@@ -534,6 +534,7 @@ export class AgentLoop extends Service implements AgentFactory {
     session: Session,
     callerSignal?: AbortSignal,
     handle?: SessionHandle,
+    parentAgent?: Agent,
   ): PreparedAgent {
     assertAgentOptions(options)
     ownerCtx.fiber.assertActive()
@@ -663,7 +664,7 @@ export class AgentLoop extends Service implements AgentFactory {
           detachSession = agent.ctx.sessions.enter(session)
           // The mounted backend routes announced live events into the active
           // write handle by session id; the loop only owns the handle itself.
-          detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent)
+          detachAgent = loopCtx.agents.enter(agent, parentAgent)
           agent.ctx.sessions.announce(session)
           assertLive()
           loopCtx.agents.announce(agent)
@@ -757,7 +758,7 @@ export class AgentLoop extends Service implements AgentFactory {
   /**
    * Create an owned agent on a caller-supplied session id.
    * @param ownerCtx - caller context that structurally owns the lifecycle.
-   * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
+   * @param options - identities, optional live parent, session seed/metadata, loop options, setup, and cancellation.
    * @returns the published handle.
    */
   async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
@@ -792,6 +793,7 @@ export class AgentLoop extends Service implements AgentFactory {
         options.signal,
         'startup',
         stored,
+        options.parentAgent,
       )
     })()
     this.ownership.trackWrapper(published)
@@ -808,18 +810,19 @@ export class AgentLoop extends Service implements AgentFactory {
     signal: AbortSignal | undefined,
     source: SessionStartSource,
     stored?: StoredSession,
+    parentAgent?: Agent,
   ): Promise<AgentHandle> {
     using ownedPreparation = preparation
     const session = ownedPreparation.session
     let prepared: PreparedAgent
     try {
-      prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle)
+      prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle, parentAgent)
     } catch (error: unknown) {
       await stored?.handle.close().catch(() => {})
       throw error
     }
     try {
-      const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
+      const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id)
       setupCommit?.commit()
       await this.appendUnstoredSuffix(stored, session)
       return prepared.publish(source)
@@ -834,7 +837,7 @@ export class AgentLoop extends Service implements AgentFactory {
   /**
    * Resume an owned agent from the configured persistence service.
    * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
-   * @param options - persisted identity, loop options, setup, and cancellation.
+   * @param options - persisted identity, optional live parent, loop options, setup, and cancellation.
    * @returns the published handle.
    */
   async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
@@ -911,6 +914,7 @@ export class AgentLoop extends Service implements AgentFactory {
           options.signal,
           'resume',
           owned,
+          options.parentAgent,
         )
       } finally {
         preparation?.[Symbol.dispose]()

+ 3 - 2
packages/core/agent-loop/tests/agent-initiator.spec.ts

@@ -238,9 +238,10 @@ describe('AgentLoop initiator scope', () => {
         const handle = await exec.agent.ctx.agents.create({
           sessionId: SessionId('child-session'),
           agentOptions: { provider: 'mock', model: 'mock' },
-          setup: (agentCtx) => {
+          parentAgent: exec.agent,
+          setup: (agentCtx, childAgent) => {
             parentDuringSetup = ctx.agents.requireInitiator()
-            explicitChild = agentCtx.agent
+            explicitChild = childAgent
             agentCtx.tools.register(defineContentToolFixture({
               name: 'observe-child',
               description: 'observe child execution identity',

+ 3 - 3
packages/core/agent-loop/tests/resume.spec.ts

@@ -636,10 +636,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const resuming = ctx.agents.resume({
       resumeSessionId: sessionId,
       agentOptions: { provider: 'mock', model: 'mock' },
-      setup: async (agentCtx) => {
-        expect(agentCtx.agent?.id).toBe(sessionId)
+      setup: async (agentCtx, agent) => {
+        expect(agent.id).toBe(sessionId)
         // The two persisted events plus the end-seed marker.
-        expect(agentCtx.agent?.session.snapshotEvents()).toHaveLength(3)
+        expect(agent.session.snapshotEvents()).toHaveLength(3)
         agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
         agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
         order.push('setup:start')

+ 5 - 7
packages/core/agent-loop/tests/scope-lifecycle.spec.ts

@@ -142,17 +142,14 @@ describe('agent scope lifecycle', () => {
     await ctx.fiber.dispose()
   })
 
-  it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
+  it('tags agent.ctx with the Agent scope key', async () => {
     const ctx = await harness()
     const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
     expect(scopeOf(agent.ctx)).toBe(agent)
-    expect(agent.ctx.agent).toBe(agent)
-    // The root accessor default: a plain context answers undefined, not a throw.
-    expect(ctx.agent).toBeUndefined()
     await agent.whenIdle()
   })
 
-  it('records agents created through an agent context as non-root runtime children', async () => {
+  it('records an explicitly owned Agent as a non-root runtime child', async () => {
     const ctx = await harness()
     const root = await ctx.agents.create({
       sessionId: SessionId('runtime-root'),
@@ -161,6 +158,7 @@ describe('agent scope lifecycle', () => {
     const child = await root.agent.ctx.agents.create({
       sessionId: SessionId('runtime-child'),
       agentOptions: { model: 'mock' },
+      parentAgent: root.agent,
     })
 
     expect(ctx.agents.list()).toEqual([root.agent, child.agent])
@@ -285,8 +283,8 @@ describe('agent scope lifecycle', () => {
     const creating = ctx.agents.create({
       sessionId: SessionId('atomic'),
       agentOptions: acceptedOptions,
-      setup: async (agentCtx) => {
-        expect(agentCtx.agent?.id).toBe(SessionId('atomic'))
+      setup: async (agentCtx, agent) => {
+        expect(agent.id).toBe(SessionId('atomic'))
         agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
         agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
         order.push('setup:start')

+ 2 - 2
packages/core/agent/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/core/agent/README.md
-README.md: fbb65ee08e5f77004613c35efd580a343efa8879
-README.zh.md: 7dcde2dd57f20d1a87bcdd965bcafbbe8cad6314
+README.md: ab80744b7cd5af06ef00d4c246c7ad5aa03e1c05
+README.zh.md: 5e7c4c336df300e282b5c4aba1ec5ea2785a94fb

+ 3 - 3
packages/core/agent/README.md

@@ -29,7 +29,7 @@ Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `
 
 ### Create or resume an agent
 
-`ctx.agents.create()` builds a fresh agent and session under one identity; `ctx.agents.resume()` loads a persisted session and rebuilds the agent on it. Both delegate to the registered factory and return an `AgentHandle` — the only object that can tear that agent down. `get(id)`, `list()`, and `roots()` find live agents, and `isOwnedBy(id, owner)` tells whether one agent was created through another's scoped context.
+`ctx.agents.create()` builds a fresh agent and session under one identity; `ctx.agents.resume()` loads a persisted session and rebuilds the agent on it. Both delegate to the registered factory and return an `AgentHandle` — the only object that can tear that agent down. Set `parentAgent` in either operation's options to make the result a runtime child; omit it for a runtime root. `get(id)`, `list()`, and `roots()` find live agents, and `isOwnedBy(id, parent)` tests that exact live relation.
 
 ```text
 const handle = await ctx.agents.create({
@@ -40,7 +40,7 @@ const handle = await ctx.agents.create({
 await handle.dispose()   // stops the loop, unregisters, removes the session, unwinds the scope
 ```
 
-`AgentOptions` supplies the initial provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation request. An optional `setup(agentCtx)` callback composes the agent's scoped world before it is published — scoped tools, prompt sections, and listeners exist before any creation announcement. Setup is composition-only: drive the agent only after creation resolves.
+`AgentOptions` supplies the initial provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation request. An optional `setup(agentCtx, agent)` callback composes the agent's scoped world before it is published: `agentCtx` owns registrations, while the explicit unpublished Agent provides its Session; the Context has no reverse Agent property. Scoped tools, prompt sections, and listeners exist before any creation announcement. Setup is composition-only: drive the agent only after creation resolves.
 
 ### Drive an agent's conversation
 
@@ -171,7 +171,7 @@ These limits define when this package needs special care. They are current packa
 
 - **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts must materialize any required identity explicitly.
 - **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
-- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
+- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx, agent)` transaction instead.
 - **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items, and there is no step-only abort that keeps the turn running.
 - **Each additional `UserMessage` carries exactly one `MessageSource`** — contributions from several plugins merged onto one message collapse under one source, so the message cannot name several producers.
 

+ 3 - 3
packages/core/agent/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 创建或恢复 agent
 
-`ctx.agents.create()` 在一个身份下构建全新 agent 与会话;`ctx.agents.resume()` 加载持久化会话并在此基础上重建 agent。两者都委托给已注册工厂,并返回 `AgentHandle`——唯一能拆除该 agent 的对象。`get(id)`、`list()` 与 `roots()` 用于查找实时 agent;`isOwnedBy(id, owner)` 用于判断一个 agent 是否通过另一个 agent 的作用域上下文创建。
+`ctx.agents.create()` 在一个身份下构建全新 agent 与会话;`ctx.agents.resume()` 加载持久化会话并在此基础上重建 agent。两者都委托给已注册工厂,并返回 `AgentHandle`——唯一能拆除该 agent 的对象。在任一操作的 options 中设置 `parentAgent`,可使结果成为运行时子级;省略它则得到运行时根级。`get(id)`、`list()` 与 `roots()` 用于查找实时 agent;`isOwnedBy(id, parent)` 用于检验这项确切的存活关系。
 
 ```text
 const handle = await ctx.agents.create({
@@ -40,7 +40,7 @@ const handle = await ctx.agents.create({
 await handle.dispose()   // stops the loop, unregisters, removes the session, unwinds the scope
 ```
 
-`AgentOptions` 提供初始 provider/model 路由、可选的适配器所有 `reasoningEffort`,以及可选的正数 `maxTokens` 输出上限。循环会校验确切模型的推理支持、解析适配器默认值、把有效值记录在请求头中,并将它们应用到每个对话请求。可选的 `setup(agentCtx)` 回调会在 agent 发布之前组合其作用域世界——作用域工具、提示词段与监听器在任何创建公告之前就已存在。Setup 只做组合:创建完成后才能驱动 agent。
+`AgentOptions` 提供初始 provider/model 路由、可选的适配器所有 `reasoningEffort`,以及可选的正数 `maxTokens` 输出上限。循环会校验确切模型的推理支持、解析适配器默认值、把有效值记录在请求头中,并将它们应用到每个对话请求。可选的 `setup(agentCtx, agent)` 回调会在 agent 发布之前组合其作用域世界:`agentCtx` 拥有注册,显式的未发布 Agent 则提供其 Session;Context 不含反向 Agent 属性。作用域工具、提示词段与监听器在任何创建公告之前就已存在。Setup 只做组合:创建完成后才能驱动 agent。
 
 ### 驱动 agent 的对话
 
@@ -171,7 +171,7 @@ await handle.agent.whenIdle()
 
 - **发起方作用域只存在于进程内**:worker、子进程、HTTP、持久队列和重启必须显式传递所需身份。
 - **环境身份可能比存活状态更久**:消费方在生命周期敏感工作前,仍要检查 `agent.status`、取消状态和所属能力约定。
-- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx)` 事务。
+- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx, agent)` 事务。
 - **`cancel()` 默认清空收件箱**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项,且不存在让轮次继续运行、只中止步骤的操作。
 - **每条附加 `UserMessage` 恰好携带一个 `MessageSource`**:多个插件合并到一条消息上的贡献会归入同一来源,因此该消息无法列出多个生产者。
 

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä