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

Merge branch 'master' into feat/turn-running-time

imccyu 1 месяц назад
Родитель
Сommit
ad03ccbd7a
55 измененных файлов с 1523 добавлено и 603 удалено
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml
  2. 3 3
      .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md
  3. 3 3
      .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md
  4. 0 77
      .agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md
  5. 0 77
      .agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md
  6. 6 0
      .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml
  7. 82 0
      .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md
  8. 82 0
      .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md
  9. 3 3
      .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml
  10. 52 0
      .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md
  11. 52 0
      .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md
  12. 2 2
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
  13. 2 2
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
  14. 2 2
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
  15. 2 2
      .agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml
  16. 3 3
      .agents/notes/implemented/feature/2026-07-30-search-render-card.md
  17. 3 3
      .agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md
  18. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
  19. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-read-card.md
  20. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
  21. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml
  22. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-result-card.md
  23. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md
  24. 2 2
      .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml
  25. 4 4
      .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
  26. 4 4
      .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
  27. 2 2
      apps/cli/README.i18n.yaml
  28. 2 0
      apps/cli/README.md
  29. 2 0
      apps/cli/README.zh.md
  30. 10 8
      apps/cli/config/base.cordis.yml
  31. 12 19
      apps/cli/src/headless.ts
  32. 58 0
      apps/cli/src/process-shutdown.ts
  33. 4 8
      apps/cli/src/web.ts
  34. 18 0
      apps/cli/tests/fixtures/never-dispose.mjs
  35. 121 0
      apps/cli/tests/headless-shutdown.e2e.ts
  36. 131 0
      apps/cli/tests/process-shutdown.spec.ts
  37. 7 5
      apps/web/tests/approval-composer.e2e.ts
  38. 215 136
      apps/web/tests/composer-draft-scroll.e2e.ts
  39. 54 4
      apps/web/tests/sidebar-scrollbar.e2e.ts
  40. 15 9
      apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md
  41. 4 0
      apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md
  42. 5 4
      docs/config-catalog.md
  43. 39 24
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  44. 132 74
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  45. 162 33
      packages/client/ui-conversation/tests/input-bar.spec.tsx
  46. 10 3
      packages/client/ui-sidebar/src/client/SidebarRoot.module.css
  47. 38 0
      packages/client/ui-sidebar/tests/sidebar-styles.spec.ts
  48. 30 28
      packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css
  49. 1 1
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  50. 45 22
      packages/client/ui-workspace/tests/browser-styles.spec.ts
  51. 2 2
      packages/telemetry/session-telemetry-otel/README.i18n.yaml
  52. 2 1
      packages/telemetry/session-telemetry-otel/README.md
  53. 2 1
      packages/telemetry/session-telemetry-otel/README.zh.md
  54. 45 18
      packages/telemetry/session-telemetry-otel/src/index.ts
  55. 34 0
      packages/telemetry/session-telemetry-otel/tests/otel.spec.ts

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.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/bug-fix/2026-07-30-approval-panel-command-cap.md
-2026-07-30-approval-panel-command-cap.md: 941f7eda187f263f2d8af6aa643d493c92a3669b
-2026-07-30-approval-panel-command-cap.zh.md: 939a700934f6467947028d988da9a694169e203e
+2026-07-30-approval-panel-command-cap.md: 1e4b75c106b71605a004ef35301445da76ec8cf0
+2026-07-30-approval-panel-command-cap.zh.md: aa9f708b78c8ace735e1c96c0ff9ab0a7a4d526a

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md

@@ -14,7 +14,7 @@ The InputBar the panel replaces has always been capped (14 lines, then the texta
 
 The panel's justification and command move into one scroll region (`data-approval-scroll`) capped at the same height as the composer's draft area; the amber strip and the action row sit outside it, so both buttons are in the card at every content length.
 
-The cap is one value with two consumers, declared as `--dsh-composer-text-max-height: 336px` on `ConversationRoot`'s `.composerSeat` — the composer chain's only shared ancestor, since the fallback InputBar and an elected takeover render as siblings. `InputBar`'s mirror and the panel's scroll region both read it, so the seat cannot cap its two states differently: what the designer asked for ("unify it with the input box's max height") is now a fact of the stylesheet rather than a number repeated in two files. The region is `box-sizing: border-box` so the cap is its outer height, the same box the composer's draft area occupies.
+The cap is one value with two consumers, declared as `--dsh-composer-text-max-height: 336px` on `ConversationRoot`'s `.composerSeat` — the composer chain's only shared ancestor, since the fallback InputBar and an elected takeover render as siblings. `InputBar`'s draft scrollport and the panel's scroll region both read it, so the seat cannot cap its two states differently: what the designer asked for ("unify it with the input box's max height") is now a fact of the stylesheet rather than a number repeated in two files. The region is `box-sizing: border-box` so the cap is its outer height, the same box the composer's draft area occupies.
 
 The region is a tab stop (`tabIndex={0}`, named `role="group"`). Unlike the question composer's scroll body, whose option rows are focusable and pull the container along, this one holds nothing but text: without its own tab stop a keyboard-only user could reach the buttons and never the command's tail, and approve what they could not finish reading.
 
@@ -34,12 +34,12 @@ The panel's card rebinds `--dsh-scrollbar-thumb{,-hover}` to the l2 pair, as eve
 
 - A long command scrolls inside the card and the refuse/allow buttons stay on screen. Measured on the built client at 900x1000 and 900x700: the region reports `scrollHeight` past `clientHeight`, and both buttons stay inside the card and inside the viewport.
 - Electing the takeover no longer changes how tall the composer seat can get, so the transcript above it does not reflow by hundreds of pixels when an approval arrives or resolves.
-- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`. Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root.
+- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`, on the box that scrolls its draft ([one scrollport for both text layers](2026-07-31-composer-text-layers-share-one-scrollport.md) moved the declaration off the auto-grow mirror). Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root.
 - The scenario's recorded command is a 200-token blob, far longer than a round trip needs. That cost is deliberate: the cap is unfalsifiable without content that passes it, and the model compresses any regular payload (the first recording turned "alpha 400 times" into `printf 'alpha %.0s' {1..400}`, a one-line command that proves nothing).
 
 ## Verification
 
-`apps/web/tests/approval-composer.e2e.ts` drives the real composition: a read-only session, a denied write, the model's escalation retry, and the answer clicked through the panel. The geometry assertion runs on the live panel at two viewport heights and is guarded against holding vacuously — the region must actually be scrolling, and the measured cap must equal the composer's own, which the test reads off the live textarea before sending rather than hardcoding the px value.
+`apps/web/tests/approval-composer.e2e.ts` drives the real composition: a read-only session, a denied write, the model's escalation retry, and the answer clicked through the panel. The geometry assertion runs on the live panel at two viewport heights and is guarded against holding vacuously — the region must actually be scrolling, and the measured cap must equal the composer's own, which the test reads off the live draft scrollport before sending rather than hardcoding the px value.
 
 Confirmed both directions against the built client. With the cap reverted, the region reports `scrolls: false` and grows to the command's full height (1798px for the recorded blob at 900x1000, against 336px capped); at 900x700 the card is 680px tall against a 700px viewport and the action row's bottom lands at y=749 — below the fold, the designer's report exactly. With the cap restored the scenario passes in replay.
 

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 面板的理由与命令移入同一个滚动区域(`data-approval-scroll`),其高度上限与 composer 的草稿区完全相同;琥珀色状态条与操作按钮行位于该区域之外,因此无论内容多长,两个按钮都留在卡片内。
 
-这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的 mirror 与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的"可以跟输入框最大高度统一",如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。
+这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的草稿滚动容器与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的"可以跟输入框最大高度统一",如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。
 
 该区域自身是一个 Tab 停靠点(`tabIndex={0}`,带名称的 `role="group"`)。提问 composer 的滚动体不需要这样做——它的选项行本身可聚焦,会把容器一起带过去;而这里除文本之外别无内容:没有自己的停靠点,仅用键盘的用户能走到按钮却走不到命令尾部,于是可能批准了自己没读完的东西。
 
@@ -34,12 +34,12 @@ Status: implemented
 
 - 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 `scrollHeight` 超过 `clientHeight`,两个按钮都留在卡片内、也都留在视口内。
 - 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的会话流不会有数百像素的重排。
-- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。
+- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析,且落在真正滚动草稿的那个盒子上([两层文本共用同一个滚动容器](2026-07-31-composer-text-layers-share-one-scrollport.md)把该声明从自增高镜像层移了出去)。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。
 - 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把"alpha 重复 400 次"写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。
 
 ## 验证
 
-`apps/web/tests/approval-composer.e2e.ts` 驱动的是真实组合:一个只读会话、一次被拒绝的写入、模型的越权重试,以及在面板上点击完成的回应。几何断言在两个视口高度上针对活动面板执行,并有守卫防止它空洞地成立——该区域必须确实处在滚动状态,且实测上限必须等于 composer 自身的上限,后者由测试在发送之前从活动 textarea 上读出,而不是把该像素值写死。
+`apps/web/tests/approval-composer.e2e.ts` 驱动的是真实组合:一个只读会话、一次被拒绝的写入、模型的越权重试,以及在面板上点击完成的回应。几何断言在两个视口高度上针对活动面板执行,并有守卫防止它空洞地成立——该区域必须确实处在滚动状态,且实测上限必须等于 composer 自身的上限,后者由测试在发送之前从活动的草稿滚动容器上读出,而不是把该像素值写死。
 
 在构建产物客户端上双向确认过。撤销上限后,该区域报告 `scrolls: false`,并长到命令的完整高度(900x1000 下,录制的字符块为 1798px,而设上限后为 336px);在 900x700 下卡片高 680px、视口高 700px,操作按钮行底边落在 y=749——正在折叠之下,与设计同学的反馈完全一致。恢复上限后,该场景在回放模式下通过。
 

+ 0 - 77
.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md

@@ -1,77 +0,0 @@
-# Agent Note: The composer's glyph layer tracks the textarea's scroll offset
-
-Status: implemented
-
-English | [中文](2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md)
-
-## Problem
-
-A composer draft longer than the 14-line cap could not be scrolled. The caret moved and the selection moved, but the words stayed frozen at line 1 — no wheel gesture, drag, or arrow key brought the end of a long draft on screen, so the bottom of anything past ~14 lines was unreachable and unreadable while writing it.
-
-The cap itself was working. The composer paints its text in two stacked layers ([InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)): the `<textarea>` owns the value, the selection, and the caret but renders its own glyphs `color: transparent`, and every visible character is painted by the `[data-input-backdrop]` div beneath it, which also carries the claim-token highlight, the chips, and the ghost hint. That split is what makes chips and highlights possible at all — a textarea cannot style a range of its own text.
-
-The two layers were coupled in geometry but not in scroll. The backdrop is `position: absolute; inset: 0; overflow: hidden`: it is clipped, not scrolled, and nothing in the browser links its offset to the textarea's. Below the cap that is invisible, because both layers rest at offset 0 and the mirror div sizes the box to the draft. At the cap the textarea starts scrolling and the backdrop does not follow, so the layer the user actually reads never moves.
-
-The defect is therefore exactly as old as the cap, and it hid behind the resting state: a short draft, the state every screenshot and every existing fixture captured, renders identically with and without the coupling.
-
-## Decision
-
-`InputBar` mirrors the textarea's `scrollTop` onto the backdrop from one `scroll` listener, registered beside the existing wheel-chaining listener in the same effect (the textarea is never unmounted — the inert state renders the same element disabled).
-
-One listener is the whole coupling, because every way the box moves ends in a `scroll` event on the textarea. A gesture scrolls it; an edit scrolls the caret into view; a draft that shrinks past the current offset clamps it. The clamp case is the one that looks like it needs separate handling and does not: the two layers share an extent, so they clamp to the same maximum, and the textarea's clamp fires the `scroll` that mirrors it.
-
-That shared extent is not free, and mirroring an offset is only correct while it holds. Two things break it, both discovered in review, both failing in the same direction — a backdrop shorter than the textarea, so the assignment clamps and the glyphs sit below the caret. A textarea reserves a line box for the caret after a final newline; `white-space: pre-wrap` collapses a text node's trailing newline and generates none. A draft ending in a newline therefore made the backdrop exactly one line shorter than the textarea — measured 628 against 652 — so the assignment clamped and the glyphs sat a line behind the caret at the very bottom. The backdrop now carries the same trailing-line sentinel the mirror div already did: its content is the decoration walk plus one `'\n'`, which the same collapse absorbs when the draft does not end in a newline and which supplies the missing line box when it does. Measured across plain, trailing-newline, soft-wrapping, unbreakable-run, and interior-blank-line drafts, the two extents now agree in every case.
-
-The second premise is wrap width, and it is asserted rather than fixed. Only `.input` scrolls, so only `.input` can lose content width to a scrollbar that consumes layout space, and a narrower `.input` wraps a long draft onto more lines — worth 2 to 5 lines for an 8px difference, measured on a standalone harness, while at equal widths a textarea and a div agree exactly. Measured on the running app across the three engines Playwright ships, the widths agree on two and not on the third:
-
-| engine | `.input` / `.backdrop` / `.mirror` wrap width | extents |
-|---|---|---|
-| chromium | 776 / 776 / 776 | equal |
-| firefox | 776 / 776 / 776 | equal |
-| WebKit | **768** / 776 / 776 | equal for the drafts measured |
-
-WebKit's textarea loses 8px to its scrollbar while the clipped layers keep theirs. That gap predates this change and is not closed here; the mirror is unaffected on the drafts measured because the extents still agree, but a draft whose wrapping is sensitive at exactly that width would make `.input` taller and clamp the mirrored offset. The scenario asserts the equality on the lane's engine, so a regression into that state fails loudly rather than silently.
-
-`scrollbar-gutter: stable` on the shared metrics block was tried and removed. WebKit applies it to `overflow-y: auto` but not to `overflow: hidden`, so it left `.input` at 768 against 776 — exactly the gap it was meant to close — while costing chromium 8px of text width unconditionally. Closing this needs one geometry every engine agrees on, not that property.
-
-The mirror is one-directional: the textarea is the authority because it owns the caret, and the caret is what the browser scrolls to.
-
-## Alternatives considered
-
-**Give the backdrop `overflow: auto` and let it scroll itself.** It would then have a scroll offset of its own to keep in step, which is the same problem plus a second scrollbar painted over the input. The backdrop is a projection of the textarea, not an independently navigable surface.
-
-**Drop the backdrop and style the textarea's own text.** This removes the layer split and the whole class of desync with it. Rejected because it is not implementable: a textarea renders one uniform text run, so the claim-token highlight, the chips, and the ghost hint — the reasons the backdrop exists — have no way to be expressed. Losing them to fix scrolling trades a bounded defect for a feature deletion.
-
-**Render the draft in a `contenteditable` div instead of a textarea.** One element, one scroll offset, styleable ranges. Rejected as far out of proportion to the defect: `contenteditable` would put IME composition, undo/redo, selection semantics, and paste normalization back on us, all of which the textarea plus the input machine currently handle, and the machine already owns an undo log that assumes a textarea's value semantics.
-
-**Scroll the backdrop from the existing wheel handler instead of a `scroll` listener.** The handler already runs on every wheel over the textarea, so it looks like the natural place. Rejected because it covers only one of the ways the box scrolls: typing at the end, `End`, arrow keys, drag-selection past the edge, and scrollbar drags all move the textarea without a wheel event. Listening to `scroll` is listening to the thing itself rather than to one of its causes.
-
-**Reserve the scrollbar gutter on all three layers with `scrollbar-gutter: stable`.** Adopted, then reverted on measurement. The reasoning was that whatever a platform's scrollbar costs, three layers reserving it stay equal — and `overflow: hidden` is a scroll container, so the spec says the clipped layers honour it. Chromium agrees (8px reserved on each, widths 768/768/768). WebKit does not: it reserves for `overflow-y: auto` and not for `overflow: hidden`, leaving 768 against 776 — the same gap, unclosed — so the property bought nothing on the one engine where the divergence is observable while costing every chromium user 8px of text column. Reverted in favour of asserting the premise and recording the WebKit gap.
-
-**Suppress the textarea's scrollbar instead of reserving a gutter on the other layers.** `scrollbar-width: none` on `.input` would equalize the widths without narrowing the text column. Rejected because the composer deliberately shows a thumb once the draft passes the cap — `.card` binds the l2 scrollbar tokens for exactly that — and removing it takes away the only affordance that says a long draft continues below.
-
-**Translate the backdrop with `transform: translateY(-scrollTop)` instead of scrolling it.** A transform is not clamped by content height, so it would paper over any extent divergence — including the trailing-newline one — without matching the layers. Rejected because the divergence is the actual defect: unequal extents also mean the two layers disagree about where the last line sits, and hiding that behind an unclamped transform would leave a mismatch that resurfaces the moment anything measures the backdrop. Fixing the extent keeps one truth about the draft's height.
-
-**Add a second mirror in a layout effect keyed on the committed draft.** This shipped in the first version of the change, on the theory that an edit reflows both layers without necessarily moving the textarea, and that a shrinking draft clamps each layer independently. Both premises are false, and it was removed after mutation-testing each hook alone against the built client: with only the layout effect disabled the browser scenario stays green, while disabling only the `scroll` listener fails it. Typing scrolls the caret into view, which is an ordinary `scroll`; a shrinking draft clamps both layers to the same maximum because their extents are equal, and the textarea's clamp fires `scroll` too. The specific hazard the effect was imagined to cover — React replacing the backdrop's children when the decoration set changes shape, resetting its offset — does not occur: measured in chromium, replacing every child of an `overflow: hidden` box preserves `scrollTop` (300 stays 300), and the only replacement that zeroes it is one that shrinks the content below the offset, which is the clamp case already covered.
-
-**Sync in the `onChange` handler.** Rejected for the same reason plus one of its own: it fires before React commits the new draft to the backdrop, so it would mirror against the previous layout.
-
-## Consequences
-
-- A draft past the cap scrolls its glyphs. Measured in the browser scenario: after a wheel gesture over a 40-line draft the last line sits inside the visible box and the first has scrolled out above it; before, the last line stayed a full draft-height below the box while the textarea's own offset had moved.
-- The coupling is one-directional and cheap — one assignment of one number, no measurement, no layout read beyond `scrollTop` — so it adds nothing to the typing path's cost.
-- Chips, claim-token highlights, and text-ref marks stay aligned with their glyphs while scrolled, because they are positioned inside the backdrop and move with it. Nothing about the decoration walk changes.
-- The composer's two-layer design keeps this hazard: any future layer added beside the backdrop needs the same mirroring, and any change to how a layer reserves its last line box breaks the extent equality the mirror depends on. The e2e scenario asserts both — the relation the user cares about (which line is on screen) and the extent equality underneath it — so a future divergence fails on the invariant rather than on a screenshot.
-- Extent equality is asserted, not assumed. It is the premise that turns "mirror the offset" from correct into subtly wrong, and it failed for the trailing-newline shape before the sentinel.
-- Wrap-width equality is the other premise, and it does NOT hold universally: WebKit lays `.input` out 8px narrower than the glyph layers. That predates this change and is left open, with the measurement recorded above and an assertion on the lane's engine. A draft whose wrapping turns on those 8px would clamp the mirror on WebKit.
-- The composer's layout is unchanged. An earlier revision narrowed the text column by 8px on every platform to chase the wrap-width premise; measurement showed it did not buy the guarantee, so the metrics are the same as before this change.
-
-## Testing
-
-The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) proves the mirroring path runs: it stubs both offsets, because jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, and asserts the backdrop follows the textarea to a new offset and back to the top. Reverting the `ref` makes it fail.
-
-The user-visible fact needs a real engine, so [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures it in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls, with a DOM Range over the backdrop's own text reporting where the first and last lines sit relative to the visible box. A vacuity guard asserts the draft actually overflows the capped box first. A separate case drives the trailing-newline shape and asserts the two extents are equal before asserting the glyphs reach the end; each layer's maximum is observed by asking for an impossible offset and reading back the clamp, not computed from `scrollHeight`. A third asserts the gutter premise: equal wrap widths, and a reserved band greater than zero on each layer. The band is what keeps that assertion from being vacuous — the widths would also match with no reservation at all on this engine's overlay scrollbar, and it is the reservation, not the match, that carries the guarantee to a platform whose scrollbar takes real width.
-
-Confirmed both directions against the built client. With the mirroring reverted and the packages rebuilt, the wheel case fails on the layer offsets, the typing case fails with it, and the golden diff reads `last draft line is on screen: false` while `textarea moved: true` — the reported symptom stated as a fixture. The resting-state case passes in both builds, which is the point: it is the state that hid the defect.
-
-Note that the composer ships inside a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `InputBar.tsx` — the package build must run for the browser lane to see it, and a scenario run against a stale `lib/` asserts against an older client than the tree.

+ 0 - 77
.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md

@@ -1,77 +0,0 @@
-# Agent Note: composer 的字形层跟随 textarea 的滚动偏移
-
-Status: implemented
-
-[English](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) | 中文
-
-## 问题
-
-草稿一旦超过 14 行的高度上限,就无法再滚动。光标会动,选区会动,但文字始终冻结在第 1 行——无论滚轮、拖拽还是方向键,都无法把长草稿的末尾带到可见范围内,因此约 14 行之后的内容在书写过程中既够不着也读不到。
-
-高度上限本身是正常工作的。composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)):`<textarea>` 持有取值、选区与光标,但它自己的字形以 `color: transparent` 渲染;用户看到的每一个字符都由其下的 `[data-input-backdrop]` 层绘制,该层同时承载 claim token 高亮、chip 与提示影子文本。这一拆分正是 chip 与高亮得以存在的前提——textarea 无法为自身文本的某个区间单独设置样式。
-
-两层在几何上是耦合的,在滚动上却不是。backdrop 为 `position: absolute; inset: 0; overflow: hidden`:它只做裁剪,不做滚动,浏览器也不会把它的偏移与 textarea 关联起来。未达上限时这一点不可见,因为两层都停在偏移 0,且镜像层会把盒子撑到草稿的高度。一旦触及上限,textarea 开始滚动而 backdrop 不跟随,于是用户真正在读的那一层从不移动。
-
-因此该缺陷与高度上限同龄,并且藏在静止状态背后:短草稿——也就是所有截图与既有 fixture(测试前置数据)所捕获的那个状态——在有无该耦合时渲染完全一致。
-
-## 决策
-
-`InputBar` 通过一个 `scroll` 监听把 textarea 的 `scrollTop` 镜像到 backdrop 上,该监听与既有的滚轮接力监听注册在同一个 effect 中(textarea 从不卸载——失效状态渲染的是同一个元素的 disabled 形态)。
-
-一个监听即构成完整耦合,因为这个盒子移动的每一种方式最终都会在 textarea 上产生 `scroll` 事件:手势使它滚动;编辑会把光标滚入可见范围;草稿缩短到当前偏移之下时它会被钳位。看似需要单独处理、实则不需要的正是钳位这一种:两层共享同一滚动范围,因此它们会钳位到同一个最大值,而 textarea 的钳位本身就会触发那次完成镜像的 `scroll`。
-
-这个「共享的滚动范围」并非白得,而镜像偏移只有在它成立时才是正确的。有两件事会破坏它,都是在审查中被发现的,且失效方向相同——backdrop 比 textarea 矮,于是赋值被钳制、字形落到光标之下。textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行、不生成任何行盒。因此以换行结尾的草稿会让 backdrop 恰好比 textarea 少一行——实测为 628 对 652——于是该赋值被钳制,滚到最底部时字形比光标落后一行。现在 backdrop 也带上了镜像层早已具备的同一枚尾行哨兵:其内容为装饰扫描的结果再加一个 `'\n'`;草稿不以换行结尾时它被同一次折叠吸收,以换行结尾时它补上缺失的那个行盒。对纯文本、尾随换行、软折行、不可断长串以及中间空行五类草稿实测,两侧范围在每种情形下均相等。
-
-第二个前提是折行宽度,它是被断言的,而不是被修复的。只有 `.input` 会滚动,因此也只有 `.input` 会把内容宽度让给一条占布局宽度的滚动条;`.input` 一旦更窄,长草稿就会折出更多行——在独立环境实测,8px 的宽度差值 2 到 5 行,而宽度相等时 textarea 与 div 完全一致。在运行中的应用上、对 Playwright 自带的三个引擎实测,两个相等、一个不等:
-
-| 引擎 | `.input` / `.backdrop` / `.mirror` 折行宽度 | 滚动范围 |
-|---|---|---|
-| chromium | 776 / 776 / 776 | 相等 |
-| firefox | 776 / 776 / 776 | 相等 |
-| WebKit | **768** / 776 / 776 | 所测草稿下相等 |
-
-WebKit 的 textarea 把 8px 让给了自己的滚动条,而两个被裁剪的图层没有。该差距先于本次改动存在,本 PR 未予关闭;在所测草稿下滚动范围仍然相等,因此镜像不受影响,但一份恰好在该宽度上折行敏感的草稿会让 `.input` 更高、从而钳制镜像偏移。场景在测试通道所用引擎上断言了这项相等性,因此一旦回退到那种状态会显式失败,而不是悄然发生。
-
-共享度量块上的 `scrollbar-gutter: stable` 曾被采用又被移除:WebKit 对 `overflow-y: auto` 应用它、对 `overflow: hidden` 不应用,于是 `.input` 仍是 768 对 776——正是它本想关闭的那个差距——同时又让 chromium 无条件损失 8px 文本宽度。要关闭它,需要一套所有引擎都认同的几何,而不是这个属性。
-
-该镜像是单向的:textarea 是权威方,因为它持有光标,而浏览器滚动的目标正是光标。
-
-## 曾考虑的替代方案
-
-**给 backdrop 加 `overflow: auto`,让它自行滚动。** 那样它就有了一个属于自己的滚动偏移需要同步,问题原样保留,还额外多出一条画在输入框上的滚动条。backdrop 是 textarea 的投影,而不是一个可独立导航的界面。
-
-**去掉 backdrop,直接为 textarea 自身文本设置样式。** 这会消除分层,连同整类失步问题一并消除。之所以否决,是因为它根本无法实现:textarea 只渲染一段统一的文本流,因此 claim token 高亮、chip 与提示影子文本——backdrop 存在的全部理由——都无从表达。为修滚动而放弃它们,是拿一个有界的缺陷去换一次功能删除。
-
-**改用 `contenteditable` div 承载草稿,不再用 textarea。** 一个元素、一个滚动偏移、区间可设样式。之所以否决,是它与该缺陷的体量严重不相称:`contenteditable` 会把 IME 组词、撤销/重做、选区语义与粘贴规范化重新压回我们身上,而这些目前都由 textarea 加输入状态机处理,且状态机已持有一份以 textarea 取值语义为前提的撤销日志。
-
-**在既有的滚轮处理函数里滚动 backdrop,而不是新增 `scroll` 监听。** 该处理函数本就在 textarea 上的每次滚轮时运行,看似是自然的落点。之所以否决,是它只覆盖了盒子滚动的其中一种成因:在末尾输入、`End`、方向键、拖选越过边缘、拖动滚动条,都会在没有滚轮事件的情况下移动 textarea。监听 `scroll` 是在监听事情本身,而不是它的某一个成因。
-
-**用 `scrollbar-gutter: stable` 让三层一起预留滚动条 gutter。** 曾经采用,实测后回退。当初的推理是:无论平台滚动条占多少宽度,三层都预留同样多即可保持相等;而且 `overflow: hidden` 也是滚动容器,按规范应当遵守该声明。chromium 确实如此(三层各预留 8px,宽度 768/768/768)。WebKit 不然:它对 `overflow-y: auto` 预留、对 `overflow: hidden` 不预留,结果仍是 768 对 776——差距原样保留——于是该属性在唯一能观测到这一偏差的引擎上一无所获,却让每一位 chromium 用户损失 8px 文本列。改为断言该前提并记录 WebKit 的差距。
-
-**改为抑制 textarea 的滚动条,而不是给另外两层预留 gutter。** 在 `.input` 上写 `scrollbar-width: none` 同样能让宽度相等,且不必收窄文本列。之所以否决:草稿超过上限后 composer 是有意显示滚动条滑块的——`.card` 正是为此绑定了 l2 滚动条 token——去掉它就等于拿走了「下面还有内容」这一唯一提示。
-
-**改用 `transform: translateY(-scrollTop)` 平移 backdrop,而不是滚动它。** transform 不受内容高度钳制,因此它能把任何范围偏差——包括尾随换行这一种——一并掩盖,却并不让两层真正对齐。之所以否决,是因为这个偏差本身就是真正的缺陷:范围不等同时意味着两层对末行位置的判断不一致,把它藏在一个不受钳制的 transform 之后,只会让这一失配在任何人去测量 backdrop 的那一刻重新浮现。修正范围本身,才能让草稿高度只有一个事实来源。
-
-**再加一个以已提交草稿为 key 的 layout effect 作为第二道镜像。** 该改动的第一版确实带着它,理由是:一次编辑会让两层重排却不一定让 textarea 移动,且草稿变短时两层各自独立地被钳位。这两个前提都不成立,因此在针对构建产物客户端逐个变异测试每个 hook 之后将其移除:仅禁用 layout effect 时浏览器场景全绿,而仅禁用 `scroll` 监听则会失败。输入会把光标滚入可见范围,那就是一次普通的 `scroll`;草稿变短时两层因范围相等而钳位到同一个最大值,且 textarea 的钳位同样会触发 `scroll`。该 effect 本想覆盖的那个具体隐患——React 在装饰集合形状变化时替换 backdrop 的全部子节点,从而重置其偏移——并不会发生:在 chromium 中实测,替换一个 `overflow: hidden` 盒子的全部子节点会保留 `scrollTop`(300 仍为 300),唯一会将其归零的替换是把内容缩短到偏移之下,而那正是已被覆盖的钳位情形。
-
-**在 `onChange` 处理函数里同步。** 除上述同样的理由外还有其自身的问题:它在 React 把新草稿提交到 backdrop 之前触发,因而会按上一次的布局做镜像。
-
-## 后果
-
-- 超过上限的草稿会滚动其字形。浏览器场景实测:在 40 行草稿上做一次滚轮手势后,最后一行位于可见盒子之内,第一行已滚出上方;此前最后一行仍停在盒子下方整整一个草稿高度处,而 textarea 自身的偏移已经移动了。
-- 该耦合是单向且廉价的——一次对一个数字的赋值,没有测量,除 `scrollTop` 外没有额外的布局读取——因此不会给输入路径增加开销。
-- chip、claim token 高亮与文本引用标记在滚动时始终与其字形对齐,因为它们定位在 backdrop 内部并随之移动。装饰扫描本身没有任何改动。
-- composer 的双层设计保留了这一隐患:日后在 backdrop 旁新增的任何一层都需要同样的镜像;而任何改变某一层如何保留其末行行盒的改动,都会破坏镜像所依赖的范围相等性。e2e 场景对两者都做了断言——用户真正关心的关系(哪一行在屏幕上),以及其下的范围相等性——因此日后一旦出现偏差,失败会落在不变量上,而不是落在某张截图上。
-- 范围相等性是被断言的,而非被假定的。它正是那个能把「镜像偏移」从正确变为微妙错误的前提,并且在加入哨兵之前,它在尾随换行这一形态上确实不成立。
-- 折行宽度相等是另一个前提,而它并非普遍成立:WebKit 把 `.input` 排得比字形层窄 8px。该问题先于本次改动存在,此处保持开放,上文记录了实测数值,并在测试通道所用引擎上加了断言。一份折行恰好取决于这 8px 的草稿会在 WebKit 上钳制镜像。
-- composer 的布局没有变化。此前有一版为追求折行宽度前提而在所有平台把文本列收窄了 8px;实测表明它并不能带来该保证,因此度量与改动前保持一致。
-
-## 验证
-
-[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例证明镜像路径确实执行:它对两侧偏移都做了桩替换——因为 jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动任何元素——并断言 backdrop 既跟随 textarea 到新的偏移,也跟随它回到顶部。撤掉那个 `ref` 会让它失败。
-
-用户可见的事实需要真实引擎,因此 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物客户端测量它:在全新工作区空白会话的 composer 中放入 40 行草稿,零模型调用,用一个跨越 backdrop 自身文本的 DOM Range 报告首行与末行相对于可见盒子的位置。一个防空转守卫会先断言草稿确实溢出了设有上限的盒子。另有一个独立用例驱动尾随换行这一形态,先断言两侧范围相等,再断言字形确实抵达末尾;每一层的最大值都通过请求一个不可能的偏移再读回其钳位结果来观测,而非由 `scrollHeight` 计算得出。第三个用例断言 gutter 前提:折行宽度相等,且每层预留的带宽大于零。正是这条「带宽」使该断言不至于空转——在本引擎的 overlay 滚动条下,即使完全不预留,两侧宽度也会相等;把保证传递到滚动条真正占宽的平台上的,是那次预留,而不是这次相等。
-
-已双向确认。撤掉镜像并重新构建各包后,滚轮用例在两层偏移上失败,输入用例随之失败,golden 差异读作 `last draft line is on screen: false` 而 `textarea moved: true`——即以 fixture(测试前置数据)形式陈述的原始现象。静止状态用例在两种构建下都通过,这正是要点所在:它就是掩盖了该缺陷的那个状态。
-
-注意 composer 随客户端模块 bundle 一同发布,因此仅运行 `pnpm run build:web` 不会纳入对 `InputBar.tsx` 的改动——必须运行包构建,浏览器测试通道才能看到它;针对陈旧 `lib/` 运行的场景,断言的是比当前工作树更旧的客户端。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.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-07-31-composer-text-layers-share-one-scrollport.md
+2026-07-31-composer-text-layers-share-one-scrollport.md: ba11384409714d6a64a964d63197705acf39d213
+2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 9a4a2bcc9f947d385d1ff9fc6367b9a3eab17733

+ 82 - 0
.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md

@@ -0,0 +1,82 @@
+# Agent Note: The composer's two text layers share one scrollport
+
+Status: implemented
+
+English | [中文](2026-07-31-composer-text-layers-share-one-scrollport.zh.md)
+
+## Problem
+
+The composer paints its text in two stacked layers ([InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)): the `<textarea>` owns the value, the selection, and the caret but renders its own glyphs `color: transparent`, and every visible character is painted by the `[data-input-backdrop]` div beneath it, which also carries the claim-token highlight, the chips, and the ghost hint. That split is what makes chips and highlights possible at all — a textarea cannot style a range of its own text. The draft box is capped at 14 lines, so past the cap something has to scroll.
+
+Two layers with two scroll offsets fail in two stages, and this change is the second one.
+
+The first was static. The backdrop is `position: absolute; inset: 0; overflow: hidden` — clipped, not scrolled — and nothing in the browser links its offset to the textarea's, so past the cap the caret moved and the words stayed frozen at line 1. Below the cap that is invisible, because both layers rest at 0; the defect was exactly as old as the cap and hid behind the resting state that every screenshot captured. It was fixed by mirroring the textarea's `scrollTop` onto the backdrop from a `scroll` listener, and that made the two layers agree **at rest**.
+
+The second is what a user then reported: swiping quickly from the top of a long draft throws the caret up out of its own text, as though it carried momentum, and it settles back a moment later. The mirror is the cause. A wheel gesture scrolls the textarea on the compositor, off the main thread; the `scroll` event that drives the assignment is dispatched afterwards, so for the frames in between the caret sits at the new offset and every glyph sits at the old one. Measured on a standalone harness of the same geometry, moving the offset 200px and reading the caret's distance to its own glyphs before the task ends: chromium 203px, firefox 202px, WebKit 203px of separation, settling to the fixed line-box constant (3/2/3) a frame or two later. Every engine, every gesture, proportional to how fast the user scrolls.
+
+No listener can close that gap, because the gap is the definition of a listener: it runs after the thing it reacts to. Anything that keeps two boxes equal in JavaScript is a frame behind a compositor that moves one of them without asking.
+
+## Decision
+
+One scrolling box, holding both layers.
+
+`[data-input-scroll]` is the composer's only scrollport and carries the 14-line cap. Inside it, the auto-grow stack is as tall as the **whole** draft — the hidden mirror div is in normal flow and no longer capped, so it sizes the stack to the full text — and the backdrop and textarea ride that height absolutely. The textarea is `overflow: hidden` with no scrollable overflow of its own; it can no longer hold an offset at all.
+
+The browser then applies one offset to both layers, in the same frame, on the same compositor. The caret is bound to its glyphs by construction rather than by upkeep: there is no code to run, no event to wait for, and no state that can be one frame stale. The wheel-chaining handler stays, retargeted from the textarea to the scrollport, and remains the only listener on the box.
+
+Two things the previous mechanism needed are gone with it:
+
+**The backdrop's trailing-line sentinel.** It existed to keep the two boxes' scroll extents equal — a textarea reserves a line box for the caret after a final newline while `white-space: pre-wrap` collapses a text node's trailing newline, so a draft ending in a newline made the backdrop one line shorter and clamped the mirrored offset a line above the caret. With one scrollport the backdrop's own extent decides nothing: the mirror div sizes the stack for both layers, both start at the same top, and a layer whose content ends earlier simply paints nothing on the last line. The shape is worth keeping in mind rather than the mechanism: it is the one that measured 628 against 652 when the two boxes had to agree on a height.
+
+**The wrap-width premise.** All three layers now resolve their width inside the scrollport, so a scrollbar that consumes layout space costs them the same width by construction. This closes the divergence the superseded note recorded as open and unfixable by any property: WebKit reserved gutter space for the `overflow-y: auto` textarea and not for the `overflow: hidden` layers beside it, laying the textarea out 768 against 776 — worth 2 to 5 wrapped lines on a long draft, i.e. glyphs under the wrong caret on the one engine where it was observable. Measured on the harness after the change, all three layers report one width on all three engines Playwright ships.
+
+**Edits the composer performs itself now ask for the reveal.** Paste and cut suppress the native edit — the machine owns the draft and the undo log — and restore the caret with `setSelectionRange`, which reveals nothing: measured in chromium and WebKit, pasting a long block leaves the view where it was while the caret sits at the end of what was pasted. That defect predates this change (Firefox happened to reveal it, in the old geometry only) and is fixed here because one scrollport is what finally makes the reveal ours to perform. The two restores share one helper that measures the caret against the hidden mirror — same draft, same metrics, same wrap width, so a Range collapsed at the caret's index reports where the caret is without a caret API — and scrolls the minimum that brings it inside, which is what the browser does for typing.
+
+One shape needs a rule of its own, because the engines disagree about it: a caret straight after a newline sits on a line with nothing on it to measure, which is where a trailing-newline draft ends. chromium returns **no client rects at all** for the collapsed position — an all-zero box, which would send the reveal the wrong way — firefox reports the line above, and WebKit the right one. The helper therefore measures the newline the caret just left, whose box is the line it came from, and steps one line down; all three then land on the same offset (649 of 652, with the caret's line at 315 inside the 336px box). A non-collapsed Range over that newline returns a real rectangle on all three engines even when the draft ends in consecutive newlines, so each trailing blank line composes with the same rule.
+
+Revealing the caret is the one thing that now depends on the browser rather than on us: with no offset of its own, the textarea's scroll-into-view has to walk up to the scrollport. It does, on every engine measured — typing at the draft's end brings the scrollport to the caret (625, 626 and 628 of a 628px maximum in chromium, firefox and WebKit), walking the caret back up with `ArrowUp` scrolls back to it, and typing after scrolling away returns to it.
+
+## Alternatives considered
+
+**Mirror `scrollTop` onto the backdrop from a `scroll` listener.** The superseded decision, and correct at rest: it is what made a long draft scrollable at all. Rejected now because it cannot be correct in motion — it is a main-thread reaction to a compositor-thread fact — and because it needed two premises to stay true that the single scrollport does not need at all (equal extents, equal wrap widths), each of which had already failed once.
+
+**Translate the backdrop with `transform: translateY(-scrollTop)` instead of assigning an offset.** Same lag: still main-thread, still driven by the same event. It additionally papers over extent divergence rather than making the layers agree, so a mismatch resurfaces the moment anything measures the backdrop.
+
+**Drive the backdrop from a scroll-driven animation (`animation-timeline: scroll()`).** This would run the coupling on the compositor and genuinely eliminate the lag while keeping two boxes. Rejected on support: Safari does not implement it and Firefox has only recently, so the composer would keep the reported defect on the engines that lack it, and the fallback path would be the mechanism being replaced.
+
+**Scroll both layers from JavaScript, with the textarea `overflow: hidden` and a wheel handler assigning both offsets in one task.** No divergence during wheel gestures, since nothing scrolls without us. Rejected because it replaces native scrolling — momentum, trackpad rubber-banding, scrollbar dragging, keyboard scrolling — with a hand-written approximation, and the caret-reveal path (the browser setting the textarea's own offset) still lands asynchronously.
+
+**Keep the cap on the mirror and just wrap today's structure in a scroller.** The layers would stay window-sized, not draft-sized: `inset: 0` on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything.
+
+**Give the backdrop `overflow: auto` and let it scroll itself.** It would then have an offset of its own to keep in step, which is the same problem plus a second scrollbar painted over the input. The backdrop is a projection of the textarea, not an independently navigable surface.
+
+**Drop the backdrop and style the textarea's own text.** This removes the layer split and the whole class of desync with it. Rejected because it is not implementable: a textarea renders one uniform text run, so the claim-token highlight, the chips, and the ghost hint — the reasons the backdrop exists — have no way to be expressed. Losing them to fix scrolling trades a bounded defect for a feature deletion.
+
+**Render the draft in a `contenteditable` div.** One element, one offset, styleable ranges. Rejected as far out of proportion: `contenteditable` would put IME composition, undo/redo, selection semantics, and paste normalization back on us, all of which the textarea plus the input machine currently handle, and the machine already owns an undo log that assumes a textarea's value semantics.
+
+**`scrollbar-gutter: stable` on all three layers.** Tried and reverted while the textarea was the scroller: WebKit applied it to `overflow-y: auto` and not to `overflow: hidden`, leaving the same 8px gap unclosed while costing every chromium user 8px of text column. Moot now — the layers share a containing block, so there is nothing to reserve.
+
+**`scrollbar-width: none` on the scrolling layer.** Would equalize widths by hiding the thumb. Rejected: the composer deliberately shows one once the draft passes the cap — `.card` binds the l2 scrollbar tokens for exactly that — and it is the only affordance saying a long draft continues below.
+
+## Consequences
+
+- The caret cannot leave its glyphs. The browser scrolls one box, so the separation between where the textarea puts a line and where the backdrop paints it is a fixed line-box constant at every offset, mid-gesture included. The scenario measures exactly that number.
+- The scrollbar moved from the textarea to the scrollport — the same visual place, one box out. The `.card` l2 token binding still inherits down to it.
+- Chips, claim-token highlights, and text-ref marks stay aligned with their glyphs while scrolled, because they are positioned inside the backdrop and move with it. The decoration walk is unchanged apart from the dropped sentinel.
+- On Firefox and WebKit, clicking into a composer whose draft overflows the cap now also scrolls the conversation transcript to its bottom: the caret's scroll-into-view walks past the composer's scrollport up to the transcript scrollport, which a textarea shorter than its box never made it do. Chromium does not. Measured, with `overscroll-behavior: contain` and `contain: paint` both tried and neither stopping the walk — there is no CSS that ends scroll-into-view chaining. Accepted: it scrolls toward the bottom, where the composer already sits, and the alternative is a caret visibly detached from its text on every engine.
+- Paging and drag-selection are unchanged, both measured old against new. `PageDown`/`PageUp` never moved a textarea's caret in the first place — chromium scrolls a page and leaves `selectionStart` where it was, in both geometries; only the box that scrolls differs. Drag-selecting past the bottom edge still auto-scrolls, and to the same place (chromium 628/628, firefox 625/620, WebKit 170/170 — WebKit's slower autoscroll is equally slow before and after).
+- The composer's own `focus()` calls pass `preventScroll` — the unlock/session-switch effect and the focus-keeping mousedown on the toolbar buttons — so a focus nobody gestured for cannot move the transcript through the taller textarea's reveal chain. Suppressing that walk hands the caret back to us on the one path where it matters: the composer DOM is reused across sessions, so switching to a longer draft keeps the previous offset while the value swap puts the caret at the new draft's end. Measured on all three engines, that leaves the caret 940px below a box sitting at 0; the effect therefore reveals it in its own scrollport, landing at 625 of 628 — what the old geometry reached at 628 through the browser. The mousedown path needs no reveal: the caret has not moved, and the next keystroke gets the browser's native one. A separate reveal-only effect handles a non-empty draft that arrives after render: `ConversationSession` seeds a persisted draft in its own mount effect, which runs AFTER this component's, so the first reveal would otherwise measure an empty mirror and never run again for the draft that then appeared. The second effect never focuses, so ordinary empty/non-empty transitions such as send-clear or failed-send restore cannot take focus from another control.
+- Undo and redo can change the draft without a caret restore or reveal: the machine replays a previous draft and the DOM selection stays where the browser clamps it. That predates this change and is unchanged by it — named here because the two restores that DO reveal make the omission look deliberate, and the helper is sitting right there if it is ever reported.
+- Any layer added beside the backdrop belongs INSIDE the scrollport and must be as tall as the draft, or it reintroduces exactly this defect. This is the composer's standing hazard: the two-layer split is load-bearing for chips and highlights, so the coupling has to be structural, not maintained.
+
+## Testing
+
+The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's.
+
+[composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures the rest in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls. Every metric is read in the caret's own coordinate frame — where the textarea places line n, offset included — against a DOM Range over the backdrop's text for the same line, because that difference is what a user sees. The decisive case changes the offset and re-reads that difference **before the task ends**, which is before any `scroll` listener could have run: 0 with one scrollport, and the full delta with a mirror. A vacuity guard asserts the draft overflows the capped box first, and separate cases cover the cap, one wrap width across all three layers, a wheel gesture, a trailing-newline draft, and the caret-reveal path that the textarea's own scrolling used to handle — typing after scrolling away must bring the scrollport back to the caret.
+
+A separate case covers the paste path end to end: a short draft, the caret at its end, and one `paste` event carrying real clipboard data — the same event a Cmd-V delivers, through the same handler — then the offset and the last pasted line. It waits on the offset rather than on the draft overflowing, because the restore lands one frame after the machine commits the draft; a build without the reveal fails that wait.
+
+The two-geometry comparison behind the decision was measured on a standalone harness before implementing, since the old and new arrangements cannot both exist in the app at once: the same-task separation is 203/202/203px old against 3/2/3px new (chromium/firefox/WebKit), the wrap widths 768-against-776 old on WebKit against 1264/1264/1264 new on all three, and the textarea's own scrollable overflow 0 in the new geometry, which is what makes a second offset impossible rather than merely equal.
+
+Note that the composer ships inside a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `InputBar.tsx` — the package build must run for the browser lane to see it, and a scenario run against a stale `lib/` asserts against an older client than the tree.

+ 82 - 0
.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md

@@ -0,0 +1,82 @@
+# Agent Note: composer 的两层文本共用同一个滚动容器
+
+Status: implemented
+
+[English](2026-07-31-composer-text-layers-share-one-scrollport.md) | 中文
+
+## 问题
+
+composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)):`<textarea>` 持有取值、选区与光标,但它自己的字形以 `color: transparent` 渲染;用户看到的每一个字符都由其下的 `[data-input-backdrop]` 层绘制,该层同时承载 claim token 高亮、chip 与提示影子文本。这一拆分正是 chip 与高亮得以存在的前提——textarea 无法为自身文本的某个区间单独设置样式。草稿框的高度上限为 14 行,因此超过上限之后总得有东西滚动。
+
+两层各自持有一个滚动偏移,会分两个阶段失效,本次改动是其中的第二个。
+
+第一个阶段是静态的。backdrop 为 `position: absolute; inset: 0; overflow: hidden`——只裁剪、不滚动——浏览器也不会把它的偏移与 textarea 关联起来,于是超过上限之后光标在动而文字冻结在第 1 行。未达上限时这一点不可见,因为两层都停在 0;该缺陷与高度上限同龄,藏在所有截图所捕获的那个静止状态背后。当时的修复是用一个 `scroll` 监听把 textarea 的 `scrollTop` 镜像到 backdrop 上,这让两层在**静止时**保持一致。
+
+第二个阶段正是随后被用户报告的现象:从长草稿的顶部快速滑到底部时,光标像带着惯性一样从自己的文字里往上飞出去,过一会儿又落回原位。原因就是那个镜像。滚轮手势在合成线程上滚动 textarea,不经过主线程;而驱动赋值的 `scroll` 事件在其后才派发,因此中间这些帧里光标位于新偏移、每一个字形却仍位于旧偏移。在同一套几何的独立环境上实测:把偏移改变 200px 并在本任务结束之前读取光标与其字形之间的距离,chromium 分离 203px、firefox 202px、WebKit 203px,一两帧之后才收敛到固定的行盒常量(3/2/3)。每个引擎、每次手势都如此,且滑动越快分离越远。
+
+任何监听都无法消除这个间隙,因为这个间隙正是监听的定义:它在自己所响应的那件事之后才运行。凡是用 JavaScript 维持两个盒子相等的做法,都会落后于那个不经询问就搬动其中一个盒子的合成器一帧。
+
+## 决策
+
+只保留一个滚动盒,让它同时装下两层。
+
+`[data-input-scroll]` 是 composer 唯一的滚动容器,14 行的高度上限落在它身上。容器内部的自增高栈与**整份**草稿等高——隐藏的镜像层处于常规流中且不再设上限,因此由它把栈撑到完整文本高度——backdrop 与 textarea 以绝对定位骑在这个高度上。textarea 为 `overflow: hidden`,自身没有可滚动溢出,也就再无法持有任何偏移。
+
+于是浏览器在同一帧、同一个合成器上,把同一个偏移施加给两层。光标与字形的绑定来自结构本身,而不是来自持续维护:没有代码要跑,没有事件要等,也没有任何状态可能落后一帧。滚轮接力处理器保留,只是从 textarea 改挂到滚动容器上,并且仍是这个盒子上唯一的监听。
+
+上一版机制所需要的两样东西随它一起消失:
+
+**backdrop 的尾行哨兵。** 它的存在只是为了让两个盒子的滚动范围相等——textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行,因此以换行结尾的草稿会让 backdrop 少一行,把镜像偏移钳制在光标上方一行。改为单一滚动容器后,backdrop 自身的范围不再决定任何事:镜像层为两层统一定高,两层顶端对齐,内容更早结束的那一层只是在最后一行什么都不画。值得记住的是这类草稿形状而不是那套机制:正是它在「两个盒子必须就高度达成一致」的时代量出了 628 对 652。
+
+**折行宽度这一前提。** 现在三层都在滚动容器内部解析自身宽度,因此一条占布局宽度的滚动条对它们的代价由结构保证相等。这也就关闭了被取代的那篇笔记记录为「悬置且没有任何属性能修」的分歧:WebKit 会为 `overflow-y: auto` 的 textarea 预留槽位,却不为它旁边 `overflow: hidden` 的层预留,把 textarea 排成 768 对 776——在长草稿上值 2 到 5 个折行,也就是在唯一能观察到它的那个引擎上把字形放到了错误的光标之下。改动后在独立环境实测,Playwright 自带的三个引擎上三层宽度均一致。
+
+**由 composer 自己完成的编辑,现在会主动请求回视。** 粘贴与剪切都会抑制原生编辑——草稿与撤销日志归状态机所有——再用 `setSelectionRange` 恢复光标,而这不会带来任何回视:在 chromium 与 WebKit 上实测,粘贴一大段之后视图停在原处,光标却落在所粘内容的末尾。该缺陷早于本次改动(Firefox 只在旧几何下恰好会回视),在此修复,是因为单一滚动容器才终于让「回视」成为我们能自己做的事。两处恢复共用一个 helper:它以隐藏的镜像层为标尺——同一份草稿、同一套度量、同一折行宽度,因此在光标索引处折叠一个 Range 就能报出光标位置,无需任何 caret API——并且只滚动到刚好把该行带进可见范围为止,与浏览器为输入所做的一致。
+
+有一种形状需要单独的规则,因为引擎之间在这里并不一致:紧跟在换行之后的光标,落在一条没有任何内容可供度量的行上——以换行结尾的草稿正是终止于此。chromium 对这个折叠位置**根本不返回任何 client rect**(一个全零盒子,会把回视带向反方向),firefox 报的是上一行,WebKit 报的才是对的那一行。因此该 helper 改为度量光标刚离开的那个换行——它的盒子就是光标来的那一行——再往下走一行;三者随即落在同一个偏移上(649/652,光标所在行位于 336px 盒内的 315)。即使草稿以连续换行结尾,覆盖该换行的非折叠 Range 在三个引擎上都会返回真实矩形,因此每个尾随空行都能沿用同一条规则定位。
+
+现在唯一依赖浏览器而非依赖我们自己的,是把光标滚入可见范围:textarea 没有了自己的偏移,它的 scroll-into-view 必须向上走到滚动容器。实测的每个引擎都会这么做——在草稿末尾输入会把滚动容器带到光标处(chromium、firefox、WebKit 分别为 625、626、628,最大值 628),用 `ArrowUp` 把光标一路走回去会滚回去,滚离光标后再输入也会回到光标。
+
+## 备选方案
+
+**用 `scroll` 监听把 `scrollTop` 镜像到 backdrop 上。** 被取代的那个决策,静止时是正确的:正是它让长草稿第一次可以滚动。现在被否决,是因为它在运动中不可能正确——它是主线程对合成线程事实的响应——并且它需要两个前提持续成立(范围相等、折行宽度相等),而单一滚动容器根本不需要这两个前提,其中每一个都已经失效过一次。
+
+**用 `transform: translateY(-scrollTop)` 平移 backdrop,而不是赋一个偏移。** 延迟相同:仍在主线程,仍由同一个事件驱动。它还会把范围分歧盖住而不是让两层真正一致,于是只要有什么东西去测量 backdrop,错配就会重新浮现。
+
+**用滚动驱动动画(`animation-timeline: scroll()`)驱动 backdrop。** 这会把耦合放到合成器上运行,在保留两个盒子的前提下确实能消除延迟。因支持度被否决:Safari 尚未实现、Firefox 也是近期才有,因此在缺少它的引擎上 composer 会保留被报告的缺陷,而其回退路径正是这次要替换掉的机制。
+
+**两层都由 JavaScript 驱动滚动:textarea 设 `overflow: hidden`,滚轮处理器在同一个任务里给两个偏移赋值。** 滚轮手势期间不会分离,因为没有我们就没有东西会滚动。被否决,是因为它用手写近似替换了原生滚动——惯性、触控板回弹、拖拽滚动条、键盘滚动——而且光标回视路径(浏览器设置 textarea 自己的偏移)仍然是异步落地的。
+
+**把上限留在镜像层上,只在今天的结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」:绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的,而不是相对其可滚动溢出区域,于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高,这套排布才有意义。
+
+**给 backdrop 加 `overflow: auto`,让它自己滚动。** 那样它就有了一个自己的偏移需要保持同步,即同一个问题再加一条画在输入框上的滚动条。backdrop 是 textarea 的投影,不是一个可独立导航的界面。
+
+**去掉 backdrop,直接给 textarea 自己的文本上样式。** 这会消除分层,也一并消除这一整类失步。被否决是因为它不可实现:textarea 只渲染一段统一的文本,claim token 高亮、chip 与提示影子文本——backdrop 存在的理由——无从表达。为修滚动而失去它们,是拿一个有界缺陷换一次功能删除。
+
+**用 `contenteditable` div 渲染草稿。** 一个元素、一个偏移、区间可上样式。因代价与缺陷严重不成比例被否决:`contenteditable` 会把输入法组合、撤销/重做、选区语义与粘贴规范化重新压回我们身上,而这些目前都由 textarea 加输入状态机处理,且状态机已经持有一份假定 textarea 取值语义的撤销日志。
+
+**给三层都加 `scrollbar-gutter: stable`。** 在 textarea 还是滚动者时试过并已回退:WebKit 只对 `overflow-y: auto` 生效、不对 `overflow: hidden` 生效,那 8px 的差距原样留着,却让每个 chromium 用户无条件损失 8px 文本列宽。现在已无意义——三层共享同一包含块,没有什么需要预留。
+
+**给滚动的那一层加 `scrollbar-width: none`。** 靠隐藏滑块来抹平宽度。被否决:草稿超过上限时 composer 是有意显示滑块的——`.card` 绑定 l2 滚动条 token 正是为此——而它是唯一提示「长草稿在下面还有」的可供性。
+
+## 影响
+
+- 光标不可能离开自己的字形。浏览器滚动的是同一个盒子,因此「textarea 把某一行放在哪」与「backdrop 把这一行画在哪」之间的距离在任何偏移下都是一个固定的行盒常量,手势进行中也不例外。场景测试度量的正是这个数。
+- 滚动条从 textarea 移到了滚动容器上——视觉位置相同,只是外移了一层。`.card` 的 l2 token 绑定仍会继承下去。
+- chip、claim token 高亮与文本引用标记在滚动时仍与其字形对齐,因为它们定位在 backdrop 内部、随之移动。除去掉哨兵之外,装饰扫描没有变化。
+- 在 Firefox 与 WebKit 上,点进一个草稿超过上限的 composer 现在还会把会话记录滚动到底部:光标的 scroll-into-view 会越过 composer 的滚动容器一路走到会话记录的滚动容器,而一个比自身盒子矮的 textarea 从不会引发这一步。chromium 不会。已实测,并试过 `overscroll-behavior: contain` 与 `contain: paint`,两者都拦不住这次上行——没有任何 CSS 能终止 scroll-into-view 的接力。接受:它滚向底部,而 composer 本来就在底部,而其替代方案是在每个引擎上都出现光标与文字明显分离。
+- 翻页与拖拽选区的行为未变,二者均做了新旧对照实测。`PageDown`/`PageUp` 本来就不会移动 textarea 的插入点——chromium 是滚动一页并保持 `selectionStart` 不变,新旧几何皆然,区别只在于滚的是哪个盒子。拖拽选区越过下边缘仍会自动滚动,且落点一致(chromium 628/628、firefox 625/620、WebKit 170/170——WebKit 自动滚动较慢,但改动前后一样慢)。
+- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦,不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后,光标就回到了我们手上,而这在一条路径上确实要紧:composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。另一个只负责回视的 effect 会处理渲染后才到达的非空草稿:`ConversationSession` 在自己的 mount effect 中注入持久化草稿,而该 effect 在本组件的 effect 之后运行,否则第一次回视会量到空镜像,且不会再为随后出现的草稿重跑。第二个 effect 从不聚焦,因此发送后清空或发送失败后恢复这类普通的空/非空转换不会从其他控件夺走焦点。
+- 撤销/重做可以在不恢复光标、也不回视的情况下改动草稿:状态机重放上一版草稿,DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名,是因为另外两处恢复都会回视,会让这处遗漏看起来像有意为之;真被报告时 helper 就在旁边。
+- 任何新增在 backdrop 旁边的层都属于滚动容器**内部**,并且必须与草稿等高,否则就会重新引入这一缺陷。这是 composer 长期存在的风险点:两层拆分对 chip 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。
+
+## 测试
+
+[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。
+
+[composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区空会话的 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。
+
+另有一个用例端到端覆盖粘贴路径:短草稿、光标停在末尾,然后派发一个携带真实剪贴板数据的 `paste` 事件——与 Cmd-V 送达的是同一个事件,走同一个处理器——再检查偏移与所粘内容的最后一行。它等待的是偏移而不是「草稿是否溢出」,因为恢复发生在状态机提交草稿之后的下一帧;没有这次回视的构建会卡在这个等待上失败。
+
+支撑该决策的两套几何对比是在实现之前于独立环境度量的,因为新旧排布无法在应用里同时存在:同任务分离度旧为 203/202/203px、新为 3/2/3px(chromium/firefox/WebKit),折行宽度旧在 WebKit 上为 768 对 776、新在三个引擎上均为 1264/1264/1264,而新几何下 textarea 自身的可滚动溢出为 0——正是这一点让第二个偏移不可能存在,而不只是碰巧相等。
+
+注意 composer 打包在 client-module bundle 内,因此只跑 `pnpm run build:web` 并不会带上 `InputBar.tsx` 的改动——必须先跑包构建,浏览器泳道才看得到;对着过期 `lib/` 跑场景,断言的是比当前代码树更旧的客户端。

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml → .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md
-2026-07-31-composer-glyph-layer-tracks-the-textarea.md: d60a100be98683b5f7a7c88edf7585d275134730
-2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: eab3f9e3fe3bddb426836113d08f1839329119d5
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md
+2026-08-03-cli-signal-shutdown-escalation.md: 2746b5784baad0f3b14258280cd56a621db07c15
+2026-08-03-cli-signal-shutdown-escalation.zh.md: 0bda83327d4cc8fe2edb61f8145a89138610901e

+ 52 - 0
.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md

@@ -0,0 +1,52 @@
+# Agent Note: Bounded, escalating signal shutdown for Web and headless
+
+Status: implemented
+
+English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md)
+
+## Problem
+
+The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and `dsh -p` so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound.
+
+A user then reproduced `dsh -p` hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts.
+
+The latch then turned that telemetry defect into an unkillable CLI: normal completion was already awaiting the single-shot root disposal; the first SIGINT joined the same pending disposal and set the signal latch; later SIGINTs returned at the latch, so the process had no remaining escape. A signal received before normal completion had the same unbounded wait. Web used the same latch shape.
+
+Telemetry's own timeouts cannot prove that the whole plugin tree settles. Any current or future disposer can wedge, and the process boundary must preserve both a graceful first attempt and a user-controlled way out.
+
+## Decision
+
+The fix has two ownership layers. The OTel backend adds `shutdownTimeoutMillis` (default and shipped value: three seconds) around the SDK provider's complete shutdown Promise. Crossing it rejects into the telemetry coordinator's existing contained-failure path, allowing the Cordis tree to finish disposal; pending records may be lost because OTel exposes no cancellation for the transport Promise.
+
+Web and headless share `createProcessShutdown`, one process-level controller around root disposal:
+
+- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another.
+- The first signal starts the same graceful disposal and a referenced five-second exit backstop. Disposal success or failure exits once; neither can cancel the process exit.
+- A signal received while shutdown is pending forces immediate exit with that signal path's code. This includes the first `Ctrl+C` after headless normal completion has already entered disposal, and a second signal after a signal initiated the drain.
+- The five-second bound is a process-safety invariant, not a deployment tunable. It is long enough for the telemetry deployment's ordinary drain ceiling while still bounding any wedged disposer at the launcher boundary.
+
+Headless preserves exit 0 for a completed turn, exit 1 for another turn-end reason or API business error, 130 for SIGINT, and 143 for SIGTERM. Web preserves its existing SIGTERM exit 0 and SIGINT exit 130 behavior.
+
+This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-telemetry-default-mount.md) assumption that SDK exporter/processor timeouts bound complete provider shutdown, and its earlier decision to defer a process-level backstop. The backend owns its export loss/latency policy and closes the known SDK `forceFlush()` gap; the launcher owns the outer guarantee that no plugin can trap the process indefinitely.
+
+## Alternatives considered
+
+**Bound only the telemetry backend's `shutdown()`.** Insufficient because it protects the known OTel wait but cannot protect the launcher from another plugin's disposer.
+
+**Restore Node's default immediate signal exit.** Rejected because a healthy first signal should still flush telemetry and release other resources. Immediate exit is the explicit escalation path, not the default.
+
+**Add only the five-second timeout.** Rejected because a user pressing `Ctrl+C` again is asking to stop waiting now. Swallowing that intent for the rest of the grace period recreates the reported behavior at a shorter duration.
+
+## Consequences
+
+A healthy exit still disposes the complete Cordis tree. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a repeated signal ends it immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated.
+
+The controller is launcher infrastructure rather than a Cordis plugin: it makes no claim that disposal completed, and it does not weaken the lifecycle rule that ordinary disposers must reach quiescence.
+
+## Testing
+
+`apps/cli/tests/process-shutdown.spec.ts` pins resolved and rejected disposal, the five-second backstop, normal-call coalescing, a signal interrupting normal disposal, and second-signal escalation.
+
+`apps/cli/tests/headless-shutdown.e2e.ts` boots the real shipped Web/headless Loader tree in a PTY with a test-only plugin whose disposer announces entry and never settles. The test sends SIGINT after the observation URL, waits for proof that disposal started, sends SIGINT again, and requires exit 130. The source/artifact launch resolver keeps the same regression on both execution planes. This PTY case covers the user-visible process state; no model-output snapshot changes.
+
+`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` holds a real OTLP request open after timer export begins and pins that Cordis disposal returns at `shutdownTimeoutMillis`, despite the SDK's `forceFlush()` remaining pending. The collector is then released so the still-observed provider Promise settles cleanly.

+ 52 - 0
.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md

@@ -0,0 +1,52 @@
+# Agent Note:Web 与 headless 的有界信号关闭和重复信号强制退出
+
+状态:已实现
+
+[English](2026-08-03-cli-signal-shutdown-escalation.md) | 中文
+
+## 问题
+
+默认挂载遥测后,`dsh web` 与 `dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。
+
+随后有用户复现,`dsh -p` 在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。
+
+闩锁随后把这个遥测缺陷变成无法终止的 CLI(命令行界面):正常完成流程已经在等待单次根级 dispose;第一次 SIGINT 会加入同一个待结算的 dispose,并设置信号闩锁;后续 SIGINT 在闩锁处直接返回,因此进程再无退出途径。正常完成之前收到信号时,同样会陷入无界等待。Web 使用的闩锁结构与此相同。
+
+遥测自身的超时无法证明整棵插件树都能结算。任何当前或未来的 disposer 都可能卡死;进程边界既要保留第一次优雅关闭的机会,也必须给用户留下强制退出的途径。
+
+## 决策
+
+修复分为两层归属。OTel 后端围绕 SDK 提供方的完整关闭 Promise 增加 `shutdownTimeoutMillis`(默认值和交付值均为 3 秒)。超过该截止时间时会 reject,并进入遥测协调器现有的失败隔离路径,使 Cordis 插件树能够完成 dispose;由于 OTel 未公开取消传输 Promise 的能力,待处理记录可能丢失。
+
+Web 与 headless 共用 `createProcessShutdown`,它是围绕根级 dispose 建立的进程级控制器:
+
+- 多次正常关闭调用会汇合到同一次 dispose,并保留首次请求的退出码;这些调用不会相互触发强制退出。
+- 第一个信号会启动同一次优雅 dispose,并设置一个带引用的 5 秒退出兜底。dispose 无论成功或失败都会触发且仅触发一次退出;任何一种结果都无法取消进程退出。
+- 关闭待结算期间收到信号时,会立即按该信号路径的退出码强制退出。这既包括 headless 正常完成已经进入 dispose 后收到的第一次 `Ctrl+C`,也包括由信号启动排空后收到的第二个信号。
+- 5 秒上限是进程安全不变式,而不是部署调节项。它足以覆盖遥测部署的常规排空时限,同时仍在启动器边界为任何卡死的 disposer 设置等待上限。
+
+headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API 业务错误仍以 1 退出,对 SIGINT 以 130 退出,对 SIGTERM 以 143 退出。Web 保留现有行为:SIGTERM 以 0 退出,SIGINT 以 130 退出。
+
+这项决策取代了[遥测部署 Agent Note](../feature/2026-07-31-web-telemetry-default-mount.md) 中 SDK 导出器/处理器超时能够限制提供方完整关闭流程的假设,也取代了其中暂缓进程级退出兜底的决定。后端负责导出数据丢失与延迟策略,并封住已知的 SDK `forceFlush()` 缺口;启动器负责最外层保证,确保任何插件都无法无限期困住进程。
+
+## 考虑过的替代方案
+
+**只限制遥测后端的 `shutdown()`。** 仍不充分:它能保护已知的 OTel 等待,但无法保护启动器免受其他插件 disposer 的影响。
+
+**恢复 Node 默认的信号即时退出。** 不予采纳:收到第一个信号时,健康流程仍应刷新遥测数据并释放其他资源。即时退出是显式的强制退出路径,而非默认行为。
+
+**只增加 5 秒超时。** 不予采纳:用户再次按下 `Ctrl+C`,就是要求立即停止等待。若在剩余宽限期内继续吞掉这一意图,只是缩短了报告中故障的持续时间,并未解决问题。
+
+## 后果
+
+健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号则立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭契约已经失败,或用户明确要求强制退出时,才会有意接受这一结果。
+
+该控制器属于启动器基础设施,而不是 Cordis 插件:它不会声称 dispose 已经完成,也不会削弱普通 disposer 必须达到完全停稳状态的生命周期规则。
+
+## 测试
+
+`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功与失败、5 秒退出兜底、正常调用汇合、信号中断正常 dispose,以及第二次信号强制退出的行为。
+
+`apps/cli/tests/headless-shutdown.e2e.ts` 在 PTY 中启动真实交付的 Web/headless Loader 插件树,并挂载一个仅用于测试的插件;该插件的 disposer 会声明已经进入清理流程,但永不结算。测试在观察地址出现后发送 SIGINT,等待 dispose 已启动的证据,再次发送 SIGINT,并要求进程以 130 退出。源码/产物启动解析器使两个执行平面都覆盖同一项回归。该 PTY 用例覆盖用户可见的进程状态;模型输出快照没有变化。
+
+`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` 在定时器导出开始后保持一条真实 OTLP 请求打开,并固定以下行为:即使 SDK 的 `forceFlush()` 仍待结算,Cordis dispose 也会在 `shutdownTimeoutMillis` 到期时返回。随后测试释放 collector,使仍受观察的提供方 Promise 干净结算。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
-2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823
-2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf
+2026-07-28-web-terminal-card.md: 0e5f3e2157ebfc4e71aead26c15b6ee91958a5d5
+2026-07-28-web-terminal-card.zh.md: 1285d3fbb46ebd32ff163feac632cd487e8a04f1

Разница между файлами не показана из-за своего большого размера
+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md


Разница между файлами не показана из-за своего большого размера
+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md


+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-search-render-card.md
-2026-07-30-search-render-card.md: 36f772d7198ef30d6c243549cbaa9f16c780268b
-2026-07-30-search-render-card.zh.md: 7d7ba352f19f3fb83cb6b7d049980776dc2c277e
+2026-07-30-search-render-card.md: 29544cb703f3ab048f4e7702935887ecf05179bf
+2026-07-30-search-render-card.zh.md: e0ae21924e82152ba629ab628476113ad9afe3d8

+ 3 - 3
.agents/notes/implemented/feature/2026-07-30-search-render-card.md

@@ -18,7 +18,7 @@ The discriminant is `shape`, not `kind`, deliberately: the same presentation mod
 
 One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `shape` for the row layout. The discriminated `shape` keeps each variant's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional.
 
-The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op for every consumer (the TUI already falls back to `result.content`, and web fallbacks read the raw `tool/result` content), and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw `tool/result` content.
+The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op because consumer fallbacks already read the raw `tool/result` content, and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw result content.
 
 The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`.
 
@@ -30,7 +30,7 @@ The card tag is result-time only. A search call stays a `GenericCallView` (`kind
 
 The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes, because only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`.
 
-The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm: its result-view switch handles `terminal` and `diff` explicitly, and a `search` view falls through to the same dim generic body, reading the model-facing text from `this.result?.content`. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, the TUI output stays byte-identical to the pre-search-card fallback. The web frontend that renders the structured `files`/`paths` shape is a separate later PR; this PR is the backend contract and its two producers.
+A consumer without a dedicated `search` arm falls back to the same generic body and reads the model-facing text from the raw result. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, that fallback stays byte-identical to the pre-search-card path. The frontend that renders the structured `files`/`paths` shape is independent of this backend contract and its two producers.
 
 ## Alternatives considered
 
@@ -48,7 +48,7 @@ The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm:
 
 `grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-retained matches or paths — the same retention outcome the render consumes, so there is no second retention pass and no doubled search text on the wire. The serialized meta is bounded by `searchMetaMaxBytes`, so a broad search no longer persists an unbounded structured copy into the session log.
 
-A UI without a search card renders the raw `tool/result` content, so no consumer regresses, and the TUI stays byte-identical. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
+A UI without a search card renders the raw `tool/result` content, so no consumer regresses. A consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
 
 ## Testing
 

+ 3 - 3
.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 用一个带两种形状的视图而非两张卡片,因为两个工具是同一个视觉对象 —— 一个搜索结果 —— web 消费方先在一个 `card` 值上分支,再在 `shape` 上分支决定行布局。判别式 `shape` 让每个变体的字段保持非可选(matches 视图总有 `files`,paths 视图总有 `paths`),而不是一个所有形状相关字段都可选的单一接口。
 
-该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;那对每个消费方都是 no-op(TUI 本就回退到 `result.content`,web 回退读原始 `tool/result` 内容),却把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始 `tool/result` 内容。
+该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;但消费方的回退路径本就读取原始 `tool/result` 内容,因此这不会产生效果,却会把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始结果内容。
 
 卡片标签只在结果时存在。搜索调用保持为 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,所以 `SearchCallView` 能携带的东西不会比 generic 标题更多。这是与 terminal 卡片的不对称之处 —— terminal 的调用视图携带执行前就存在的命令、cwd、description;搜索的结构化内容只在 `execute` 之后才存在。
 
@@ -30,7 +30,7 @@ Status: implemented
 
 `SearchMeta` 的成员形状是对象字面量 `type` 别名,而非视图暴露的 `SearchFileMatches`/`SearchLineMatch` 接口,因为只有 type 别名可赋给 `presentationMeta` 返回的 `JsonValue` 索引签名;两者结构等价,所以投影值仍读回为 `SearchResultView`。
 
-TUI(`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:它的结果视图 switch 显式处理 `terminal` 与 `diff`,`search` 视图落入同一个变暗的 generic body,从 `this.result?.content` 读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以 TUI 输出与无 search 卡片的回退逐字节一致。渲染结构化 `files`/`paths` 形状的 web 前端是另一个后续 PR;本 PR 是后端契约及其两个生产者。
+没有专用 `search` 分支的消费方会回退到同一个 generic body,并从原始结果中读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以该回退与引入 search 卡片之前的路径逐字节一致。渲染结构化 `files`/`paths` 形状的前端独立于这个后端契约及其两个生产者。
 
 ## 考虑过的备选
 
@@ -48,7 +48,7 @@ TUI(`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:
 
 `grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已保留匹配或路径的一次有界投影 —— 与 render 消费的是同一份保留产出,所以没有第二次保留计算,线上也没有翻倍的搜索文本。序列化 meta 受 `searchMetaMaxBytes` 约束,所以宽泛搜索不再把无界的结构化副本持久化进会话日志。
 
-无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化,TUI 也逐字节一致。渲染结构化形状的 web 消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
+无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化。渲染结构化形状的消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
 
 ## 测试
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card.md
-2026-07-30-web-read-card.md: 1fb3d61a113d26f6daf023fc791f3638055b5be0
-2026-07-30-web-read-card.zh.md: 946bcca95bcc9bb50beb1ef22e77e4a21728b538
+2026-07-30-web-read-card.md: 26d1634b6be86666980e65f842de51c868c26efd
+2026-07-30-web-read-card.zh.md: 749177e93e8e3b2e34aed46d1fe99226395a6686

Разница между файлами не показана из-за своего большого размера
+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-read-card.md


Разница между файлами не показана из-за своего большого размера
+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md


+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card.md
-2026-07-30-web-result-card.md: deec27832aba2d5d868889f7306cbaef4f0b90b4
-2026-07-30-web-result-card.zh.md: 037e029332fbb665d90860d7e11c2fd117e6eb45
+2026-07-30-web-result-card.md: 838b13a7f3240c753e5cd1af6909389055c6352d
+2026-07-30-web-result-card.zh.md: 6e5170fcad82d709b050fb05e8efcfe955f20391

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-result-card.md

@@ -16,13 +16,13 @@ One tag with a `kind` discriminant, not two tags. Both calls are web retrieval a
 
 `presentationMeta` carries what render text cannot. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. For `web_search` the meta is the ONLY faithful route to `{url, title?, snippet?, publishedAt?}`: the render collapses those fields into one lossy free-text line, so a consumer cannot reparse them. For `web_fetch` the meta is a smaller but real gain: `url`/`statusCode` are recoverable from the deterministic `Fetched <url> (HTTP <n>)` header line, but `truncated` is the effective truncation — provider cap, pre-conversion source cut, or the deployment's `fetchMaxOutputChars` output cap — which a client cannot recompute because it does not know that cap. The fetch card and the model-facing text derive `truncated` from one shared `renderFetchOutput(result, maxOutputChars)` helper, so the card never disagrees with the footer the model saw. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s body is already markdown in the result content, so it is not duplicated into meta.
 
-Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content. The TUI does exactly this: it renders no structured web body, and its transcript renderer routes a `web` view's fallback content through the same dim Markdown path as a generic card's content (`packages/ui/tui/src/components/transcript.ts`, where both `render` and `renderBody` narrow the `generic` arm to `view.content` and give a `web` view the same `this.result?.content` fallback). Copying the result content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
+Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content, the same input a generic card consumes. Copying that content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
 
 `presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed.
 
 ## Consequences
 
-The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. The one observable change is that the `web_search`/`web_fetch` `tool/result` events now persist a `data.meta` payload (the `web-fetch` keyless snapshot is refreshed accordingly); the model-facing render text and the TUI presentation are unchanged (the TUI falls back to the same result content). The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer PR that renders it, delivered there. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
+The frontend consumer was a separate later PR: this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
 
 A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md

@@ -16,13 +16,13 @@ Status: implemented
 
 `presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched <url> (HTTP <n>)` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。
 
-两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容。TUI 正是如此:它不渲染结构化的 web 正文,其 transcript 渲染器把 `web` 视图的回退内容与 generic 卡片的内容路由进同一条 dim Markdown 路径(`packages/ui/tui/src/components/transcript.ts` 中 `render` 与 `renderBody` 都把 `generic` 分支收窄为 `view.content`,并给 `web` 视图相同的 `this.result?.content` 回退)。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
+两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容,这也是 generic 卡片消费的输入。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
 
 `presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。
 
 ## Consequences
 
-web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件现在持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照随之刷新);面向模型的 render 文本与 TUI 呈现不变(TUI 回退到相同的结果内容)。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费者 PR,在那里交付。任何做穷尽 switch 的现有 `ToolResultView` 消费者都必须新增一个 `web` 分支;TUI 并不穷尽 switch,无需新增。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
+前端消费方由后续独立 PR 交付:本次生产者变更新增契约分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
 
 未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
-2026-07-31-web-telemetry-default-mount.md: e9ec7d0cda37db44e753c9aee572763b7e24ada6
-2026-07-31-web-telemetry-default-mount.zh.md: 68b411d0668772ce81d7f323c2d286714a223ca4
+2026-07-31-web-telemetry-default-mount.md: c1525a44196991d5059969ea70af34501b199323
+2026-07-31-web-telemetry-default-mount.zh.md: f203ea1943387beda445bd81ac78fa2cc0471d45

+ 4 - 4
.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md

@@ -10,15 +10,15 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry
 
 ## Decision
 
-The shared `dsh` core (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so every surface — TUI, web, and headless — reports; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Each surface's exit path drains the queue: web/headless dispose on SIGINT/SIGTERM (headless gained those handlers in this change), and the TUI's normal exit runs `disposeRootAndExit` (root dispose, 5s bounded — above the ~1s drain ceiling configured here) while its `/resume` handoff disposes the root before `execve`.
+The shared `dsh` base (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so Web and headless report; the raw-config command also mounts it before applying its required deployment overlay. This is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving the backend's three-second shutdown deadline time to drain before the five-second launcher bound.
 
 | Ruling | Value | Rationale |
 |---|---|---|
-| Mount surface | base.cordis.yml (TUI + web + headless) | One deployment stance for every surface; per-surface divergence would need a reason, and none exists |
+| Mount surface | base.cordis.yml (raw config + Web + headless) | One deployment stance for every tree that loads the shared base; the raw overlay decides whether that deployment creates sessions |
 | Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs |
 | Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) |
 | Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
-| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` | Dispose must release within ~1s against an unreachable collector: timeoutMillis doubles as the per-attempt socket timeout and the retry deadline (1s effectively disables the SDK's 5-try backoff), and aligning batch size with the queue cap makes the drain a single batch; SDK defaults can stall 40s+ |
+| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | Ordinary unreachable-collector failure releases in ~1s: timeoutMillis is the per-attempt socket timeout and retry deadline, while one queue-sized batch avoids sequential drain multiplication. The DSH-owned 3s outer bound covers the SDK's preceding unbounded `forceFlush()` wait when the transport Promise never obtains a socket. |
 | Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth |
 | CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint |
 
@@ -30,7 +30,7 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl
 
 **A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat.
 
-**A `Promise.race` timeout backstop around exit.** Deferred: the parameter set already bounds the worst-case drain to ~1.5-3s (typically <100ms), measured SIGINT-to-exit 110ms-1.1s; the unbounded drip-feed-response risk stays under observation, and on real evidence the race lands inside the backend's `shutdown()` (never the coordinator — that would decide loss semantics for every backend).
+**A `Promise.race` timeout backstop around exit.** Originally deferred because the SDK parameters appeared to bound the backend's drain to ~1.5-3s (typically <100ms), with measured SIGINT-to-exit of 110ms-1.1s. A Linux sandbox reproduction later proved that `BatchLogRecordProcessor.shutdown()` can wait forever in `exporter.forceFlush()` before reaching its `exportTimeoutMillis`-bounded completion Promise. The [CLI shutdown fix](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) therefore adds both a three-second backend bound for that specific gap and a five-second process-level bound plus repeated-signal escape for the whole plugin tree.
 
 ## Consequences
 

+ 4 - 4
.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md

@@ -10,15 +10,15 @@ Status: implemented
 
 ## Decision
 
-`dsh` 共享核心(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此所有 surface——TUI、web、headless——都上报;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。各 surface 的退出路径都会排空队列:web/headless 在 SIGINT/SIGTERM 上 dispose(headless 的信号处理是本次补上的),TUI 的正常退出走 `disposeRootAndExit`(根 dispose,5s 兜底——高于此处配置的 ~1s drain 上界),其 `/resume` 移交也在 `execve` 前 dispose 根。
+`dsh` 共享 base(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此 Web 与 headless 都会上报;原始配置命令也会先挂载该行,再应用其必需的部署 overlay。这是**内部测试期的部署立场**——有 endpoint 就上报,用户可通过环境变量退出。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给后端 3 秒关闭截止时间完成排空。
 
 | 决策项 | 取值 | 理由 |
 |---|---|---|
-| 挂载面 | base.cordis.yml(TUI + web + headless) | 所有 surface 一个部署立场;按 surface 分化需要理由,而当前没有 |
+| 挂载面 | base.cordis.yml(原始配置 + Web + headless) | 所有加载共享 base 的配置树采用同一个部署立场;原始配置 overlay 决定该部署是否创建会话 |
 | endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 |
 | 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) |
 | 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 |
-| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` | collector 不可达时 dispose 必须 ~1s 内放行:timeoutMillis 同时是单次 socket 超时与重试 deadline(1s 等效关掉 SDK 5 次 backoff),批大小对齐队列上限使 drain 恒为单批;默认参数下最坏可卡 40s+ |
+| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | collector 不可达的常规故障会在约 1s 内放行:timeoutMillis 是单次 socket 超时与重试 deadline,使用与队列等大的单批可避免依次排空导致耗时倍增。由 DSH 管理的 3s 外层上限覆盖 SDK 先执行的无界 `forceFlush()` 等待,即传输 Promise 始终无法取得 socket 的情况。 |
 | 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 |
 | CI 隔离 | 全部 8 个 GitHub workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/snapshot/built smoke)不得向生产 endpoint 泄测试会话 |
 
@@ -30,7 +30,7 @@ Status: implemented
 
 **开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
 
-**退出时 `Promise.race` 兜底超时。** 暂缓:参数组合已把最坏 drain 压到 ~1.5-3s(典型 <100ms),实测 SIGINT→退出 110ms-1.1s;drip-feed 慢滴响应的无界等待风险留观,出现实证再在 backend `shutdown()` 内加 race(不放 coordinator——那会替所有 backend 决定丢失语义)。
+**退出时 `Promise.race` 兜底超时。** 最初暂缓,是因为 SDK 参数看似已经将后端排空耗时限制在约 1.5-3s(通常 <100ms),实测 SIGINT 到退出耗时 110ms-1.1s。后来在 Linux 沙箱中复现并证明,`BatchLogRecordProcessor.shutdown()` 可能在 `exporter.forceFlush()` 中永久等待,无法进入受 `exportTimeoutMillis` 限制的完成 Promise。因此,[CLI 关闭修复](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) 既为这一特定缺口增加 3 秒后端上限,也为整棵插件树增加 5 秒进程级上限和重复信号退出途径。
 
 ## Consequences
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: 360ab26ec3dfecf2841a012fda8947d6a84fdfec
-README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4
+README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
+README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01

+ 2 - 0
apps/cli/README.md

@@ -49,6 +49,8 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
 
 `dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
 
+Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
+
 Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
 
 New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.

+ 2 - 0
apps/cli/README.zh.md

@@ -49,6 +49,8 @@ dsh web --dump-config
 
 `dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
 
+Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
+
 两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑;headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
 
 新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。

+ 10 - 8
apps/cli/config/base.cordis.yml

@@ -111,17 +111,19 @@
 # process out (the launchers patch the row disabled; config cannot disable
 # a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
 # random UUID; delete the file to reset the identity) as the Resource's
-# user.id. The exporter/processor values bound the shutdown drain to ~1s
-# against an unreachable collector: exporter.timeoutMillis is both the
-# per-attempt socket timeout and the retry deadline (1s effectively
-# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
-# (both explicit) makes the drain a single batch, and exportTimeoutMillis
-# is the processor's own cap on that one export cycle — the second bound
-# when the exporter's clock alone does not fire. Every CLI exit path drains it
-# by disposing the root on SIGINT/SIGTERM.
+# user.id. The exporter/processor values normally bound the shutdown drain
+# to ~1s against an unreachable collector: exporter.timeoutMillis is both
+# the per-attempt socket timeout and the retry deadline (1s effectively
+# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
+# (both explicit) makes the drain a single batch. The SDK awaits
+# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
+# shutdownTimeoutMillis is the load-bearing outer bound when a transport
+# promise never settles. Every CLI exit path drains it by disposing the root
+# on SIGINT/SIGTERM.
 - id: telemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
+    shutdownTimeoutMillis: 3000
     exporter:
       url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
       compression: gzip

+ 12 - 19
apps/cli/src/headless.ts

@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import { AppCLIEntry } from './app-cli-entry.ts'
+import { createProcessShutdown } from './process-shutdown.ts'
 
 /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
 interface TurnOutcome {
@@ -21,12 +22,12 @@ interface TurnOutcome {
   reason: string
 }
 
-/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
-async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
+/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
+async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
   if (response.result.ok) return response.result.value
   const { code, message } = response.result.error
   process.stderr.write(`dsh: ${code}: ${message}\n`)
-  await dispose()
+  await shutdown()
   process.exit(1)
 }
 
@@ -82,23 +83,16 @@ export async function runHeadless(task: string): Promise<void> {
     port: 0,
   })
   const { ctx, port } = await entry.run()
-  const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
-  // Signal exits must still dispose the tree: the composition mounts
-  // exit-drained plugins (telemetry's queued tail and shutdown marker would
-  // otherwise be lost), and Node's default signal exit skips disposal.
-  let signalled = false
-  const disposeAndExit = (code: number): void => {
-    if (signalled) return
-    signalled = true
-    void dispose().finally(() => { process.exit(code) })
-  }
-  process.on('SIGTERM', () => { disposeAndExit(143) })
-  process.on('SIGINT', () => { disposeAndExit(130) })
+  // Normal completion and signals share one bounded drain. A signal received
+  // during that drain escalates immediately instead of becoming a no-op.
+  const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
+  process.on('SIGTERM', () => { shutdown.interrupt(143) })
+  process.on('SIGINT', () => { shutdown.interrupt(130) })
   // The headless session is web-observable while it runs (same composition).
   process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
   const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
 
-  const created = await unwrap(await api.sessions.create({}), dispose)
+  const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
 
   // Open the stream before prompting so no frame is lost — kept in this order
   // even though in-process delivery has no race, so the code survives a move
@@ -111,11 +105,10 @@ export async function runHeadless(task: string): Promise<void> {
     sessionId: created.sessionId,
     mode: 'queue',
     content: [{ type: 'text', text: task }],
-  }), dispose)
+  }), () => shutdown.shutdown(1))
 
   const outcome = await done
   process.stdout.write(outcome.text + '\n')
   abort.abort()
-  await dispose()
-  process.exit(outcome.reason === 'completed' ? 0 : 1)
+  await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
 }

+ 58 - 0
apps/cli/src/process-shutdown.ts

@@ -0,0 +1,58 @@
+/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
+
+/** Maximum grace allowed for the application tree to dispose before process exit. */
+export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
+
+/** Process-exit controller shared by normal completion and Unix signal handlers. */
+export interface ProcessShutdown {
+  /** Start or join graceful disposal before exiting with `code`. */
+  shutdown(code: number): Promise<void>
+  /** Start graceful disposal, or force exit when a shutdown is already running. */
+  interrupt(code: number): void
+}
+
+/**
+ * Create one process-exit controller around an application disposer.
+ * @param dispose - Whole-application teardown that resolves at quiescence.
+ * @param exit - Process exit boundary, replaceable by tests.
+ * @param timeoutMs - Grace before forced exit, replaceable by tests.
+ * @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
+ */
+export function createProcessShutdown(
+  dispose: () => Promise<void>,
+  exit: (code: number) => void = (code) => { process.exit(code) },
+  timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
+): ProcessShutdown {
+  let pending: Promise<void> | undefined
+  let timeout: ReturnType<typeof setTimeout> | undefined
+  let exited = false
+
+  const exitOnce = (code: number): void => {
+    if (exited) return
+    exited = true
+    /* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
+    if (timeout !== undefined) clearTimeout(timeout)
+    exit(code)
+  }
+
+  const shutdown = (code: number): Promise<void> => {
+    if (pending !== undefined) return pending
+    timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
+    pending = Promise.resolve().then(dispose).then(
+      () => { exitOnce(code) },
+      () => { exitOnce(code) },
+    )
+    return pending
+  }
+
+  return {
+    shutdown,
+    interrupt(code) {
+      if (pending !== undefined) {
+        exitOnce(code)
+        return
+      }
+      void shutdown(code)
+    },
+  }
+}

+ 4 - 8
apps/cli/src/web.ts

@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-tool-bash'
 import { AppCLIEntry } from './app-cli-entry.ts'
+import { createProcessShutdown } from './process-shutdown.ts'
 
 // The shipped base plus the Web application's overlay.
 const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
@@ -118,17 +119,12 @@ export async function runWeb(
   const { ctx, port: boundPort } = await entry.run()
   const resolvedLocalWebUrl = localWebUrl(ctx)
 
-  let exiting = false
-  const shutdown = (code: number): void => {
-    if (exiting) return
-    exiting = true
-    void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
-  }
+  const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
 
   // Install shutdown handling before publishing readiness: supervisors may
   // send a signal as soon as they observe the URL line.
-  process.on('SIGTERM', () => { shutdown(0) })
-  process.on('SIGINT', () => { shutdown(130) })
+  process.on('SIGTERM', () => { shutdown.interrupt(0) })
+  process.on('SIGINT', () => { shutdown.interrupt(130) })
 
   // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
   // must name an address the /api trust fence was configured with.

+ 18 - 0
apps/cli/tests/fixtures/never-dispose.mjs

@@ -0,0 +1,18 @@
+/** Test-only Cordis plugin whose disposer announces entry and never settles. */
+
+import { existsSync } from 'node:fs'
+
+/**
+ * Register a disposer that keeps process shutdown pending until it is forced.
+ * @param {import('cordis').Context} ctx - loader-mounted test plugin context.
+ */
+export function apply(ctx) {
+  const keepAlive = setInterval(() => {}, 60_000)
+  ctx.effect(() => async () => {
+    clearInterval(keepAlive)
+    const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
+    if (armFile === undefined || !existsSync(armFile)) return
+    process.stderr.write('dsh-test: never-dispose started\n')
+    await new Promise(() => {})
+  })
+}

+ 121 - 0
apps/cli/tests/headless-shutdown.e2e.ts

@@ -0,0 +1,121 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
+
+const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+const neverDisposePlugin = pathToFileURL(
+  fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
+).href
+
+const POSIX_HEADLESS_PTY_DRIVER = String.raw`
+import errno, json, os, pty, select, signal, sys, time
+node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
+env = os.environ.copy()
+env.update(json.loads(launch_env_json))
+pid, fd = pty.fork()
+if pid == 0:
+    os.chdir(cwd)
+    os.execvpe(node, [node, *json.loads(launch_args_json)], env)
+
+markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
+output = bytearray()
+marker_index = 0
+deadline = time.monotonic() + float(timeout_seconds)
+status = None
+while time.monotonic() < deadline:
+    ready, _, _ = select.select([fd], [], [], 0.05)
+    if ready:
+        try:
+            chunk = os.read(fd, 65536)
+        except OSError as error:
+            if error.errno != errno.EIO:
+                raise
+            chunk = b""
+        if chunk:
+            output.extend(chunk)
+    while marker_index < len(markers) and markers[marker_index] in output:
+        if marker_index == 0:
+            open(os.path.join(cwd, "shutdown-armed"), "w").close()
+        os.write(fd, b"\x03")
+        marker_index += 1
+    waited, candidate = os.waitpid(pid, os.WNOHANG)
+    if waited == pid:
+        status = candidate
+        break
+
+if status is None:
+    os.kill(pid, signal.SIGKILL)
+    _, status = os.waitpid(pid, 0)
+sys.stdout.buffer.write(output)
+if marker_index != len(markers):
+    sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
+    sys.exit(124)
+actual_exit = os.waitstatus_to_exitcode(status)
+if actual_exit != 130:
+    sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
+    sys.exit(125)
+`
+
+async function runHeadlessPtySmoke(): Promise<string> {
+  const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
+  try {
+    const home = join(cwd, '.dsh')
+    await mkdir(home, { recursive: true })
+    await writeFile(join(home, 'config.yaml'), [
+      '- insert:',
+      '    - id: never-dispose',
+      `      name: '${neverDisposePlugin}'`,
+      '',
+    ].join('\n'))
+    const launch = resolveExampleLaunch({
+      srcBin: dshBinScript,
+      configArgs: ['-p', 'never complete'],
+      tsconfigPath,
+      env: {
+        DSH_HOME: home,
+        DSH_AGENTS_HOME: join(cwd, '.agents'),
+        DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
+        DSH_TELEMETRY_DISABLED: '1',
+        DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
+      },
+    })
+    const timeoutMs = 15_000
+    const result = await execa('python3', [
+      '-c',
+      POSIX_HEADLESS_PTY_DRIVER,
+      launch.command,
+      JSON.stringify(launch.args),
+      JSON.stringify(launch.env),
+      cwd,
+      String(timeoutMs / 1_000),
+    ], {
+      stdin: 'ignore',
+      timeout: timeoutMs + 5_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+      stripFinalNewline: false,
+    })
+    if (result.timedOut) {
+      throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    if (result.failed) {
+      throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    return result.stdout
+  } finally {
+    await rm(cwd, { recursive: true, force: true })
+  }
+}
+
+describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
+  it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
+    const output = await runHeadlessPtySmoke()
+    expect(output).toContain('dsh: observing at ')
+    expect(output).toContain('dsh-test: never-dispose started')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+})

+ 131 - 0
apps/cli/tests/process-shutdown.spec.ts

@@ -0,0 +1,131 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  createProcessShutdown,
+  PROCESS_SHUTDOWN_TIMEOUT_MS,
+} from '../src/process-shutdown.ts'
+
+function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
+  let resolve!: () => void
+  let reject!: (error: Error) => void
+  const promise = new Promise<void>((accept, fail) => {
+    resolve = accept
+    reject = fail
+  })
+  return { promise, resolve, reject }
+}
+
+afterEach(() => {
+  vi.useRealTimers()
+  vi.restoreAllMocks()
+})
+
+describe('process shutdown', () => {
+  it('exits once after graceful disposal resolves or rejects', async () => {
+    const resolvedExit = vi.fn()
+    const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
+    await resolved.shutdown(0)
+    expect(resolvedExit).toHaveBeenCalledOnce()
+    expect(resolvedExit).toHaveBeenCalledWith(0)
+
+    const rejectedExit = vi.fn()
+    const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
+    await rejected.shutdown(1)
+    expect(rejectedExit).toHaveBeenCalledOnce()
+    expect(rejectedExit).toHaveBeenCalledWith(1)
+  })
+
+  it('uses process.exit as the default process boundary', async () => {
+    const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
+    const shutdown = createProcessShutdown(() => Promise.resolve())
+
+    await shutdown.shutdown(7)
+
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(7)
+  })
+
+  it('forces exit when graceful disposal reaches its bound', async () => {
+    vi.useFakeTimers()
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit)
+    const pending = shutdown.shutdown(0)
+
+    await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
+    expect(exit).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(1)
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(0)
+
+    disposal.resolve()
+    await pending
+    expect(exit).toHaveBeenCalledOnce()
+  })
+
+  it('honors a caller-supplied grace period', async () => {
+    vi.useFakeTimers()
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
+    const pending = shutdown.shutdown(0)
+
+    await vi.advanceTimersByTimeAsync(24)
+    expect(exit).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(1)
+    expect(exit).toHaveBeenCalledOnce()
+
+    disposal.resolve()
+    await pending
+  })
+
+  it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit)
+    const pending = shutdown.shutdown(0)
+
+    shutdown.interrupt(130)
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(130)
+
+    disposal.resolve()
+    await pending
+    expect(exit).toHaveBeenCalledOnce()
+  })
+
+  it('drains on the first signal and forces on the second signal', async () => {
+    const disposal = deferred()
+    const dispose = vi.fn(() => disposal.promise)
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(dispose, exit)
+
+    shutdown.interrupt(143)
+    await Promise.resolve()
+    expect(dispose).toHaveBeenCalledOnce()
+    expect(exit).not.toHaveBeenCalled()
+
+    shutdown.interrupt(130)
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(130)
+
+    disposal.resolve()
+    await shutdown.shutdown(0)
+    expect(exit).toHaveBeenCalledOnce()
+  })
+
+  it('coalesces normal shutdown calls without treating them as escalation', async () => {
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit)
+
+    const first = shutdown.shutdown(0)
+    const second = shutdown.shutdown(1)
+    expect(second).toBe(first)
+    expect(exit).not.toHaveBeenCalled()
+
+    disposal.resolve()
+    await first
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(0)
+  })
+})

+ 7 - 5
apps/web/tests/approval-composer.e2e.ts

@@ -77,12 +77,14 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
     const input = page.locator('textarea').first()
     await input.waitFor({ timeout: 10_000 })
 
-    // The composer's own text cap, measured on the live textarea before the
-    // takeover replaces it. The panel's scroll region must stop at the same
-    // height (the designer's requirement: one cap for the composer seat), and
-    // measuring it here keeps the assertion free of the px value itself.
+    // The composer's own text cap, measured on the live draft scrollport before
+    // the takeover replaces it — the box that carries the cap, while the
+    // textarea inside it is as tall as the whole draft. The panel's scroll
+    // region must stop at the same height (the designer's requirement: one cap
+    // for the composer seat), and measuring it here keeps the assertion free of
+    // the px value itself.
     await input.fill(CAP_PROBE)
-    const composerCap = await input.evaluate(el => el.clientHeight)
+    const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
     expect(composerCap).toBeGreaterThan(0)
     await input.fill('')
 

+ 215 - 136
apps/web/tests/composer-draft-scroll.e2e.ts

@@ -1,36 +1,31 @@
 // Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
-// GLYPHS, not just its caret.
+// GLYPHS AND ITS CARET AS ONE.
 //
 // The composer paints its text in two stacked layers (see
 // packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
 // `<textarea>` carries the value, the selection and the caret but renders its
 // own glyphs `color: transparent`, and every visible character is painted by the
 // `[data-input-backdrop]` div underneath it, which also carries the claim-token
-// highlight, the chips and the ghost hint. The backdrop is `position: absolute;
-// inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
-// browser links its scroll offset to the textarea's.
+// highlight, the chips and the ghost hint.
 //
-// So past the cap the textarea scrolled and the words did not: the caret walked
-// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
-// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
-// backdrop on every textarea `scroll`, which is the one event every way of
-// moving the box ends in.
+// Two layers can only stay together by moving together. They now do: both sit
+// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
+// tall as the whole draft — so one offset, applied by the browser, moves the
+// caret and the words in the same frame. Scrolling the textarea and assigning
+// its offset to the backdrop looks equivalent and is not: a wheel gesture is
+// composited off the main thread, so the assignment lands frames late and the
+// caret visibly flies ahead of the text it belongs to.
 //
-// Mirroring an offset is only correct while both layers can reach it, so the
-// geometry underneath is asserted here alongside the visible outcome: the
-// backdrop's trailing-line sentinel (a textarea reserves a line box for the
-// caret after a final newline; `pre-wrap` collapses one), and one wrap width
-// across all three layers (only the textarea scrolls, so only it can lose
-// width to a scrollbar that consumes layout space). Either breaks the extent
-// equality, and an unreachable offset clamps the glyphs below the caret.
+// That failure is what the same-task measurement below pins. Every metric here
+// is read through the caret's own coordinate frame — where the textarea puts
+// line n — against where the backdrop paints line n, because that difference is
+// the defect a user sees, and it is the one number a mirror between two boxes
+// cannot hold at zero.
 //
-// Only a real engine can show this. Scrolling is layout: jsdom reports
+// Only a real engine can show any of this. Scrolling is layout: jsdom reports
 // `scrollHeight === clientHeight` for every element and never scrolls one, so
-// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx has
-// to stub both offsets and can only prove the mirroring code path runs. What is
-// asserted here instead is the user-visible fact that path exists for — after
-// scrolling to the end of a long draft, the LAST line is the one on screen —
-// measured with a DOM Range over the backdrop's own text.
+// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can
+// only assert that one scrollport contains both layers.
 //
 // Zero model calls: a fresh workspace's blank session already carries a live
 // composer, and the scenario only types into it. A stray stream would fail loud
@@ -49,10 +44,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
 /**
  * Committed golden of the composer's two-layer scroll geometry. The change
- * alters no DOM and no accessible name, so the aria goldens the other scenarios
- * commit are byte-identical with and without it; this records the relations
- * instead, which makes a shift in the cap or in the layer coupling a reviewable
- * diff rather than an assertion someone has to reconstruct.
+ * alters no accessible name, so the aria goldens the other scenarios commit are
+ * byte-identical with and without it; this records the relations instead, which
+ * makes a shift in the cap or in the layer coupling a reviewable diff rather
+ * than an assertion someone has to reconstruct.
  */
 const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
 const MODE = webSnapshotMode()
@@ -69,41 +64,54 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
 }).join('\n')
 
 /**
- * A draft ending in a newline: the shape whose layer extents diverge without
- * the backdrop's trailing-line sentinel. A textarea reserves a line box for the
- * caret after a final newline; `white-space: pre-wrap` collapses a text node's
- * trailing newline and generates none, so the backdrop would come out exactly
- * one line shorter and the mirrored offset would clamp a line above the caret.
+ * A draft ending in a newline: the shape where the two layers reserve their
+ * final line box on different terms. A textarea keeps one for the caret after a
+ * final newline; `white-space: pre-wrap` collapses a text node's trailing
+ * newline and generates none. The hidden auto-grow mirror carries the newline
+ * and so decides the height for both, which is why the backdrop needs no
+ * padding of its own — but only a draft of this shape can show it.
  */
 const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
 
-/** The composer's two text layers as the browser lays them out. */
+/** The composer's text layers as the browser lays them out. */
 interface ComposerMetrics {
   /** True when the draft is taller than the capped box — the situation under test. */
   overflows: boolean
-  /** Visible height of the textarea's content box: the cap in pixels. */
+  /** Visible height of the scrollport's content box: the cap in pixels. */
   clientHeight: number
   /** Whole lines that fit in the visible box, at the composer's own line-height. */
   visibleLines: number
-  /** The textarea's scroll offset, which the caret and the selection follow. */
-  inputScrollTop: number
-  /** The backdrop's scroll offset, which every visible glyph follows. */
-  backdropScrollTop: number
-  /** True when the two layers agree — the coupling this scenario exists for. */
-  layersAgree: boolean
+  /** The composer's one scroll offset, which the caret and the glyphs both follow. */
+  scrollTop: number
+  /** Furthest that offset can go. */
+  scrollMax: number
+  /**
+   * Scrollable overflow the textarea holds on its own — 0, or a second offset
+   * exists that nothing keeps equal to this one.
+   */
+  inputScrollable: number
+  /**
+   * Distance between where the caret sits for a draft line and where the
+   * backdrop paints that line, in pixels. A fixed value (the difference between
+   * a line box's top and its glyph box's) is alignment; a value that CHANGES
+   * with the scroll offset is the defect — the words trailing the caret.
+   */
+  caretGlyphGap: number
+  /**
+   * How much that gap moves when the offset changes inside a single task: 0
+   * here, because one box carries both layers. Assigning one box's offset to
+   * another cannot be 0 — a scroll event is dispatched after the task that
+   * moved the box, so between the two there is a frame with the caret at the
+   * new offset and the glyphs at the old one.
+   */
+  gapShiftOnScroll: number
   /**
    * Top of the LAST draft line relative to the visible box's top, in pixels: at
-   * most `clientHeight` when that line is on screen. This is the reported
-   * symptom as a number — with the layers uncoupled the backdrop stays at offset
-   * 0, so the last line sits a full draft-height below the box.
+   * most `clientHeight` when that line is on screen.
    */
   lastLineOffset: number
   /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
   firstLineOffset: number
-  /** Furthest the textarea can scroll. */
-  inputMax: number
-  /** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
-  backdropMax: number
   /** Content width the textarea wraps at. */
   inputWrapWidth: number
   /** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
@@ -113,14 +121,16 @@ interface ComposerMetrics {
 }
 
 /**
- * Measure both composer layers in the page.
+ * Measure the composer's layers in the page, in the caret's coordinate frame.
  * @param page - the page under test.
- * @returns the two layers' offsets and where the draft's first and last lines sit.
+ * @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit.
  */
 function measureComposer(page: Page): Promise<ComposerMetrics> {
   return page.evaluate(({ first, last }) => {
     const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
     if (input === null) throw new Error('no live composer textarea in the DOM')
+    const scroll = input.closest<HTMLElement>('[data-input-scroll]')
+    if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
     const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
     if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
     // The hidden auto-grow mirror: the textarea's next sibling, and the layer
@@ -128,47 +138,50 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
     // two that carry glyphs.
     const mirror = input.nextElementSibling
     if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
-    const box = input.getBoundingClientRect()
     // The draft carries no chips or claim token, so the decoration walk emits it
-    // as one text node — the backdrop's first, ahead of the trailing-line
-    // sentinel React renders as a second one. Both markers live in that first
-    // node, which is what the Range below needs.
+    // as a single text node, which is what the Range below needs.
     const text = backdrop.firstChild
     if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
-    const offsetOf = (marker: string): number => {
+    const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
+    /** Where the backdrop paints the line holding `marker`, in viewport coordinates. */
+    const glyphTop = (marker: string): number => {
       const at = text.data.indexOf(marker)
       if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
       const range = document.createRange()
       range.setStart(text, at)
       range.setEnd(text, at + marker.length)
-      return range.getBoundingClientRect().top - box.top
+      return range.getBoundingClientRect().top
     }
-    const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
-    // Each layer's own maximum, probed by asking for an impossible offset and
-    // reading back what it clamped to, then restored. Reading scrollHeight -
-    // clientHeight instead would compute the maximum rather than observe it.
-    const restore = input.scrollTop
-    const restoreBackdrop = backdrop.scrollTop
-    input.scrollTop = 1e7
-    backdrop.scrollTop = 1e7
-    const inputMax = input.scrollTop
-    const backdropMax = backdrop.scrollTop
-    input.scrollTop = restore
-    backdrop.scrollTop = restoreBackdrop
+    const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop)
+    // Where the CARET sits on the draft's first line: the textarea lays its own
+    // (transparent) glyphs out from its border box, shifted by any offset it
+    // holds itself. Reading the caret's frame this way rather than the
+    // scrollport's is what makes the gap the user-visible quantity — it stays
+    // honest if the textarea ever starts scrolling on its own again.
+    const gap = (): number =>
+      Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first))
+    // The same-task probe: move the offset and re-read the gap before the task
+    // ends, which is before any scroll event could have run a listener.
+    const before = gap()
+    const restore = scroll.scrollTop
+    scroll.scrollTop = restore === 0 ? 120 : 0
+    const gapShiftOnScroll = Math.abs(gap() - before)
+    scroll.scrollTop = restore
+    const box = scroll.getBoundingClientRect()
     return {
-      inputMax,
-      backdropMax,
       inputWrapWidth: input.clientWidth,
       backdropWrapWidth: backdrop.clientWidth,
       mirrorWrapWidth: mirror.clientWidth,
-      overflows: input.scrollHeight > input.clientHeight,
-      clientHeight: input.clientHeight,
-      visibleLines: Math.floor(input.clientHeight / lineHeight),
-      inputScrollTop: input.scrollTop,
-      backdropScrollTop: backdrop.scrollTop,
-      layersAgree: input.scrollTop === backdrop.scrollTop,
-      lastLineOffset: offsetOf(last),
-      firstLineOffset: offsetOf(first),
+      overflows: scroll.scrollHeight > scroll.clientHeight,
+      clientHeight: scroll.clientHeight,
+      visibleLines: Math.floor(scroll.clientHeight / lineHeight),
+      scrollTop: scroll.scrollTop,
+      scrollMax: scroll.scrollHeight - scroll.clientHeight,
+      inputScrollable: input.scrollHeight - input.clientHeight,
+      caretGlyphGap: before,
+      gapShiftOnScroll,
+      lastLineOffset: glyphTop(last) - box.top,
+      firstLineOffset: glyphTop(first) - box.top,
     }
   }, { first: FIRST_MARKER, last: LAST_MARKER })
 }
@@ -179,43 +192,56 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
  * Absolute glyph coordinates are deliberately absent: they depend on font
  * metrics and would make the fixture fail on a machine that measures text
  * differently — a golden that needs re-recording per platform documents the
- * platform, not the change. What is recorded is the cap, the layer agreement,
- * and which lines are on screen, each a comparison that survives any layout
- * keeping the coupling.
+ * platform, not the change. What is recorded is the cap, the caret-to-glyph
+ * relation, and which lines are on screen, each a comparison that survives any
+ * layout keeping the coupling.
  * @param top - metrics with the draft scrolled to its start.
  * @param bottom - metrics with the draft scrolled to its end.
  * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
+ * @param pasted - metrics right after a long block was pasted at the draft's end.
  * @returns the golden body, without a trailing newline.
  */
-function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string {
+function renderGeometry(
+  top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
+): string {
   return [
-    '# Composer draft scrolling (14-line cap, two text layers)',
+    '# Composer draft scrolling (14-line cap, two text layers, one scrollport)',
     '',
     '## At the start of the draft',
     '',
     `- draft overflows the capped box: ${String(top.overflows)}`,
     `- visible lines: ${String(top.visibleLines)}`,
-    `- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
+    `- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`,
     `- all three layers wrap at one width: ${String(
       top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
     )}`,
-    `- textarea scroll offset: ${String(top.inputScrollTop)}px`,
-    `- glyph layer tracks it: ${String(top.layersAgree)}`,
+    `- scroll offset: ${String(top.scrollTop)}px`,
+    `- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`,
     `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
     `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
     '',
     '## Scrolled to the end of the draft',
     '',
-    `- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
-    `- glyph layer tracks it: ${String(bottom.layersAgree)}`,
+    `- offset moved: ${String(bottom.scrollTop > 0)}`,
+    `- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`,
+    `- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`,
     `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
     `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
     '',
     '## Draft ending in a newline, scrolled to the end',
     '',
-    `- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
-    `- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
-    `- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
+    `- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`,
+    `- the draft's own last line is on screen: ${String(
+      trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
+    )}`,
+    '',
+    '## Right after pasting a long block at the end',
+    '',
+    `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
+    `- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`,
+    `- the pasted block's last line is on screen: ${String(
+      pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
+    )}`,
   ].join('\n').trimEnd()
 }
 
@@ -251,17 +277,16 @@ describe('web e2e: composer draft scrolling', () => {
     // case below.
     await page.locator('textarea:enabled').first().hover()
     await page.mouse.wheel(0, -2000)
-    await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
+    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
     const metrics = await measureComposer(page)
     // The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
     // 14 x 24px lines). The count, not the pixels: it is the figma constant and
     // survives a device-pixel-ratio change.
     expect(metrics.visibleLines).toBe(14)
-    // Resting state: the draft's head is what a 40-line draft shows, and its
-    // tail is far below the box. Both layers sit at the origin, which is why the
-    // uncoupled build looks correct until something scrolls.
-    expect(metrics.inputScrollTop).toBe(0)
-    expect(metrics.layersAgree).toBe(true)
+    // One scrolling box: the textarea is as tall as the draft, so there is no
+    // second offset for the caret to hold while the glyphs hold another.
+    expect(metrics.inputScrollable).toBe(0)
+    expect(metrics.scrollTop).toBe(0)
     expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
     expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
     expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
@@ -270,19 +295,12 @@ describe('web e2e: composer draft scrolling', () => {
 
   it('lays out all three text layers at one wrap width', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
-    // The premise under the mirror, asserted rather than assumed. Only .input
-    // scrolls, so only .input can lose content width to a scrollbar that
-    // consumes layout space; a narrower .input wraps a long draft onto more
-    // lines, ends up taller, and its larger maximum makes the mirrored offset
-    // clamp below the caret. Measured on a standalone harness, an 8px width
-    // difference is worth 2 to 5 lines on a wrap-sensitive draft.
-    //
-    // This holds on the lane's engine and is what a regression would break —
-    // it is NOT vacuous: measured on the same app, WebKit reports 768 against
-    // 776 here, which is the divergence the Agent Note records as a
-    // pre-existing, engine-specific limitation. The mirror is unaffected there
-    // today because the extents still agree; this assertion is what would
-    // notice if the lane's engine ever moved into the same state.
+    // A layer that breaks lines somewhere else puts the words under the wrong
+    // caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
+    // draft. The three now share a containing block — the scrollport — so a
+    // scrollbar that consumes layout space costs them the same width; before,
+    // only the textarea scrolled, and WebKit reserved gutter space for it alone
+    // (768 against 776) while chromium and firefox did not.
     const metrics = await measureComposer(page)
     expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
     // The mirror decides the box height, so it belongs in the same equality —
@@ -292,66 +310,112 @@ describe('web e2e: composer draft scrolling', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('the glyphs cannot lag the caret: one task moves both', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag'))
+    // The reported symptom, isolated. A scroll offset changes and the caret's
+    // distance to its own glyphs is re-read before the task ends — before any
+    // `scroll` listener could have run. With the layers on one scrollport the
+    // browser moved both, so the distance is unchanged; with the glyph layer
+    // catching up in a listener it is off by the whole delta until a later
+    // frame, which is a caret flying away from its text mid-gesture.
+    const metrics = await measureComposer(page)
+    expect(metrics.gapShiftOnScroll).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
     const input = page.locator('textarea:enabled').first()
     await input.hover()
-    // One delta past the whole draft: the textarea clamps at its own end, and
-    // the wheel-chaining handler leaves it native because the box is not yet at
-    // its edge when the gesture starts (the chaining itself is owned by the
-    // unit spec).
+    const resting = (await measureComposer(page)).caretGlyphGap
+    // One delta past the whole draft: the box clamps at its own end, and the
+    // wheel-chaining handler leaves it native because the box is not yet at its
+    // edge when the gesture starts (the chaining itself is owned by the unit spec).
     await page.mouse.wheel(0, 2000)
-    await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
+    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
       .toBeGreaterThan(0)
     const metrics = await measureComposer(page)
-    // The coupling, stated directly.
-    expect(metrics.layersAgree).toBe(true)
+    // The caret is still on its own glyphs after the gesture.
+    expect(metrics.caretGlyphGap).toBe(resting)
     // The reported symptom, stated as what the user sees: the end of the draft
-    // is on screen and its beginning is not. On the uncoupled build the glyph
-    // layer stays at offset 0, so `lastLineOffset` is still a full draft below
-    // the box and `firstLineOffset` is still 0 — the text never moved.
+    // is on screen and its beginning is not.
     expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
     expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
     expect(metrics.firstLineOffset).toBeLessThan(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('typing at the end of a scrolled draft keeps the layers together', async () => {
+  it('typing at the end of a scrolled draft brings the caret back into view', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
-    // The other way the box moves. Typing at the caret — parked at the draft's
-    // end by the wheel gesture — scrolls it into view, which is a `scroll` like
-    // any other; this pins that an edit is not a separate case needing its own
-    // mirror, which is why one listener is the whole implementation.
+    // The other way the box moves, and the one that depends on the browser: the
+    // textarea no longer scrolls, so revealing the caret after an edit is a
+    // scroll-into-view that has to walk up to the scrollport. Scroll away from
+    // the caret first, so the edit has somewhere to bring it back from.
     const input = page.locator('textarea:enabled').first()
     await input.press('End')
+    await input.hover()
+    await page.mouse.wheel(0, -2000)
+    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
     await input.pressSequentially(' tail')
     const metrics = await measureComposer(page)
-    expect(metrics.layersAgree).toBe(true)
+    expect(metrics.scrollTop).toBeGreaterThan(0)
+    expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
+    expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('pasting a long block scrolls to the caret it leaves at the end', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
+    // The composer suppresses the native paste — the machine owns the draft and
+    // the undo log — and restores the caret programmatically, which reveals
+    // nothing on its own: measured in chromium and WebKit, the view stayed
+    // where it was while the caret sat at the end of the pasted block. The
+    // restore now scrolls it into view, and this is the case that proves it.
+    const input = page.locator('textarea:enabled').first()
+    await input.fill('one short line')
+    await input.press('End')
+    // A real `paste` event carrying real clipboard data, dispatched at the
+    // textarea: the same event a Cmd-V delivers, and it runs the same handler.
+    await input.evaluate((el, text) => {
+      const data = new DataTransfer()
+      data.setData('text/plain', text)
+      el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
+      // Ending in a newline is the shape the engines disagree on: the caret
+      // lands on a line with nothing on it, where chromium reports no client
+      // rects at all for the collapsed position.
+    }, `\n${DRAFT}\n`)
+    await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
+    // The restore lands one frame after the machine commits the draft, so the
+    // box overflows before it moves; waiting on the offset is waiting for the
+    // behavior itself, and its absence fails this poll.
+    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
+    const metrics = await measureComposer(page)
+    // The caret is at the end of what was pasted, so the draft's last line is
+    // what has to be on screen.
     expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
     expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
+    expect(metrics.gapShiftOnScroll).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
     // The layers reserve a final line box on different terms, so this shape is
-    // the one that separates equal extents from a mirror that clamps early.
+    // the one that separates a height every layer agrees on from a box measured
+    // one line short of the caret's own last position.
     const input = page.locator('textarea:enabled').first()
     await input.fill(DRAFT_TRAILING_NEWLINE)
     await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
-    const extents = await measureComposer(page)
-    // The invariant the sentinel exists for. Without it the textarea measured
-    // 652 against the backdrop's 628 — one 24px line apart.
-    expect(extents.backdropMax).toBe(extents.inputMax)
     await input.hover()
     await page.mouse.wheel(0, 4000)
     await expect.poll(async () => {
       const m = await measureComposer(page)
-      return m.inputScrollTop === m.inputMax
+      return m.scrollTop === m.scrollMax
     }, { timeout: 10_000 }).toBe(true)
     const bottom = await measureComposer(page)
-    // At the very bottom the glyphs are level with the caret, not a line behind.
-    expect(bottom.layersAgree).toBe(true)
+    // At the very bottom the glyphs are level with the caret, and the draft's
+    // own last line — the one before the empty final line — is on screen.
+    expect(bottom.gapShiftOnScroll).toBe(0)
     expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
     expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
     expect(tripwire.pageErrors).toEqual([])
@@ -365,11 +429,11 @@ describe('web e2e: composer draft scrolling', () => {
     await input.fill(DRAFT)
     await input.hover()
     await page.mouse.wheel(0, -2000)
-    await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
+    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
     const top = await measureComposer(page)
     await input.hover()
     await page.mouse.wheel(0, 2000)
-    await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
+    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
       .toBeGreaterThan(0)
     const bottom = await measureComposer(page)
     await input.fill(DRAFT_TRAILING_NEWLINE)
@@ -377,10 +441,25 @@ describe('web e2e: composer draft scrolling', () => {
     await page.mouse.wheel(0, 4000)
     await expect.poll(async () => {
       const m = await measureComposer(page)
-      return m.inputScrollTop === m.inputMax
+      return m.scrollTop === m.scrollMax
     }, { timeout: 10_000 }).toBe(true)
     const trailingNewline = await measureComposer(page)
-    await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
+    // The paste path, measured the way a user meets it: a short draft, the
+    // caret at its end, one long block pasted in.
+    await input.fill('one short line')
+    await input.press('End')
+    await input.evaluate((el, text) => {
+      const data = new DataTransfer()
+      data.setData('text/plain', text)
+      el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
+      // The ordinary shape — not ending in a newline — so the collapsed branch
+      // of the reveal keeps a real engine under it; the case above owns the
+      // after-newline branch.
+    }, `\n${DRAFT}`)
+    await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
+    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
+    const pasted = await measureComposer(page)
+    await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 

+ 54 - 4
apps/web/tests/sidebar-scrollbar.e2e.ts

@@ -106,6 +106,10 @@ interface ListMetrics {
   overflows: boolean
   /** Border-box width minus client width: the space the scrollbar takes out of the content area. */
   band: number
+  /** Distance from the scrollbar's right edge to the sidebar edge. */
+  scrollbarEdgeOffset: number
+  /** Distance from the first row background's right edge to the sidebar edge. */
+  rowEdgeInset: number
   /** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
   clientRight: number
   /** Border-box right edge in viewport coordinates. */
@@ -133,6 +137,8 @@ function measureList(page: Page): Promise<ListMetrics> {
     if (list === null) throw new Error('sidebar session list not in the DOM')
     const time = list.querySelector<HTMLElement>('[class*="time"]')
     if (time === null) throw new Error('no row relative-time element in the sidebar list')
+    const row = list.querySelector<HTMLElement>('[role="treeitem"]')
+    if (row === null) throw new Error('no row in the sidebar list')
     // Each indirection variable is resolved through its own throwaway probe
     // appended to the list: `var()` substitution then happens where the list
     // sits in the cascade, which is the claim, and `color` normalizes whatever
@@ -167,6 +173,9 @@ function measureList(page: Page): Promise<ListMetrics> {
     const style = getComputedStyle(list)
     const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
     const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
+    const listRect = list.getBoundingClientRect()
+    const sidebarEdge = list.parentElement?.getBoundingClientRect().right
+    if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
     return {
       gutter: style.scrollbarGutter,
       width: pseudoWidth,
@@ -177,9 +186,11 @@ function measureList(page: Page): Promise<ListMetrics> {
       token: resolve('--dsh-scrollbar-thumb'),
       hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
       overflows: list.scrollHeight > list.clientHeight,
-      band: list.getBoundingClientRect().width - list.clientWidth,
-      clientRight: list.getBoundingClientRect().left + list.clientWidth,
-      borderRight: list.getBoundingClientRect().right,
+      band: listRect.width - list.clientWidth,
+      scrollbarEdgeOffset: sidebarEdge - listRect.right,
+      rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
+      clientRight: listRect.left + list.clientWidth,
+      borderRight: listRect.right,
       timeRight: time.getBoundingClientRect().right,
       // The bar is drawn in the rightmost `barWidth` of the border box, whether
       // or not that space was reserved. Its width comes from the sheet where the
@@ -188,7 +199,28 @@ function measureList(page: Page): Promise<ListMetrics> {
       // absent. Taking the UA width as the fallback is what keeps the assertion
       // honest: assuming 0 there would report no occlusion precisely in the
       // state that has it.
-      timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
+      timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
+    }
+  })
+}
+
+/**
+ * Measure only overflow and row inset, which remain observable when every
+ * session is hidden under a collapsed workspace group.
+ * @param page - the page under test.
+ * @returns the list overflow state and first row's trailing inset.
+ */
+function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
+  return page.evaluate(() => {
+    const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
+    if (list === null) throw new Error('sidebar session list not in the DOM')
+    const row = list.querySelector<HTMLElement>('[role="treeitem"]')
+    if (row === null) throw new Error('no row in the sidebar list')
+    const sidebarEdge = list.parentElement?.getBoundingClientRect().right
+    if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
+    return {
+      overflows: list.scrollHeight > list.clientHeight,
+      rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
     }
   })
 }
@@ -222,6 +254,8 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
     `- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
     `- list overflows: ${String(metrics.overflows)}`,
     `- reserved band: ${String(metrics.band)}px`,
+    `- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
+    `- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
     `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
     `- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
     `- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -299,6 +333,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
     // drawn over it. Removing the declaration makes it exactly 0. The value
     // itself is not pinned — it tracks `scrollbar-width` and the platform.
     expect(metrics.band).toBeGreaterThan(0)
+    expect(metrics.scrollbarEdgeOffset).toBe(2)
+    expect(metrics.rowEdgeInset).toBe(12)
     // The reported symptom, stated directly: no part of the row's relative time
     // lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
     // covered part. Unlike the client-edge comparison below it does not go
@@ -317,6 +353,20 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('keeps the row background inset when overflow disappears', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
+    expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
+    const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
+    await bucket.click()
+    try {
+      await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
+      expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
+    } finally {
+      await expandSeededSessions(page)
+    }
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('renders the themed thumb through the WebKit path in both palettes', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
     const light = await measureList(page)

+ 15 - 9
apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md

@@ -1,25 +1,31 @@
-# Composer draft scrolling (14-line cap, two text layers)
+# Composer draft scrolling (14-line cap, two text layers, one scrollport)
 
 ## At the start of the draft
 
 - draft overflows the capped box: true
 - visible lines: 14
-- both layers share one scroll extent: true
+- the textarea holds no scroll offset of its own: true
 - all three layers wrap at one width: true
-- textarea scroll offset: 0px
-- glyph layer tracks it: true
+- scroll offset: 0px
+- caret and glyphs stay level when the offset changes: true
 - first draft line is on screen: true
 - last draft line is on screen: false
 
 ## Scrolled to the end of the draft
 
-- textarea moved: true
-- glyph layer tracks it: true
+- offset moved: true
+- caret sits on its own glyphs: true
+- caret and glyphs stay level when the offset changes: true
 - first draft line has scrolled out above: true
 - last draft line is on screen: true
 
 ## Draft ending in a newline, scrolled to the end
 
-- both layers share one scroll extent: true
-- glyph layer tracks the caret: true
-- last draft line is on screen: true
+- caret sits on its own glyphs: true
+- the draft's own last line is on screen: true
+
+## Right after pasting a long block at the end
+
+- the composer scrolled to the caret it left: true
+- caret and glyphs stay level when the offset changes: true
+- the pasted block's last line is on screen: true

+ 4 - 0
apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md

@@ -12,6 +12,8 @@
 - --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
 - list overflows: true
 - reserved band: 8px
+- scrollbar inset from the sidebar edge: 2px
+- row background inset from the sidebar edge: 12px
 - relative time covered by the bar: 0px
 - relative time ends inside the content area: true
 - content area ends before the border box: true
@@ -28,6 +30,8 @@
 - --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
 - list overflows: true
 - reserved band: 8px
+- scrollbar inset from the sidebar edge: 2px
+- row background inset from the sidebar edge: 12px
 - relative time covered by the bar: 0px
 - relative time ends inside the content area: true
 - content area ends before the border box: true

+ 5 - 4
docs/config-catalog.md

@@ -1219,10 +1219,9 @@ Requires: `sessions`
 
 ```ts config-catalog
 /**
- * Plugin configuration: two verbatim SDK option shapes plus nothing else.
- * `exporter.url` is the one field this package validates itself — required,
- * no default, must parse as an `http(s)` URL — because a missing endpoint
- * must fail at plugin load, not at first export.
+ * Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
+ * shutdown bound. The package validates its endpoint and shutdown deadline
+ * because both must fail at plugin load rather than at first export or exit.
  */
 export interface Config {
   /**
@@ -1240,6 +1239,8 @@ export interface Config {
    * which this plugin fills); the SDK owns and documents these knobs.
    */
   processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
+  /** Maximum time spent awaiting the SDK provider's complete shutdown path. */
+  shutdownTimeoutMillis?: number
 }
 ```
 

+ 39 - 24
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -88,11 +88,11 @@
   box-shadow: var(--dsw-shadow-lv2);
   font-size: 16px;
   line-height: 24px;
-  /* Elevated surface in dark, same as the menus: the textarea inside scrolls
-     once the composer hits its height cap, so the thumb takes the l2 pair.
-     Declared on the card because the elevation belongs to the surface, and the
-     custom properties inherit down to the textarea that actually scrolls (see
-     ui-theme styles/scrollbar.css for the rebinding contract). */
+  /* Elevated surface in dark, same as the menus: the draft scrollport inside
+     scrolls once the composer hits its height cap, so the thumb takes the l2
+     pair. Declared on the card because the elevation belongs to the surface,
+     and the custom properties inherit down to the box that actually scrolls
+     (see ui-theme styles/scrollbar.css for the rebinding contract). */
   --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
   --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
 }
@@ -112,8 +112,21 @@
   height: 0;
 }
 
-/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
-   (min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
+/* The draft's scrollport, and the ONLY scrolling box in the composer: the
+   caret is the textarea's and every visible glyph is the backdrop's, so the two
+   layers stay together only by riding one offset the browser applies to both at
+   once. Scrolling one box and assigning the offset to the other cannot hold —
+   a wheel gesture is composited off the main thread, so the assignment lands
+   frames late and the words visibly trail the caret. The 14-line cap lives here
+   because this is the box the cap describes. */
+.scroll {
+  max-height: var(--dsh-composer-text-max-height);
+  overflow-y: auto;
+}
+
+/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft
+   height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are
+   as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea
    MUST share font, line-height, padding and wrapping rules or heights diverge. */
 .grow {
   position: relative;
@@ -165,7 +178,11 @@
   width: 100%;
   height: 100%;
   resize: none;
-  overflow-y: auto;
+  /* Never a scroller of its own: it is as tall as the draft, so it has no
+     scrollable overflow to hold an offset that could differ from the glyphs'.
+     The browser still reveals the caret — the scroll-into-view walks up to
+     .scroll and moves both layers together. */
+  overflow: hidden;
   border: none;
   outline: none;
   background: transparent;
@@ -189,22 +206,24 @@
      share the stack, so placeholder advances agree by construction. */
   font-family: 'DshChipCell', var(--dsw-font-family);
   font-size: inherit;
+  /* Three consumers, not two: the mirror sizes the stack, the layers must break
+     lines identically, and the caret reveal parses this value to step one line
+     down for a caret that sits after a newline. That parse needs a length, so a
+     theme resolving this to `normal` would make the reveal a silent no-op. */
   line-height: inherit;
   white-space: pre-wrap;
   word-break: break-word;
   overflow-wrap: anywhere;
-  /* These three MUST wrap at one width, because InputBar mirrors a single
-     scroll offset between .input and .backdrop and a layer that wraps onto
-     more lines is taller, has a larger scroll maximum, and clamps the mirrored
-     offset below the caret. Only .input scrolls, so only .input can lose
-     content width to a scrollbar that consumes layout space.
-     `scrollbar-gutter: stable` here does NOT buy that guarantee and was
-     removed after measuring: WebKit applies it to overflow-y:auto but not to
-     the overflow:hidden layers, so it left .input at 768 against 776 — the
-     same gap it was meant to close — while costing chromium 8px of text width
-     unconditionally. The gap it would have closed is measured and recorded in
-     the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
-     closing it needs one geometry every engine agrees on, not this property. */
+  /* These three MUST wrap at one width: the mirror decides the box height
+     the other two are laid out in, and a glyph layer that breaks lines
+     elsewhere than the textarea puts the words under the wrong caret. They do
+     so by construction now that all three sit INSIDE .scroll — a scrollbar
+     that consumes layout space narrows the scrollport, which is their shared
+     containing block, so it costs all three the same width on every engine.
+     Scrolling the textarea itself is what used to break this, and no property
+     fixed it: WebKit reserved gutter space for the overflow-y:auto textarea
+     and not for the overflow:hidden layers beside it, leaving them 8px apart
+     (768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
 }
 
 /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
@@ -222,10 +241,6 @@
 .mirror {
   visibility: hidden;
   pointer-events: none;
-  /* 14-line cap, shared with the composer takeovers (declared on
-     ConversationRoot .composerSeat). */
-  max-height: var(--dsh-composer-text-max-height);
-  overflow: hidden;
 }
 
 /* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24

+ 132 - 74
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -63,7 +63,8 @@ export function InputBar({
   const draft = input?.draft ?? ''
   const empty = draft.trim() === ''
   const inputRef = useRef<HTMLTextAreaElement | null>(null)
-  const backdropRef = useRef<HTMLDivElement | null>(null)
+  const scrollRef = useRef<HTMLDivElement | null>(null)
+  const mirrorRef = useRef<HTMLDivElement | null>(null)
   // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
   // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
   const composingRef = useRef(false)
@@ -88,29 +89,101 @@ export function InputBar({
   const locked = disabled
   const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
 
-  // Unlock (mount / session switch) returns focus to the box.
+  // Scroll the draft scrollport the minimum that brings `caret` into view — the
+  // browser's own behavior for typing, performed for the paths where it does
+  // not act.
+  //
+  // The mirror is the caret's ruler: it renders the same draft at the same
+  // metrics and the same wrap width in the same stack (that is what makes it
+  // the height authority), so a Range collapsed at the caret's index reports
+  // where the caret is without a caret API.
+  const revealCaret = (caret: number): void => {
+    const scrollEl = scrollRef.current
+    const mirrorEl = mirrorRef.current
+    const text = mirrorEl?.firstChild
+    if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return
+    // A box that cannot scroll has nothing to reveal: the draft fits, so every
+    // caret is already in view and the assignment below would clamp to itself.
+    if (scrollEl.scrollHeight <= scrollEl.clientHeight) return
+    const at = Math.min(caret, text.data.length)
+    // A caret straight after a newline sits on a line with nothing on it to
+    // measure — the shape a trailing-newline draft ends in — and the engines
+    // disagree there: chromium returns NO client rects at all (an all-zero box,
+    // which would scroll the wrong way), firefox reports the line above, WebKit
+    // the right one. Measure the newline itself instead, which is the line the
+    // caret just left, and step one line down; that they all agree on.
+    const afterNewline = at > 0 && text.data[at - 1] === '\n'
+    const range = document.createRange()
+    range.setStart(text, afterNewline ? at - 1 : at)
+    if (afterNewline) range.setEnd(text, at)
+    else range.collapse(true)
+    const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0
+    const rect = range.getBoundingClientRect()
+    const box = scrollEl.getBoundingClientRect()
+    if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom
+    else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line
+  }
+
+  // Reveal the focus end of the current selection. Today's entry paths leave a
+  // collapsed selection, but honoring direction keeps a future range-preserving
+  // path from revealing its anchor instead of its focus.
+  const revealSelectionFocus = (el: HTMLTextAreaElement): void => {
+    // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
+    const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd
+    // oxlint-disable-next-line typescript/no-unnecessary-condition
+    revealCaret(caret ?? el.value.length)
+  }
+
+  // Unlock (mount / session switch) returns focus to the box, and owns the
+  // reveal that comes with it. `preventScroll` because this focus is ours, not
+  // a gesture: the textarea is as tall as the draft, so the browser's reveal
+  // would walk up to the conversation scrollport and move the transcript under
+  // a user who only switched session. That leaves the caret to us — the DOM is
+  // reused across sessions, so switching to a longer draft keeps the previous
+  // offset while the value swap puts the caret at the new draft's end, which is
+  // off screen (measured on all three engines: offset 0 with the caret 940px
+  // down). Suppress the walk, then reveal in our own box.
   useEffect(() => {
-    if (!locked) inputRef.current?.focus()
+    const el = inputRef.current
+    if (locked || el === null) return
+    el.focus({ preventScroll: true })
+    revealSelectionFocus(el)
   }, [locked, sessionId])
 
-  // Two DOM listeners on the textarea, one lifetime (it is never unmounted —
-  // the inert state renders the same element disabled).
-  //
-  // wheel — active conversation scrollport: chain the gesture. While the
-  // textarea (capped at 14 lines with overflow-y:auto) can still move in this
-  // direction, keep the native scroll; only at its own edge forward delta to
-  // the host so a short draft never traps the gesture and a long draft stays
-  // scrollable. Hero mounts have no host and keep native wheel scrolling.
-  //
-  // scroll — the backdrop paints every visible glyph (the textarea's own text
-  // is transparent) but is clipped, not scrolled, so it does not follow the
-  // textarea on its own: without this mirror a draft past the cap moves the
-  // caret while the words stay frozen in place. Every way the box moves ends
-  // in a `scroll` event, edits included (the caret is scrolled into view), and
-  // the layers share an extent, so a draft that shrinks past the offset clamps
-  // both to the same maximum — one listener covers the coupling.
+  // A persisted draft arrives AFTER the unlock effect: ConversationSession
+  // adopts it in its own mount effect, and a parent's mount effect runs after
+  // its children's. Reveal when the draft becomes non-empty so a restored long
+  // draft does not stay at its head with the caret at its end. This effect does
+  // not focus: send-clear, failed-send restore, and first-character transitions
+  // must not steal focus from another control the user moved to.
   useEffect(() => {
     const el = inputRef.current
+    if (locked || draft === '' || el === null) return
+    revealSelectionFocus(el)
+  }, [draft !== ''])
+
+  // Caret restore after an edit the composer performs itself. The machine owns
+  // the draft and the undo log, so paste and cut suppress the native edit and
+  // write the value through the machine — and a
+  // programmatic selection change reveals nothing: measured in chromium and
+  // WebKit, pasting a long block leaves the view where it was while the caret
+  // sits at the end of the draft. Native typing gets its reveal from the
+  // browser; these two have to ask for it, so they share one restore.
+  const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
+    requestAnimationFrame(() => {
+      el.setSelectionRange(caret, caret)
+      revealCaret(caret)
+    })
+  }
+
+  // Wheel chaining on the draft scrollport, one lifetime (it is never
+  // unmounted — the inert state renders the same element disabled). While the
+  // capped box can still move in this direction, keep the native scroll; only
+  // at its own edge forward the delta to the active conversation scrollport, so
+  // a short draft never traps the gesture and a long draft stays scrollable.
+  // Hero mounts have no host and keep native wheel scrolling.
+  useEffect(() => {
+    const el = scrollRef.current
     if (el === null) return
     const onWheel = (e: WheelEvent): void => {
       const host = el.closest('[data-conversation-scroll]')
@@ -121,16 +194,8 @@ export function InputBar({
       e.preventDefault()
       host.scrollTop += e.deltaY
     }
-    const onScroll = (): void => {
-      const backdropEl = backdropRef.current
-      if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
-    }
     el.addEventListener('wheel', onWheel, { passive: false })
-    el.addEventListener('scroll', onScroll, { passive: true })
-    return () => {
-      el.removeEventListener('wheel', onWheel)
-      el.removeEventListener('scroll', onScroll)
-    }
+    return () => { el.removeEventListener('wheel', onWheel) }
   }, [])
 
   const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
@@ -234,7 +299,7 @@ export function InputBar({
     e.clipboardData.setData('text/plain', text)
     if (cut && !machineBusy && !locked) {
       keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
-      requestAnimationFrame(() => { el.setSelectionRange(start, start) })
+      restoreCaret(el, start)
     }
     void slice
   }
@@ -253,7 +318,7 @@ export function InputBar({
     // land (paste-upgrade). The DOM layer only starts the transaction.
     keyboard.pasteBegin(text, sel)
     const caret = sel.start + text.length
-    requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
+    restoreCaret(el, caret)
     keyboard.track(keyboard.snapshot.draft, caret)
   }
 
@@ -264,10 +329,13 @@ export function InputBar({
     void e
   }
 
-  // Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
+  // Button presses steal focus from the textarea; suppress at mousedown so
+  // typing continues seamlessly. `preventScroll` for the same reason as the
+  // unlock effect, and with no reveal of its own: the caret has not moved, and
+  // the next keystroke gets the browser's native one.
   const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
     e.preventDefault()
-    inputRef.current?.focus()
+    inputRef.current?.focus({ preventScroll: true })
   }
 
   const onToggleCommandMenu = (): void => {
@@ -369,22 +437,6 @@ export function InputBar({
       const displayHint = translated !== hintKey ? translated : deco.hint
       backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
     }
-    // Trailing-line sentinel, the same one the mirror div carries and for the
-    // same reason: a textarea reserves a line box for the caret after a final
-    // newline, while `white-space: pre-wrap` collapses a text node's trailing
-    // newline and generates none. Without it a draft ending in a newline makes
-    // the backdrop exactly one line SHORTER than the textarea, so mirroring the
-    // offset at the very bottom clamps and the glyphs sit a line behind the
-    // caret. The extra newline is absorbed by that same collapse when the draft
-    // does not end in one, so it costs no height in the ordinary case.
-    //
-    // The mirror only fails one way — a backdrop SHORTER than the textarea
-    // clamps the assignment, while a taller one takes every offset exactly and
-    // hides the surplus below the clip. That is why the ghost hint needs no
-    // handling of its own: it can only add content after the draft and before
-    // this sentinel, never remove a line box, so it moves the pair to equal or
-    // to the safe side.
-    backdrop.push('\n')
   }
 
   return (
@@ -402,32 +454,38 @@ export function InputBar({
       <div className={css.card} data-composer-card>
         {overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
         {accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
-        {/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
-            (min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
-            rows by '\n' cannot see soft wraps. */}
-        <div className={css.grow}>
-          <div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
-          <textarea
-            ref={inputRef}
-            className={css.input}
-            value={draft}
-            disabled={locked}
-            readOnly={machineBusy}
-            data-phase={input?.phase ?? 'inert'}
-            placeholder={placeholder ?? (disabled
-              ? t('placeholder.unavailable')
-              : planActive ? t('placeholder.plan') : t('placeholder.default'))}
-            rows={2}
-            onChange={onChange}
-            onKeyDown={onKeyDown}
-            onSelect={onSelect}
-            onCopy={(e) => { onCopyOrCut(e, false) }}
-            onCut={(e) => { onCopyOrCut(e, true) }}
-            onPaste={onPaste}
-            onCompositionStart={onCompositionStart}
-            onCompositionEnd={onCompositionEnd}
-          />
-          <div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
+        {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
+            stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
+            absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
+            lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the
+            glyphs to the backdrop, so they can only stay together by moving together: one scroll
+            offset the browser applies to both layers at once, never a JS mirror between two boxes,
+            which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
+        <div ref={scrollRef} className={css.scroll} data-input-scroll>
+          <div className={css.grow}>
+            <div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
+            <textarea
+              ref={inputRef}
+              className={css.input}
+              value={draft}
+              disabled={locked}
+              readOnly={machineBusy}
+              data-phase={input?.phase ?? 'inert'}
+              placeholder={placeholder ?? (disabled
+                ? t('placeholder.unavailable')
+                : planActive ? t('placeholder.plan') : t('placeholder.default'))}
+              rows={2}
+              onChange={onChange}
+              onKeyDown={onKeyDown}
+              onSelect={onSelect}
+              onCopy={(e) => { onCopyOrCut(e, false) }}
+              onCut={(e) => { onCopyOrCut(e, true) }}
+              onPaste={onPaste}
+              onCompositionStart={onCompositionStart}
+              onCompositionEnd={onCompositionEnd}
+            />
+            <div ref={mirrorRef} aria-hidden className={css.mirror} data-input-mirror>{`${draft}\n`}</div>
+          </div>
         </div>
         <div className={css.row}>
           <div className={css.tools}>

+ 162 - 33
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -4,7 +4,7 @@
 // semantics (input stays free; primary turns stop), the machine pending lock,
 // decoration backdrop, error/notice strips, and the focus-keeping mousedown.
 
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,6 +18,18 @@ import { zh } from '../src/client/locales.ts'
 
 afterEach(cleanup)
 
+// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
+// is absent — and the composer measures the caret with one when it restores the
+// selection after an edit it performed itself. Every case here runs against a
+// zero rect; the reveal case below substitutes its own and restores this one.
+const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
+Range.prototype.getBoundingClientRect = ZERO_RECT
+
+// Read through the descriptor so the native method is never referenced unbound;
+// the reveal case below wraps it to record what it was asked to measure.
+const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
+  .value as (this: Range, node: Node, offset: number) => void
+
 const SCTX = {} as ClientContext
 const SID = 's1' as SessionId
 
@@ -321,7 +333,7 @@ describe('running and lock semantics (queue cut 1)', () => {
     expect((textarea).value).toBe('typed')
   })
 
-  it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
+  it('wheel over a non-overflowing draft forwards to the conversation host', () => {
     const host = document.createElement('div')
     host.setAttribute('data-conversation-scroll', '')
     Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
@@ -337,17 +349,18 @@ describe('running and lock semantics (queue cut 1)', () => {
     }
   })
 
-  it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
+  it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
     const host = document.createElement('div')
     host.setAttribute('data-conversation-scroll', '')
     Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
     const { view, textarea } = bench()
     host.appendChild(view.container)
     document.body.appendChild(host)
-    Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
-    Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
+    const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
+    Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
+    Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
     let scrollTop = 150
-    Object.defineProperty(textarea, 'scrollTop', {
+    Object.defineProperty(scrollport, 'scrollTop', {
       configurable: true,
       get: () => scrollTop,
       set: (value: number) => { scrollTop = value },
@@ -371,35 +384,151 @@ describe('running and lock semantics (queue cut 1)', () => {
     }
   })
 
-  it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
+  it('the caret layer and the glyph layer ride one scrollport', () => {
     const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
+    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
     const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
-    Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
-    Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
-    // A scrolled draft: the textarea moves, the clipped backdrop must follow.
-    textarea.scrollTop = 120
-    fireEvent.scroll(textarea)
-    expect(backdrop.scrollTop).toBe(120)
-    // Every later move tracks too, including back to the top — a one-shot
-    // mirror would leave the glyphs parked at the first offset it saw.
-    textarea.scrollTop = 0
-    fireEvent.scroll(textarea)
-    expect(backdrop.scrollTop).toBe(0)
-  })
-
-  it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
-    // jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
-    // (the browser scenario owns that); what is checkable is that the backdrop's
-    // text is the draft plus exactly one newline. A textarea reserves a line box
-    // after a final newline and `pre-wrap` collapses one, so without the
-    // sentinel a draft ending in a newline leaves the backdrop a line short and
-    // the mirrored offset clamps.
-    const withNewline = bench({ draft: 'alpha\nbeta\n' })
-    const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
-    expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
-    const withoutNewline = bench({ draft: 'alpha\nbeta' })
-    const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
-    expect(plain.textContent).toBe('alpha\nbeta\n')
+    // The caret is the textarea's and every visible glyph is the backdrop's, so
+    // one box has to carry both or an offset can exist in one and not the other.
+    // jsdom has no layout and loads no stylesheet — which box scrolls is the
+    // browser scenario's to assert; what is checkable here is that the
+    // scrollport element holds both layers.
+    expect(scroll.contains(textarea)).toBe(true)
+    expect(scroll.contains(backdrop)).toBe(true)
+    // The glyph layer carries the draft and nothing else: with one scrollport
+    // it no longer pads its own height to match a second box's scroll extent.
+    expect(backdrop.textContent).toBe('line\n'.repeat(40))
+  })
+
+  it('an edit the composer performs itself scrolls the caret back into view', async () => {
+    // Paste and cut suppress the native edit, so no engine reveals the caret
+    // for them. jsdom has no layout: the rects are stubbed,
+    // and what is asserted is the arithmetic — minimal scroll, in both
+    // directions, and nothing at all for a caret already inside the box.
+    const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
+    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
+    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
+    expect(mirror.firstChild).toBeInstanceOf(Text)
+    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
+    // jsdom reports scrollHeight === clientHeight for every element, which is
+    // the composer's own "nothing to reveal" case; a scrollable box is what
+    // puts the reveal on the table at all.
+    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
+    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
+    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
+    onTestFinished(() => {
+      Range.prototype.getBoundingClientRect = ZERO_RECT
+      Range.prototype.setStart = NATIVE_SET_START
+    })
+    // Which layer the caret is measured against, and at which index: the stub
+    // records `setStart` so a helper that measured the backdrop instead, or
+    // always collapsed at 0, fails here rather than only in the browser lane.
+    let measured: { node: Node; offset: number } | null = null
+    Range.prototype.setStart = function setStart(node: Node, offset: number): void {
+      measured = { node, offset }
+      NATIVE_SET_START.call(this, node, offset)
+    }
+    const caretAt = (top: number): void => {
+      Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
+    }
+    const settle = async (): Promise<void> => {
+      await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
+    }
+    // Pasted text lands below the fold: scroll down by exactly the overshoot.
+    caretAt(500)
+    fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
+    await settle()
+    expect(scroll.scrollTop).toBe(88) // 524 - 436
+    // Measured on the mirror's own text, at the index the paste left the caret
+    // (an empty draft's selection start, 0, plus the pasted length).
+    expect(measured!.node).toBe(mirror.firstChild)
+    expect(measured!.offset).toBe('pasted'.length)
+    // A caret already inside the box does not move it.
+    caretAt(200)
+    fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
+    await settle()
+    expect(scroll.scrollTop).toBe(88)
+    // Above the fold (a cut can leave it there): scroll back up.
+    caretAt(60)
+    fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
+    await settle()
+    expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
+    // A caret straight after a newline has nothing on its line to measure, so
+    // the newline it just left is measured instead and one line is added.
+    // chromium reports no client rects at all for the collapsed position.
+    mirror.style.lineHeight = '24px'
+    caretAt(500)
+    fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
+    await settle()
+    // The four pastes accumulate at the draft's head, so the caret is at the
+    // end of what they inserted — and the measured index is the newline before it.
+    expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
+    expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
+  })
+
+  it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
+    // The composer DOM is reused across sessions, so the previous session's
+    // offset survives while the value swap puts the caret at the new draft's
+    // end. `preventScroll` keeps the browser from revealing it through the
+    // conversation scrollport, which leaves the reveal to the effect itself.
+    const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
+    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
+    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
+    onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
+    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
+    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
+    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
+    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
+    Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
+    // The draft ends in a newline, so the reveal takes the after-newline path
+    // and needs a resolvable line-height (jsdom computes `normal`).
+    mirror.style.lineHeight = '24px'
+    // Which index the effect reveals at, not merely that it scrolled: a
+    // revealCaret(0) would land the same offset without this.
+    onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
+    let measured: { node: Node; offset: number } | null = null
+    Range.prototype.setStart = function setStart(node: Node, offset: number): void {
+      measured = { node, offset }
+      NATIVE_SET_START.call(this, node, offset)
+    }
+    const focused: (boolean | undefined)[] = []
+    textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
+    textarea.setSelectionRange(textarea.value.length, textarea.value.length)
+    act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
+    expect(focused).toEqual([true])
+    expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
+    // The draft ends in a newline, so the rule measures that newline: the
+    // caret's own index is the mirror text's length minus its sentinel.
+    expect(measured!.node).toBe(mirror.firstChild)
+    expect(measured!.offset).toBe(textarea.value.length - 1)
+  })
+
+  it('a persisted draft adopted after mount gets its caret revealed too', () => {
+    // ConversationSession seeds the stored draft in its own mount effect, which
+    // runs after this component's: the first reveal measures an empty mirror,
+    // so the draft's arrival has to run it again without reclaiming focus.
+    const { view, textarea, shell } = bench()
+    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
+    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
+    // The restored draft ends in a newline, so the reveal takes the
+    // after-newline path and needs a resolvable line-height (jsdom says `normal`).
+    mirror.style.lineHeight = '24px'
+    onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
+    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
+    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
+    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
+    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
+    Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
+    const other = document.createElement('input')
+    document.body.appendChild(other)
+    onTestFinished(() => { other.remove() })
+    other.focus()
+    expect(scroll.scrollTop).toBe(0)
+    act(() => { shell.setDraft('restored\n'.repeat(40)) })
+    expect(document.activeElement).toBe(other)
+    // The caret the machine left at the draft's end, revealed once the draft exists.
+    expect(textarea.selectionStart).toBe(textarea.value.length)
+    expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
   })
 
   it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {

+ 10 - 3
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -7,10 +7,11 @@
    mid-slide. */
 
 .root {
+  --dsh-sidebar-inline-padding: 12px;
   display: flex;
   flex-direction: column;
   height: 100%;
-  padding: 6px 12px;
+  padding: 6px var(--dsh-sidebar-inline-padding);
   box-sizing: border-box;
   background: var(--dsw-specific-sidebar-fill);
   color: var(--dsw-alias-label-primary);
@@ -189,16 +190,22 @@
   max-width: 0;
 }
 
-/* Region seat: always mounted so the foot never moves; the browser inside
-   handles its own wide/rail content. */
+/* Region seat: always mounted so the foot never moves. Its trailing margin
+   cancels the wide shell inset so the nested scrollbar can sit at the sidebar
+   edge; the browser restores that inset inside its own rows. */
 .regionArea {
   flex: 1;
   min-height: 0;
   display: flex;
   flex-direction: column;
+  margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
   overflow: hidden;
 }
 
+.collapsed .regionArea {
+  margin-right: 0;
+}
+
 /* Foot seat: a pure layout socket pinned under the region; the ui-settings
    trigger row inside owns its own geometry (49px wide row / 36px rail
    circle) and hover chrome. */

+ 38 - 0
packages/client/ui-sidebar/tests/sidebar-styles.spec.ts

@@ -0,0 +1,38 @@
+/** Sidebar shell inset contract shared with the nested workspace browser. */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const css = readFileSync(fileURLToPath(new URL('../src/client/SidebarRoot.module.css', import.meta.url)), 'utf8')
+
+/**
+ * Declarations of one exact selector, keyed by property.
+ * @param selector - exact selector text.
+ * @returns the normalized declarations, or undefined when absent.
+ */
+function declarations(selector: string): Map<string, string> | undefined {
+  const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
+  for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
+    if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
+    const found = new Map<string, string>()
+    for (const part of body.split(';')) {
+      const colon = part.indexOf(':')
+      if (colon === -1) continue
+      found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
+    }
+    return found
+  }
+  return undefined
+}
+
+describe('SidebarRoot.module.css inset', () => {
+  it('shares and cancels the wide shell trailing padding structurally', () => {
+    const root = declarations('.root')
+    expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
+    expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
+    expect(declarations('.regionArea')?.get('margin-right')).toBe(
+      'calc(-1 * var(--dsh-sidebar-inline-padding))',
+    )
+    expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
+  })
+})

+ 30 - 28
packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css

@@ -4,10 +4,19 @@
    rail state renders only the two 36x36 icon controls. */
 
 .root {
+  --dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
+  --dsh-session-list-scrollbar-width: 8px;
+  --dsh-session-list-scrollbar-offset: 2px;
   flex: 1;
   min-height: 0;
   display: flex;
   flex-direction: column;
+  box-sizing: border-box;
+  padding-right: var(--dsh-session-list-edge-inset);
+}
+
+.root.rail {
+  padding-right: 0;
 }
 
 .iconButton {
@@ -167,9 +176,14 @@
   min-height: 0;
   display: flex;
   flex-direction: column;
+  margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
   overflow: hidden;
 }
 
+.rail .listArea {
+  margin-right: 0;
+}
+
 /* Relative for the bottom fade overlay. */
 .treeBody {
   flex: 1;
@@ -184,7 +198,7 @@
 .fade {
   position: absolute;
   left: 0;
-  right: 0;
+  right: var(--dsh-session-list-edge-inset);
   bottom: 0;
   height: 72px;
   background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
@@ -200,29 +214,28 @@
   from { opacity: 0; }
 }
 
-/* List: the only scrolling region. Block, not a flex column: as flex items
-   the 54/34 rows would shrink under content overflow; block children keep
-   their design heights and the 4px rhythm rides margins instead of gap. */
+/* List: the only scrolling region. Block children keep their design heights
+   under content overflow. The 2px edge offset, stable 8px themed scrollbar,
+   and remaining padding equal the shell's right inset, with or without
+   overflow, so moving the bar does not move the rows. */
 .list {
   flex: 1;
   min-height: 0;
   overflow-y: auto;
+  margin-right: var(--dsh-session-list-scrollbar-offset);
+  padding-right: calc(
+    var(--dsh-session-list-edge-inset)
+    - var(--dsh-session-list-scrollbar-width)
+    - var(--dsh-session-list-scrollbar-offset)
+  );
   padding-bottom: 12px;
-  /* Row trailing content (the relative time, and the hover action buttons
-     that replace it) sits flush against the row's 8px right padding, so an
-     overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
-     the rows instead of on top of them; `stable` holds the reservation when
-     the list is short enough not to scroll, so expanding a group does not
-     shift every row left. */
   scrollbar-gutter: stable;
 }
 
-.list > [role='treeitem'] + [role='treeitem'] {
-  margin-top: 4px;
-}
-
-.searchTree > [role='treeitem'] + [role='treeitem'] {
-  margin-top: 4px;
+.flatList > * + *,
+.searchTree > [role='treeitem'] + [role='treeitem'],
+.groupSection > * + * {
+  margin-top: 2px;
 }
 
 .searchStatus,
@@ -237,22 +250,11 @@
   color: var(--dsw-alias-label-secondary);
 }
 
-/* One workspace section: header row + expanded session run. Rows inside
-   keep the former flat-list 4px gap as sibling margins; the inter-group
-   breathing room (figma 133:7661 batch separator, 20px after an expanded
-   run) rides the NEXT section's top margin so the last group adds none. */
-.groupSection > * + * {
-  margin-top: 4px;
-}
-
+/* One workspace section: header row + a compact expanded session run. */
 .groupSection + .groupSection {
   margin-top: 4px;
 }
 
-.groupSection:has([aria-expanded='true']) + .groupSection {
-  margin-top: 20px;
-}
-
 .empty {
   padding: 16px 12px;
   color: var(--dsw-alias-label-tertiary);

+ 1 - 1
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -238,7 +238,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
   const now = Date.now()
   return (
     <div className={clsx(css.treeBody, css.wide)}>
-      <div className={css.list} role="tree" aria-label={t('section.sessions')}>
+      <div className={clsx(css.list, css.flatList)} role="tree" aria-label={t('section.sessions')}>
         {rows.length === 0 && (
           <div className={css.empty}>{t('empty.none')}</div>
         )}

+ 45 - 22
packages/client/ui-workspace/tests/browser-styles.spec.ts

@@ -1,8 +1,7 @@
 /**
- * WorkspaceBrowser scroll-region style contract, asserted against the CSS text
- * on disk: the session list reserves its scrollbar gutter so the scrollbar
- * cannot overlay row trailing content, and reserves it whether or not the list
- * currently overflows so expanding a group does not shift rows sideways.
+ * WorkspaceBrowser spacing contract, asserted against the CSS text on disk:
+ * row fills share the shell's trailing inset, the stable scrollbar counts
+ * inside it, and flat, grouped, and search views keep their intended rhythm.
  */
 import { readFileSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
@@ -11,38 +10,62 @@ import { describe, expect, it } from 'vitest'
 const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
 
 /**
- * Declarations of one class rule, keyed by property with whitespace collapsed.
+ * Declarations of one selector rule, keyed by property with whitespace collapsed.
  * Declaration order and trailing semicolons are normalized away.
- * @param className - local class name, without the leading dot.
+ * @param selector - one exact selector, including a leading dot for local classes.
  * @returns the rule's declarations, or undefined when no such rule exists.
  */
-function declarations(className: string): Map<string, string> | undefined {
+function declarations(selector: string): Map<string, string> | undefined {
   const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
-  const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
-  if (match === null) return undefined
-  const found = new Map<string, string>()
-  // The body group is unconditional in the pattern; the fallback only satisfies
-  // noUncheckedIndexedAccess.
-  for (const part of (match[2] ?? '').split(';')) {
-    const colon = part.indexOf(':')
-    if (colon === -1) continue
-    found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
+  for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
+    if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
+    const found = new Map<string, string>()
+    for (const part of body.split(';')) {
+      const colon = part.indexOf(':')
+      if (colon === -1) continue
+      found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
+    }
+    return found
   }
-  return found
+  return undefined
 }
 
 describe('WorkspaceBrowser.module.css list', () => {
-  const list = declarations('list')
+  const root = declarations('.root')
+  const listArea = declarations('.listArea')
+  const list = declarations('.list')
 
   it('is the scrolling region', () => {
     expect(list).toBeDefined()
     expect(list!.get('overflow-y')).toBe('auto')
   })
 
-  it('reserves the scrollbar gutter unconditionally', () => {
-    // Row trailing content sits flush against the row's right padding, so an
-    // overlay scrollbar covers it. `stable` keeps the reservation when the list
-    // is short enough not to scroll, so expanding a group does not shift rows.
+  it('counts the themed scrollbar inside the shell trailing inset', () => {
+    expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)')
+    expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
+    expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px')
+    expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
+    expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))')
+    expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)')
+    expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)')
+    expect(list?.get('padding-right')).toBe([
+      'calc(',
+      'var(--dsh-session-list-edge-inset)',
+      '- var(--dsh-session-list-scrollbar-width)',
+      '- var(--dsh-session-list-scrollbar-offset)',
+      ')',
+    ].join(' '))
+    expect(declarations('.list::-webkit-scrollbar')).toBeUndefined()
+  })
+
+  it('reserves the scrollbar whether or not the list overflows', () => {
     expect(list!.get('scrollbar-gutter')).toBe('stable')
   })
+
+  it('keeps 2px between rows and 4px between workspace groups', () => {
+    expect(declarations('.flatList > * + *')?.get('margin-top')).toBe('2px')
+    expect(declarations(".searchTree > [role='treeitem'] + [role='treeitem']")?.get('margin-top')).toBe('2px')
+    expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
+    expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
+  })
 })

+ 2 - 2
packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md
-README.md: ad4a97868c28dc3873c839490aa506271459e249
-README.zh.md: f1ad73ddf66aacc30a9024a9290df2c44686efe9
+README.md: 3abd97187cafee132823c02a0b0d103a86bda7db
+README.zh.md: 223e6a663933da81032a1fbbb4211555c4bcc159

+ 2 - 1
packages/telemetry/session-telemetry-otel/README.md

@@ -10,6 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
 - id: telemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
+    shutdownTimeoutMillis: 3000 # optional; defaults to 3000
     exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
       url: https://collector.example.com/v1/logs
       headers:
@@ -17,7 +18,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
     processor: {}            # optional; passed verbatim to BatchLogRecordProcessor
 ```
 
-`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
+`exporter.url` is required, has no default, and must parse as `http(s)`; `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline and defaults to 3000 ms; a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, however, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
 
 ## What leaves the machine
 

+ 2 - 1
packages/telemetry/session-telemetry-otel/README.zh.md

@@ -10,6 +10,7 @@
 - id: telemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
+    shutdownTimeoutMillis: 3000 # optional; defaults to 3000
     exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
       url: https://collector.example.com/v1/logs
       headers:
@@ -17,7 +18,7 @@
     processor: {}            # optional; passed verbatim to BatchLogRecordProcessor
 ```
 
-`exporter.url` 是本包(package)唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此:SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
+`exporter.url` 是必填项、没有默认值,并且必须能解析为 `http(s)`;`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。但在关闭期间,OTel 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的处理器完成 promise;如果该传输 promise 始终不结算,本包(package)会在 `shutdownTimeoutMillis` 到期时放弃等待,沿协调器现有的失败隔离路径记录关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
 
 ## 哪些数据会离开本机
 

+ 45 - 18
packages/telemetry/session-telemetry-otel/src/index.ts

@@ -6,8 +6,9 @@
  * record handed over by the seam onto `logger.emit()`. Per the seam's
  * boundary axiom, everything downstream of that call (batching, retry,
  * queueing, loss policy) is the SDK's documented behavior, configured
- * verbatim through the `exporter`/`processor` passthroughs; this package
- * adds no knobs of its own on top of them.
+ * verbatim through the `exporter`/`processor` passthroughs. The one
+ * backend-owned policy is an outer shutdown deadline: the SDK's export
+ * timeout does not bound its preceding `forceFlush()` wait.
  *
  * @module @deepseek-ai/dsh-session-telemetry-otel
  */
@@ -33,10 +34,9 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
 const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
 
 /**
- * Plugin configuration: two verbatim SDK option shapes plus nothing else.
- * `exporter.url` is the one field this package validates itself — required,
- * no default, must parse as an `http(s)` URL — because a missing endpoint
- * must fail at plugin load, not at first export.
+ * Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
+ * shutdown bound. The package validates its endpoint and shutdown deadline
+ * because both must fail at plugin load rather than at first export or exit.
  */
 export interface Config {
   /**
@@ -54,21 +54,31 @@ export interface Config {
    * which this plugin fills); the SDK owns and documents these knobs.
    */
   processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
+  /** Maximum time spent awaiting the SDK provider's complete shutdown path. */
+  shutdownTimeoutMillis?: number
 }
 
 /**
  * Schemastery validator for {@link Config}; cordis runs it before the plugin
- * starts. Shape-level only — the load-bearing `exporter.url` check lives in
- * the constructor so its error message names the field. Both slots are opaque
- * passthroughs: the SDK owns their shapes and validates its own options;
+ * starts. Shape-level only — load-bearing value checks live in the constructor
+ * so their errors name the fields. Both SDK slots are opaque passthroughs:
+ * the SDK owns their shapes and validates its own options;
  * re-declaring them field-by-field here would violate the boundary axiom
  * (and silently drop every field not re-declared).
  */
 export const Config: z<Config> = z.object({
   exporter: z.any(),
   processor: z.any(),
+  shutdownTimeoutMillis: z.number(),
 })
 
+/** Default outer allowance for the SDK's complete shutdown sequence. */
+export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
+
+// Node clamps larger timer delays to one millisecond. This is a runtime
+// protocol limit, not a deployment default.
+const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
+
 /** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */
 const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
   info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
@@ -90,6 +100,7 @@ export class TelemetryOtel extends Telemetry {
   private readonly provider: LoggerProvider
   private readonly ledger: Logger
   private readonly ops: Logger
+  private readonly shutdownTimeoutMillis: number
 
   constructor(ctx: Context, config: Config) {
     super(ctx)
@@ -115,6 +126,11 @@ export class TelemetryOtel extends Telemetry {
     if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
       throw new Error(`session-telemetry-otel: processor.maxExportBatchSize must be a positive integer, got ${String(batchSize)}`)
     }
+    const shutdownTimeoutMillis = config.shutdownTimeoutMillis ?? DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
+    if (!Number.isFinite(shutdownTimeoutMillis) || shutdownTimeoutMillis <= 0 || shutdownTimeoutMillis > MAX_TIMER_DELAY_MILLIS) {
+      throw new Error(`session-telemetry-otel: shutdownTimeoutMillis must be a positive finite number no greater than ${MAX_TIMER_DELAY_MILLIS}, got ${String(shutdownTimeoutMillis)}`)
+    }
+    this.shutdownTimeoutMillis = shutdownTimeoutMillis
     this.provider = new LoggerProvider({
       resource: resourceFromAttributes({
         'service.name': APP_IDENTITY.product,
@@ -170,16 +186,27 @@ export class TelemetryOtel extends Telemetry {
   // the revival Agent Note.
 
   /**
-   * Delegate disposal to the SDK's shutdown contract: drain the queue and
-   * quiesce. With no concurrent `forceFlush()` in the process (see above),
-   * shutdown's internal drain is complete — everything emitted before this
-   * call, including the coordinator's dispose-time `shutdown` markers, is
-   * exported before the exporter closes. Awaited (and error-contained) by
-   * the coordinator's disposer.
-   * @returns resolves when the SDK pipeline has quiesced.
+   * Ask the SDK to drain and quiesce, but reject after the backend-owned
+   * deadline. OTel's processor export timeout wraps `exportCompleted` only;
+   * shutdown awaits `exporter.forceFlush()` first, which can remain pending
+   * when the transport never obtains a socket. The provider promise remains
+   * observed after the deadline so a later rejection cannot become unhandled.
+   * @returns resolves when the SDK pipeline quiesces, or rejects at the configured deadline.
    */
-  shutdown(): Promise<void> {
-    return this.provider.shutdown()
+  async shutdown(): Promise<void> {
+    const providerShutdown = this.provider.shutdown()
+    let timer: ReturnType<typeof setTimeout> | undefined
+    const deadline = new Promise<never>((_resolve, reject) => {
+      timer = setTimeout(() => {
+        reject(new Error(`session-telemetry-otel: provider shutdown exceeded ${this.shutdownTimeoutMillis}ms`))
+      }, this.shutdownTimeoutMillis)
+    })
+    try {
+      await Promise.race([providerShutdown, deadline])
+    } finally {
+      /* v8 ignore else -- the Promise executor assigns timer synchronously before this race starts. */
+      if (timer !== undefined) clearTimeout(timer)
+    }
   }
 }
 

+ 34 - 0
packages/telemetry/session-telemetry-otel/tests/otel.spec.ts

@@ -180,6 +180,38 @@ describe('TelemetryOtel wire', () => {
     expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
   })
 
+  it('bounds the SDK forceFlush wait when an in-flight transport never settles', async () => {
+    const gate = Promise.withResolvers<boolean>()
+    const arrived = Promise.withResolvers<boolean>()
+    const { url, captures } = await mockCollector(async (index) => {
+      if (index === 0) {
+        arrived.resolve(true)
+        await gate.promise
+      }
+    })
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = await ctx.plugin(TelemetryOtel, {
+      exporter: { url, timeoutMillis: 60_000 },
+      processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
+      shutdownTimeoutMillis: 50,
+    })
+    const session = ctx.sessions.create(SessionId('bounded-shutdown'), { meta: {} })
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    await arrived.promise
+
+    const started = performance.now()
+    await fiber.dispose()
+    expect(performance.now() - started).toBeLessThan(1_000)
+    expect(captures).toHaveLength(0)
+
+    // The outer deadline cannot cancel the SDK transport. Let it finish so
+    // the real provider promise remains clean after the test has proved the
+    // Cordis disposer no longer waits for it.
+    gate.resolve(true)
+    await expect.poll(() => captures.length).toBeGreaterThanOrEqual(2)
+  })
+
   it('passes exporter options beyond url and headers through to the SDK exporter', async () => {
     const { url, captures } = await mockCollector()
     const ctx = new Context()
@@ -227,6 +259,8 @@ describe('TelemetryOtel config fails loud', () => {
     // splices empty batches forever — dispose would hang, so reject at load.
     [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
     [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
+    [{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
+    [{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
   ])('rejects %j at plugin load', async (config, message) => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)

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