Răsfoiți Sursa

Merge pull request #4024 from deepseek-harness/turtle/orchestrator-837dec7391c5

perf(terminal-bash): make scrollback retention incremental
Turtle 1 săptămână în urmă
părinte
comite
e32f88dff2

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml

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

+ 64 - 0
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md

@@ -0,0 +1,64 @@
+# Agent Note: incremental terminal retention
+
+Status: implemented
+
+English | [中文](2026-09-11-incremental-terminal-retention.zh.md)
+
+## Problem
+
+Persistent terminal output passes through scrollback and unread-send byte limits on every PTY callback. Rebuilding the entire retained string to enforce those limits makes callback cost grow with retained output. A 4 MiB scrollback window makes this repeated work substantial even when each incoming chunk is small.
+
+## Decision
+
+The private buffer in [terminal-bash](../../../../packages/terminal/terminal-bash/src/session.ts) retains a linked sequence of strings, a head offset, and aggregate UTF-8 byte and newline counts. Appends inspect incoming text and evict only the oldest code points until both limits hold. Every evicted code point is charged to an earlier append, so total retention work is linear in input size. Reads assemble the retained strings; they remain proportional to retained output.
+
+The retained suffix matches line trimming followed by UTF-8 trimming. A trailing newline contributes an empty logical line. Truncation stays sticky until consumption clears the buffer. Adjacent surrogate halves across chunks count as one four-byte code point and are evicted together; unpaired halves retain JavaScript string identity and count as three UTF-8 bytes. The read-time `utf8Tail()` remains independent and unchanged.
+
+Every nonempty input is copied through UTF-16 before retention, preserving unpaired surrogates while detaching slices returned by the sanitizer from discarded control text. This applies to small pending fragments as well as large chunks. Private `truncated` and `isEmpty` getters let send settlement and startup polling inspect status without assembling scrollback.
+
+After at least half of the leading string is discarded, its suffix is copied to release the original backing storage. The copy costs no more than the discarded prefix, preserving amortized linear work and bounding retained string storage by the retained window. Linked nodes avoid array shifts or periodic scans of all retained chunks. Small inputs coalesce in the non-head tail up to 4096 UTF-16 units; allocating its successor copies those fragments into one owned string. Large tails already own their storage and are not copied again at this point. The head never grows during appends, and a cached last code unit avoids flattening pending fragments to inspect a cross-chunk surrogate pair. This bounds fragment metadata even for one-byte callbacks without rescanning retained text.
+
+The [persistent PTY decision](../feature/2026-07-16-persistent-pty-sessions.md) continues to own session lifecycle, model-visible output, and retention semantics. This decision specializes storage and performance; it supersedes no active decision record.
+
+## Measurement design
+
+The terminal I/O benchmark drives `LocalPtySession` with a synthetic subprocess handle. Fixed 16 KiB ASCII chunks without newlines exercise the byte limit with a long logical line. Steady-state cases fill either a 128 KiB or 4 MiB window, then append the same additional 1 MiB. A separate empty-window case sends 5 MiB. The line limit is 10,000; unread output is limited to the smaller of 256 KiB and the scrollback capacity.
+
+Synchronous ingestion measures the send start and provider callbacks. Completion additionally waits for emulator processing and readiness, then reads the bounded terminal result. Retained heap is sampled after explicit GC while the session and returned output remain reachable. Built JavaScript runs under plain Node. These measurements exclude shell startup, operating-system PTY transport, model latency, and browser rendering.
+
+### Local reference measurements
+
+On Apple M5 Pro, macOS arm64, Node v26.5.0, five fresh workers per case compare the eager-retention baseline with incremental retention. The same worker and inputs measure both versions; only the private session implementation differs. Times below are milliseconds in sample order.
+
+| Case / metric | Eager retention samples | Incremental retention samples |
+|---|---|---|
+| 128 KiB steady / ingestion | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 |
+| 128 KiB steady / completion | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 |
+| 4 MiB steady / ingestion | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 |
+| 4 MiB steady / completion | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 |
+| 5 MiB send / ingestion | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 |
+| 5 MiB send / completion | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 |
+
+The large/small steady-ingestion median ratio is 18.88 for eager retention and 0.84 for incremental retention. The 5 MiB completion median falls from 5211.802 ms to 89.339 ms (58.3×). Maximum retained heap for the large steady case rises from 4,512,656 to 6,219,296 bytes; this measures live session and result allocations together, not just buffer strings.
+
+A separate memory case sends 5 MiB in 16-byte callbacks and samples retained heap once after completion. It retains 5,802,840 bytes with tail aggregation. The same assertion with uncoalesced linked nodes fails at 22,969,720 bytes against the 16 MiB bound. This case has no performance timing verdict.
+
+The filtered-output memory case emits 513 callbacks of 64 KiB each, containing a complete 56 KiB OSC sequence followed by 8 KiB of visible text. This passes through the production sanitizer before filling a 4 MiB visible window and taking a bounded read. With incoming slices retained directly, the assertion fails at 36,706,592 bytes. Copying inputs into independent storage reduces retained heap to 7,635,440 bytes, below the unchanged 16 MiB limit. These measurements use Node v26.5.0 and fresh workers.
+
+A real PTY diagnostic runs `node -e 'process.stdout.write("x".repeat(5*1024*1024))'` through the built local subprocess provider. One baseline sample takes 106962.523 ms; one final candidate sample takes 249.007 ms. Timing begins before PTY/process spawn and ends after `session_exit` and the bounded read. Both samples exit with code 0, no signal, and truncated 256 KiB viewport/read payloads. This includes native PTY transport and Node startup, but excludes an interactive shell and prompt-readiness round trip.
+
+The [required benchmark](../../../../benchmarks/terminal-io/terminal-io.bench.ts) applies the shared CI scale and headroom to reference expectations of 20 ms steady ingestion, 50 ms steady completion, and 120 ms full completion, yielding limits of 50/125/300 ms. Median capacity scaling must stay below 4×; maximum retained heap is 16 MiB. Ratios and memory limits are unscaled. Substituting the original compiled session worker makes both timing cases fail: capacity ratio 18.977 exceeds 4, and full completion 5066.719 ms exceeds 300 ms. The final worker passes all four cases. The local benchmark command is `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts` after the benchmark build.
+
+## Alternatives considered
+
+**Cache only the byte count.** This leaves the per-append line split and full-string prefix deletion dependent on retained output. Both limits need incremental accounting.
+
+**Keep the complete output until a read.** This makes producer cost small but permits unbounded retention between reads. The configured limits apply during production.
+
+**Retain one node per callback.** Tiny callbacks make node metadata much larger than the bounded text. Bounded tail aggregation keeps node count tied to stored text blocks.
+
+**Store encoded UTF-8 chunks.** Encoding replaces unpaired UTF-16 surrogates. String chunks preserve the existing buffer semantics without adding a second text representation.
+
+## Consequences
+
+Append-time work no longer depends on repeatedly scanning the retained window. Snapshot and consume still allocate a combined string. Retention adds linked nodes and a bounded collection of pending small fragments. Functional tests cover byte/line interactions, consumption, split surrogate pairs, and 4 MiB retention. Performance evidence complements these output assertions; a synthetic provider does not establish real-shell command latency.

+ 64 - 0
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md

@@ -0,0 +1,64 @@
+# Agent Note: 增量终端保留策略
+
+Status: implemented
+
+[English](2026-09-11-incremental-terminal-retention.md) | 中文
+
+## 问题
+
+持久终端输出在每次 PTY 回调中都受 scrollback 和未读发送输出的字节上限约束。如果每次执行这些限制都重建完整的保留字符串,回调成本就会随保留输出量增长。即使每个输入分片很小,4 MiB scrollback 窗口也会使这项重复工作产生显著开销。
+
+## 决策
+
+[terminal-bash](../../../../packages/terminal/terminal-bash/src/session.ts) 的私有缓冲区保留字符串链表、头部偏移,以及 UTF-8 字节数与换行符数的汇总值。追加操作检查输入文本,并仅淘汰最旧的码点,直到两个限制都满足。每个被淘汰码点的成本可归于之前的追加操作,因此保留策略的总工作量与输入量呈线性关系。读取时拼接保留字符串,成本仍与保留输出量成正比。
+
+保留后缀与先按行数裁剪、再按 UTF-8 字节数裁剪的结果一致。末尾换行符贡献一个空逻辑行。截断标志保持为真,直到消费操作清空缓冲区。跨分片相邻的代理项两半按一个四字节码点计数,并共同淘汰;未配对代理项保留 JavaScript 字符串原值,按三个 UTF-8 字节计数。读取时的 `utf8Tail()` 保持独立且不变。
+
+每个非空输入都在保留前通过 UTF-16 复制,在保留未配对代理项的同时,使清理器返回的切片脱离已丢弃的控制文本。待处理的小片段与大分片都遵循这一规则。私有 `truncated` 和 `isEmpty` getter 让发送结算和启动轮询无需拼接 scrollback 即可检查状态。
+
+头部字符串至少一半被丢弃后,其后缀会被复制,以释放原始底层存储。复制成本不超过已丢弃前缀,因此维持摊还线性工作量,并使保留字符串存储受保留窗口约束。链表节点避免数组头部移除或定期扫描所有保留分片。小输入在非头部的尾节点合并,最多积累 4096 个 UTF-16 单元;分配后继节点时,将这些片段复制为一个独立字符串。大尾块已经拥有独立存储,此处不会再次复制。追加期间头节点不会增长,缓存的末尾码元也避免了为检查跨分片代理对而将待处理片段展平。这样即使每次回调只有一个字节,片段元数据也有界,且无需重新扫描保留文本。
+
+[持久 PTY 决策](../feature/2026-07-16-persistent-pty-sessions.zh.md)继续负责会话生命周期、模型可见输出与保留语义。本决策细化存储与性能,不取代任何活跃决策记录。
+
+## 测量设计
+
+终端 I/O 基准通过合成子进程句柄驱动 `LocalPtySession`。固定的 16 KiB ASCII 分片不含换行符,以长逻辑行触发字节上限。稳态场景先填满 128 KiB 或 4 MiB 窗口,再追加同样的 1 MiB。独立的空窗口场景发送 5 MiB。行数上限为 10,000;未读输出上限为 256 KiB 与 scrollback 容量中的较小值。
+
+同步接收计时覆盖发送启动与提供方回调。完成计时还等待终端模拟器处理与就绪,再读取有界终端结果。保留堆在显式 GC 后采样,此时会话与返回输出仍可达。构建后的 JavaScript 在普通 Node 下运行。这些测量不含 shell 启动、操作系统 PTY 传输、模型延迟或浏览器渲染。
+
+### 本地参考测量
+
+在 Apple M5 Pro、macOS arm64、Node v26.5.0 上,每个场景用五个全新 worker 比较全量保留计算基线 与增量保留策略。两个版本使用相同 worker 和输入,仅私有会话实现不同。下表时间单位为毫秒,按采样顺序排列。
+
+| 场景 / 指标 | 全量保留计算样本 | 增量保留计算样本 |
+|---|---|---|
+| 128 KiB 稳态 / 接收 | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 |
+| 128 KiB 稳态 / 完成 | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 |
+| 4 MiB 稳态 / 接收 | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 |
+| 4 MiB 稳态 / 完成 | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 |
+| 5 MiB 发送 / 接收 | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 |
+| 5 MiB 发送 / 完成 | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 |
+
+大/小窗口稳态接收时间的中位数比值在全量保留计算下为 18.88,在增量保留计算下为 0.84。5 MiB 完成时间的中位数从 5211.802 ms 降至 89.339 ms(58.3×)。大窗口稳态场景的最大保留堆从 4,512,656 字节升至 6,219,296 字节;该指标同时测量活跃会话与结果分配,并非仅缓冲区字符串。
+
+独立的内存场景以 16 字节回调发送 5 MiB,在完成后对保留堆采样一次。尾部合并时保留 5,802,840 字节。相同断言在未合并链表节点下失败:22,969,720 字节超过 16 MiB 上限。此场景不判定性能耗时。
+
+过滤输出的内存场景发送 513 个 64 KiB 回调,每个含完整的 56 KiB OSC 序列及其后的 8 KiB 可见文本。数据经过生产清理器,填满 4 MiB 可见窗口后进行有界读取。直接保留输入切片时,断言以 36,706,592 字节失败。将输入复制到独立存储后,保留堆降至 7,635,440 字节,低于不变的 16 MiB 上限。这些测量使用 Node v26.5.0 和全新 worker。
+
+真实 PTY 诊断通过构建后的本地子进程提供方运行 `node -e 'process.stdout.write("x".repeat(5*1024*1024))'`。一次基线样本耗时 106962.523 ms;一次最终候选样本耗时 249.007 ms。计时从 PTY/进程 spawn 前开始,到 `session_exit` 和有界读取完成后结束。两个样本均以代码 0 退出,无信号,viewport/read 载荷均为已截断的 256 KiB。这包括原生 PTY 传输与 Node 启动,不包括交互式 shell 及提示符就绪往返。
+
+[必需基准](../../../../benchmarks/terminal-io/terminal-io.bench.ts)对参考预期值应用共享 CI 系数与余量:稳态接收 20 ms、稳态完成 50 ms、完整发送完成 120 ms,对应限制为 50/125/300 ms。容量扩展的中位数比值必须低于 4×;最大保留堆为 16 MiB。比值与内存限制不缩放。替换为原始编译后会话 worker 时,两个计时场景都失败:容量比值 18.977 超过 4,完整发送完成时间 5066.719 ms 超过 300 ms。最终 worker 的四个场景均通过。本地命令为 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts`,在基准构建完成后执行。
+
+## 考虑过的替代方案
+
+**仅缓存字节数。** 这仍会使每次追加的按行拆分和完整字符串前缀删除依赖保留输出量。两个限制都需要增量计数。
+
+**在读取前保留全部输出。** 这使生产成本较小,但允许读取之间的保留量无限增长。配置的限制必须在产生输出时生效。
+
+**每次回调保留一个节点。** 微小回调会使节点元数据远大于有界文本。受限的尾部合并使节点数与存储的文本块相关。
+
+**保留编码后的 UTF-8 分片。** 编码会替换未配对 UTF-16 代理项。字符串分片无需引入第二种文本表示,即可保留现有缓冲区语义。
+
+## 影响
+
+追加工作不再依赖重复扫描保留窗口。Snapshot 和 consume 仍分配拼接后的字符串。保留策略额外维护链表节点与有界的待处理小片段集合。功能测试覆盖字节数与行数的交互、消费操作、跨分片代理对,以及 4 MiB 保留窗口。性能证据补充这些输出断言;合成提供方不能证明真实 shell 命令延迟。

+ 2 - 2
.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md
 #   pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md
-2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md: 8c46de37cbb936f31dc498b2100a031610b6d1e7
-2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md: 4795abff037ee19a7407cae02aee71ec3abcca44
+2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md: d925222c6c1f07707815353caa15b2ff51212e7f
+2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md: 34128c671e898d7a6729ca3fa132585c7b53bdbe

+ 1 - 1
.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md

@@ -9,7 +9,7 @@ English | [中文](2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.m
 Three packages hand-roll promise-wrapped timers that the `node:timers/promises` builtin already provides, while other packages (`dsh-llm-mock-server` `pause()`, `dsh-lsp-stdio`, `dsh-acp-snapshot`) already use the builtin — so the hand-rolled copies are also a consistency gap:
 Three packages hand-roll promise-wrapped timers that the `node:timers/promises` builtin already provides, while other packages (`dsh-llm-mock-server` `pause()`, `dsh-lsp-stdio`, `dsh-acp-snapshot`) already use the builtin — so the hand-rolled copies are also a consistency gap:
 
 
 - `packages/llm/llm-retry/src/index.ts` `cancellableDelay()` (~14 lines): `new Promise` + `setTimeout` + manual abort-listener add/remove, resolving `true` on elapse and `false` on abort, consumed once for the backoff wait.
 - `packages/llm/llm-retry/src/index.ts` `cancellableDelay()` (~14 lines): `new Promise` + `setTimeout` + manual abort-listener add/remove, resolving `true` on elapse and `false` on abort, consumed once for the backoff wait.
-- `packages/workflow/workflow-worker-thread/src/host.ts` `sleep()` (~7 lines): promise-wrapped unref'd `setTimeout` used as the dispose-grace bound.
+- The former `workflow-worker-thread` host's `sleep()` (~7 lines, evaluated in PR #679): promise-wrapped unref'd `setTimeout` used as the dispose-grace bound.
 - `packages/terminal/terminal-bash/src/session.ts` `delay()` (~4 lines): bare promise-wrapped `setTimeout` used in polling/teardown waits.
 - `packages/terminal/terminal-bash/src/session.ts` `delay()` (~4 lines): bare promise-wrapped `setTimeout` used in polling/teardown waits.
 
 
 ## Proposal
 ## Proposal

+ 1 - 1
.agents/notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.zh.md

@@ -9,7 +9,7 @@ Status: rejected — 实现(PR #679)证伪了行为等价前提:vitest 的
 三个包手写了用 promise 包装的定时器,而 `node:timers/promises` 内置模块早已提供同等能力;其他包(`dsh-llm-mock-server` 的 `pause()`、`dsh-lsp-stdio`、`dsh-acp-snapshot`)已经在使用该内置模块,因此这些手写副本同时也是一处一致性缺口:
 三个包手写了用 promise 包装的定时器,而 `node:timers/promises` 内置模块早已提供同等能力;其他包(`dsh-llm-mock-server` 的 `pause()`、`dsh-lsp-stdio`、`dsh-acp-snapshot`)已经在使用该内置模块,因此这些手写副本同时也是一处一致性缺口:
 
 
 - `packages/llm/llm-retry/src/index.ts` 的 `cancellableDelay()`(约 14 行):`new Promise` + `setTimeout` + 手动添加和移除中止监听器,定时器触发时 resolve 为 `true`、被中止时 resolve 为 `false`,仅在退避等待处消费一次。
 - `packages/llm/llm-retry/src/index.ts` 的 `cancellableDelay()`(约 14 行):`new Promise` + `setTimeout` + 手动添加和移除中止监听器,定时器触发时 resolve 为 `true`、被中止时 resolve 为 `false`,仅在退避等待处消费一次。
-- `packages/workflow/workflow-worker-thread/src/host.ts` 的 `sleep()`(约 7 行):promise 包装、已 unref 的 `setTimeout`,用作 dispose(资源释放)宽限的时间上界。
+- 原 `workflow-worker-thread` host 的 `sleep()`(约 7 行,在 PR #679 中评估):promise 包装、已 unref 的 `setTimeout`,用作 dispose(资源释放)宽限的时间上界。
 - `packages/terminal/terminal-bash/src/session.ts` 的 `delay()`(约 4 行):朴素的 promise 包装 `setTimeout`,用于轮询与拆卸等待。
 - `packages/terminal/terminal-bash/src/session.ts` 的 `delay()`(约 4 行):朴素的 promise 包装 `setTimeout`,用于轮询与拆卸等待。
 
 
 ## 提案
 ## 提案

+ 3 - 0
benchmarks/package.json

@@ -6,6 +6,9 @@
   "type": "module",
   "type": "module",
   "devDependencies": {
   "devDependencies": {
     "playwright": "^1.49.0",
     "playwright": "^1.49.0",
+    "@xterm/headless": "^6.0.0",
+    "@deepseek-ai/dsh-terminal": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-llm-replay": "workspace:^",
     "@deepseek-ai/dsh-llm-replay": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",

+ 2 - 0
benchmarks/terminal-io/session-adapter.ts

@@ -0,0 +1,2 @@
+/** Private production entry bundled into the plain-Node terminal I/O worker. */
+export { LocalPtySession } from '../../packages/terminal/terminal-bash/src/session.ts'

+ 69 - 0
benchmarks/terminal-io/terminal-io.bench.ts

@@ -0,0 +1,69 @@
+/** Bounded terminal output must not rescan a full retained window on every chunk. */
+import { join } from 'node:path'
+import { expect, it } from 'vitest'
+import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
+import { ciTimeBudget } from '../support/calibration.ts'
+import type { TerminalIoReport } from './terminal-io.worker.ts'
+
+const MIB = 1024 * 1024
+const ATTEMPTS = 5
+const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'terminal-io', 'terminal-io.worker.js')
+/** M5 Pro / Node 26.5 reference expectations, before shared CI scaling and headroom. */
+const EXPECTED_MS = { steadyIngest: 20, steadyComplete: 50, fullComplete: 120 }
+const MAX_CAPACITY_RATIO = 4
+const MAX_RETAINED_HEAP_BYTES = 16 * MIB
+
+function median(values: readonly number[]): number {
+  return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] as number
+}
+
+async function sample(capacity: number, mode: 'steady' | 'full' | 'tiny' | 'filtered'): Promise<TerminalIoReport> {
+  const outcome = await runBuiltBenchmarkWorker<TerminalIoReport>({
+    worker: WORKER, args: [String(capacity), mode], timeoutMs: 120_000, exposeGc: true,
+  })
+  if (outcome.timedOut || outcome.signal !== null || outcome.exitCode !== 0 || outcome.report === undefined) {
+    throw new Error('terminal I/O worker failed: ' + JSON.stringify(outcome))
+  }
+  return outcome.report
+}
+
+it('bounds steady overflow cost as retained terminal capacity grows 32 times', async () => {
+  const small: TerminalIoReport[] = []
+  const large: TerminalIoReport[] = []
+  for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
+    small.push(await sample(128 * 1024, 'steady'))
+    large.push(await sample(4 * MIB, 'steady'))
+  }
+  const capacityRatio = median(large.map(row => row.ingestMs)) / median(small.map(row => row.ingestMs))
+  const ingestBudgetMs = ciTimeBudget(EXPECTED_MS.steadyIngest)
+  const completeBudgetMs = ciTimeBudget(EXPECTED_MS.steadyComplete)
+  console.log(JSON.stringify({ scenario: 'terminal-steady', small, large, capacityRatio, ingestBudgetMs, completeBudgetMs }))
+  expect(capacityRatio).toBeLessThanOrEqual(MAX_CAPACITY_RATIO)
+  for (const rows of [small, large]) {
+    expect(median(rows.map(row => row.ingestMs))).toBeLessThanOrEqual(ingestBudgetMs)
+    expect(median(rows.map(row => row.completeMs))).toBeLessThanOrEqual(completeBudgetMs)
+    expect(Math.max(...rows.map(row => row.retainedHeapBytes))).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
+  }
+})
+
+it('bounds retained memory when five MiB arrives in sixteen-byte chunks', async () => {
+  const report = await sample(4 * MIB, 'tiny')
+  console.log(JSON.stringify({ scenario: 'terminal-tiny-chunks', report, heapBudgetBytes: MAX_RETAINED_HEAP_BYTES }))
+  expect(report.retainedHeapBytes).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
+})
+
+it('releases filtered OSC storage behind retained visible string slices', async () => {
+  const report = await sample(4 * MIB, 'filtered')
+  console.log(JSON.stringify({ scenario: 'terminal-filtered-chunks', report, heapBudgetBytes: MAX_RETAINED_HEAP_BYTES }))
+  expect(report.retainedHeapBytes).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
+})
+
+it('completes a five MiB terminal send with bounded retained output', async () => {
+  const samples: TerminalIoReport[] = []
+  for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) samples.push(await sample(4 * MIB, 'full'))
+  const completeMedianMs = median(samples.map(row => row.completeMs))
+  const completeBudgetMs = ciTimeBudget(EXPECTED_MS.fullComplete)
+  console.log(JSON.stringify({ scenario: 'terminal-five-mib', samples, completeMedianMs, completeBudgetMs }))
+  expect(completeMedianMs).toBeLessThanOrEqual(completeBudgetMs)
+  expect(Math.max(...samples.map(row => row.retainedHeapBytes))).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
+})

+ 106 - 0
benchmarks/terminal-io/terminal-io.worker.ts

@@ -0,0 +1,106 @@
+/** Terminal output ingestion and readiness with a deterministic provider boundary. */
+import { Buffer } from 'node:buffer'
+import { performance } from 'node:perf_hooks'
+import { Readable } from 'node:stream'
+import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
+import { LocalPtySession } from './session-adapter.ts'
+
+const MIB = 1024 * 1024
+const CHUNK_BYTES = 16 * 1024
+
+/** One fresh-session sample; retained heap includes the live session and returned output. */
+export interface TerminalIoReport {
+  mode: string
+  capacityBytes: number
+  chunkBytes: number
+  prefillBytes: number
+  timedBytes: number
+  ingestMs: number
+  completeMs: number
+  retainedHeapBytes: number
+  viewportBytes: number
+  readBytes: number
+  truncated: boolean
+}
+
+async function measure(capacityBytes: number, mode: string): Promise<TerminalIoReport> {
+  const chunkBytes = mode === 'filtered' ? 64 * 1024 : mode === 'tiny' ? 16 : CHUNK_BYTES
+  const chunk = Buffer.alloc(chunkBytes, 'x')
+  if (mode === 'filtered') {
+    // Each decoded callback has 56 KiB of discarded OSC followed by an 8 KiB string slice.
+    chunk.write('\x1b]0;', 0)
+    chunk[56 * 1024 - 1] = 7
+  }
+  const prefillBytes = mode === 'steady' ? capacityBytes : 0
+  const timedBytes = mode === 'filtered' ? 513 * chunkBytes : mode === 'steady' ? MIB : 5 * MIB
+  const output = new Readable({ read() {} })
+  const ended = Promise.withResolvers<SubprocessOutcome>()
+  const writeReady = Promise.withResolvers<void>()
+  const terminal: SubprocessTerminalHandle = {
+    pid: 1,
+    output,
+    done: ended.promise,
+    async write() { writeReady.resolve() },
+    async resize() {},
+    async inspectForeground() { return { processGroupId: 1, inputWaiting: false } },
+    async signalForeground() { return 1 },
+    async terminate() {
+      output.emit('end')
+      ended.resolve({ exitCode: 0, signal: null })
+      await ended.promise
+    },
+  }
+  global.gc?.()
+  const heapBefore = process.memoryUsage().heapUsed
+  const session = new LocalPtySession(terminal, {
+    backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [],
+    rows: 40, cols: 160, scrollbackLines: 10_000, scrollbackMaxBytes: capacityBytes,
+    maxReadBytes: Math.min(256 * 1024, capacityBytes),
+    pollIntervalMs: 1, exactProbeAfterMs: 150, idleSilenceMs: 1,
+    handoffGraceMs: 1, timeoutMs: 120_000, disposeGraceMs: 1,
+  })
+  try {
+    if (prefillBytes > 0) {
+      const prefill = session.startSend({ text: 'prefill', submit: false })
+      await writeReady.promise
+      for (let bytes = 0; bytes < prefillBytes; bytes += chunkBytes) output.emit('data', chunk)
+      const ready = await prefill.done
+      if (ready.waitReason !== 'inferred_idle') throw new Error('prefill did not reach readiness')
+    }
+    const start = performance.now()
+    const operation = session.startSend({ text: '', submit: false })
+    for (let bytes = 0; bytes < timedBytes; bytes += chunkBytes) output.emit('data', chunk)
+    const ingestMs = performance.now() - start
+    const result = await operation.done
+    const read = session.read({ count: 10_000 })
+    const completeMs = performance.now() - start
+    if (result.waitReason !== 'inferred_idle' || !result.truncated || !read.truncated) {
+      throw new Error('output did not reach bounded ready endpoint')
+    }
+    const expectedBytes = Math.min(256 * 1024, capacityBytes)
+    if (Buffer.byteLength(result.viewport) !== expectedBytes || Buffer.byteLength(read.text) !== expectedBytes) {
+      throw new Error('bounded output endpoint has unexpected byte count')
+    }
+    global.gc?.()
+    return {
+      mode, capacityBytes, chunkBytes, prefillBytes, timedBytes, ingestMs, completeMs,
+      retainedHeapBytes: process.memoryUsage().heapUsed - heapBefore,
+      viewportBytes: Buffer.byteLength(result.viewport), readBytes: Buffer.byteLength(read.text),
+      truncated: result.truncated && read.truncated,
+    }
+  } finally {
+    await session.close('benchmark complete')
+    output.destroy()
+  }
+}
+
+assertBuiltBenchmarkRuntime(import.meta.url, {
+  '@deepseek-ai/dsh-terminal': import.meta.resolve('@deepseek-ai/dsh-terminal'),
+})
+const capacityBytes = Number(process.argv[2])
+const mode = process.argv[3]
+if (![128 * 1024, 4 * MIB].includes(capacityBytes) || (mode !== 'steady' && mode !== 'full' && mode !== 'tiny' && mode !== 'filtered')) {
+  throw new Error('usage: terminal-io.worker.js <131072|4194304> <steady|full|tiny|filtered>')
+}
+console.log(JSON.stringify(await measure(capacityBytes, mode)))

+ 7 - 0
benchmarks/tsdown.config.ts

@@ -14,6 +14,13 @@ const shared = {
 
 
 /** Compile measured benchmark workers while keeping workspace packages on their built `lib` entries. */
 /** Compile measured benchmark workers while keeping workspace packages on their built `lib` entries. */
 export default defineConfig([
 export default defineConfig([
+  {
+    ...shared,
+    entry: { 'terminal-io.worker': 'terminal-io/terminal-io.worker.ts' },
+    outDir: '.dsh-build/terminal-io',
+    clean: true,
+    tsconfig: 'tsconfig.host.json',
+  },
   {
   {
     ...shared,
     ...shared,
     entry: { 'reconnect.worker': 'active-stream-reconnect/reconnect.worker.client.ts' },
     entry: { 'reconnect.worker': 'active-stream-reconnect/reconnect.worker.client.ts' },

+ 2 - 2
packages/terminal/terminal-bash/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md
 #   pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md
-README.md: 465eb2dbe195d5129c5545fa2a91ab001cd4e08e
-README.zh.md: 256a68e0b640947567c0c2c356afbedb96934e72
+README.md: 466a42f46844d16b85d8754df0bd910361e2e401
+README.zh.md: 7ca41883f6c807102c75b25e531187a7ab148cbf

+ 2 - 0
packages/terminal/terminal-bash/README.md

@@ -85,6 +85,8 @@ This section explains the design behind the backend and points at the code that
 
 
 One backend serves both dialects: bash and pwsh share the same session machinery — sanitizer, bounded buffers, readiness polling, cancellation, and teardown — and differ only in argv, environment, and prompt installation. Bash receives a private marker through `PS1` plus `PROMPT_COMMAND`. Pwsh writes a prompt function, pins UTF-8 console encoding, and publishes startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and returns terminal-protocol replies through the same handle, while the line sanitizer remains the only output projection.
 One backend serves both dialects: bash and pwsh share the same session machinery — sanitizer, bounded buffers, readiness polling, cancellation, and teardown — and differ only in argv, environment, and prompt installation. Bash receives a private marker through `PS1` plus `PROMPT_COMMAND`. Pwsh writes a prompt function, pins UTF-8 console encoding, and publishes startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and returns terminal-protocol replies through the same handle, while the line sanitizer remains the only output projection.
 
 
+Scrollback and unread send output retain independently owned strings with incremental byte and newline counts, so sanitized slices cannot retain discarded control sequences. Appending and evicting text takes amortized time proportional to incoming text; reads assemble the retained chunks. Retention preserves code-point boundaries and counts the empty line after a trailing newline. The [retention decision](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md) owns the complexity and measurement rationale.
+
 ### Source map
 ### Source map
 
 
 | File | Role |
 | File | Role |

+ 2 - 0
packages/terminal/terminal-bash/README.zh.md

@@ -85,6 +85,8 @@ shell 在整个生命周期内运行在有效的沙箱边界之下。当所有
 
 
 一个后端服务两种方言:bash 与 pwsh 共享同一套会话机制——清理器、有界缓冲区、就绪轮询、取消与关闭——只在 argv、环境与提示符安装方式上不同。bash 通过 `PS1` 加 `PROMPT_COMMAND` 接收私有标记。pwsh 会写入提示符函数、固定 UTF-8 控制台编码,并只在后端报告 `stdin_read` 后发布启动;回显的设置文本不能发布 shell。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过同一句柄返回终端协议响应;逐行 sanitizer 仍是唯一输出投影。
 一个后端服务两种方言:bash 与 pwsh 共享同一套会话机制——清理器、有界缓冲区、就绪轮询、取消与关闭——只在 argv、环境与提示符安装方式上不同。bash 通过 `PS1` 加 `PROMPT_COMMAND` 接收私有标记。pwsh 会写入提示符函数、固定 UTF-8 控制台编码,并只在后端报告 `stdin_read` 后发布启动;回显的设置文本不能发布 shell。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过同一句柄返回终端协议响应;逐行 sanitizer 仍是唯一输出投影。
 
 
+Scrollback 和尚未读取的发送输出保留独立拥有的字符串,并增量维护字节数与换行符数,因此清理后的切片不会保留已丢弃的控制序列。追加与淘汰文本的摊还耗时与输入文本量成正比;读取时才拼接保留的分片。保留策略维持码点边界,并将末尾换行符之后的空行计入行数。[保留策略决策](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md)记录复杂度与测量依据。
+
 ### 源码地图
 ### 源码地图
 
 
 | 文件 | 职责 |
 | 文件 | 职责 |

+ 93 - 16
packages/terminal/terminal-bash/src/session.ts

@@ -42,8 +42,22 @@ function utf8Tail(text: string, maxBytes: number): { text: string; truncated: bo
   return { text: chars.slice(start).join(''), truncated: true }
   return { text: chars.slice(start).join(''), truncated: true }
 }
 }
 
 
+// Bound pending string fragments independently of deployment retention limits.
+const COALESCED_CHUNK_UNITS = 4096
+
+interface TextChunk {
+  text: string
+  start: number
+  next: TextChunk | undefined
+}
+
+/** Retention work is amortized over appended text; reads assemble the retained chunks. */
 class BoundedTextBuffer {
 class BoundedTextBuffer {
-  private value = ''
+  private head: TextChunk | undefined
+  private tail: TextChunk | undefined
+  private bytes = 0
+  private newlines = 0
+  private lastCodeUnit = 0
   private dropped = false
   private dropped = false
 
 
   constructor(
   constructor(
@@ -51,31 +65,94 @@ class BoundedTextBuffer {
     private readonly maxLines?: number,
     private readonly maxLines?: number,
   ) {}
   ) {}
 
 
+  get truncated(): boolean {
+    return this.dropped
+  }
+
+  get isEmpty(): boolean {
+    return this.head === undefined
+  }
+
   append(text: string): void {
   append(text: string): void {
     if (text.length === 0) return
     if (text.length === 0) return
-    this.value += text
-    if (this.maxLines !== undefined) {
-      const lines = this.value.split('\n')
-      if (lines.length > this.maxLines) {
-        this.value = lines.slice(lines.length - this.maxLines).join('\n')
-        this.dropped = true
+    // Sanitized text can be a slice retaining discarded controls; copy UTF-16 without replacing lone surrogates.
+    text = Buffer.from(text, 'utf16le').toString('utf16le')
+    this.bytes += Buffer.byteLength(text)
+    const tail = this.tail
+    if (tail !== undefined) {
+      const last = this.lastCodeUnit
+      const first = text.charCodeAt(0)
+      // Concatenation can turn two three-byte lone surrogates into one four-byte code point.
+      if (last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff) this.bytes -= 2
+    }
+    for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) {
+      this.newlines += 1
+    }
+    this.lastCodeUnit = text.charCodeAt(text.length - 1)
+    // The head never grows, so eviction never rescans a growing string.
+    if (tail !== undefined && tail !== this.head && tail.text.length + text.length <= COALESCED_CHUNK_UNITS) {
+      tail.text += text
+    } else {
+      if (tail !== undefined && tail.text.length <= COALESCED_CHUNK_UNITS) {
+        // Copy coalesced fragments into one string; large tails already own their storage.
+        tail.text = Buffer.from(tail.text, 'utf16le').toString('utf16le')
       }
       }
+      const chunk: TextChunk = { text, start: 0, next: undefined }
+      if (tail === undefined) this.head = chunk
+      else tail.next = chunk
+      this.tail = chunk
+    }
+
+    while (this.head !== undefined
+      && (this.bytes > this.maxBytes || (this.maxLines !== undefined && this.newlines >= this.maxLines))) {
+      const head = this.head
+      const first = head.text.charCodeAt(head.start)
+      const second = head.start + 1 < head.text.length
+        ? head.text.charCodeAt(head.start + 1)
+        : head.next?.text.charCodeAt(0)
+      const paired = first >= 0xd800 && first <= 0xdbff
+        && second !== undefined && second >= 0xdc00 && second <= 0xdfff
+      this.bytes -= paired ? 4 : first < 0x80 ? 1 : first < 0x800 ? 2 : 3
+      if (first === 10) this.newlines -= 1
+      this.advance(paired ? 2 : 1)
+      this.dropped = true
+    }
+    const head = this.head
+    if (head !== undefined && head.start >= head.text.length / 2) {
+      // Copy UTF-16 verbatim so a small suffix cannot retain an oversized input's backing store.
+      // Copying only after discarding at least half keeps this work amortized over discarded text.
+      head.text = Buffer.from(head.text.slice(head.start), 'utf16le').toString('utf16le')
+      head.start = 0
     }
     }
-    const tail = utf8Tail(this.value, this.maxBytes)
-    this.value = tail.text
-    this.dropped ||= tail.truncated
+  }
+
+  private advance(units: number): void {
+    while (units > 0 && this.head !== undefined) {
+      const head = this.head
+      const count = Math.min(units, head.text.length - head.start)
+      head.start += count
+      units -= count
+      if (head.start === head.text.length) this.head = head.next
+    }
+    if (this.head === undefined) this.tail = undefined
   }
   }
 
 
   consume(): TerminalSendRead {
   consume(): TerminalSendRead {
-    const delta = this.value
-    const truncated = this.dropped
-    this.value = ''
+    const { text: delta, truncated } = this.snapshot()
+    this.head = undefined
+    this.tail = undefined
+    this.bytes = 0
+    this.newlines = 0
     this.dropped = false
     this.dropped = false
     return { delta, truncated }
     return { delta, truncated }
   }
   }
 
 
   snapshot(): { text: string; truncated: boolean } {
   snapshot(): { text: string; truncated: boolean } {
-    return { text: this.value, truncated: this.dropped }
+    const chunks: string[] = []
+    for (let chunk = this.head; chunk !== undefined; chunk = chunk.next) {
+      chunks.push(chunk.text.slice(chunk.start))
+    }
+    return { text: chunks.join(''), truncated: this.dropped }
   }
   }
 }
 }
 
 
@@ -495,7 +572,7 @@ export class LocalPtySession implements TerminalBackendSession {
         return
         return
       }
       }
       const elapsed = Date.now() - operation.startedAt
       const elapsed = Date.now() - operation.startedAt
-      const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
+      const startupHasOutput = !this.initializing || !this.scrollback.isEmpty
       const acceptsStdinWait = startupHasOutput && foreground !== undefined
       const acceptsStdinWait = startupHasOutput && foreground !== undefined
         && operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
         && operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
       if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
       if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
@@ -620,7 +697,7 @@ export class LocalPtySession implements TerminalBackendSession {
   private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void {
   private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void {
     const operation = this.active
     const operation = this.active
     if (operation === undefined) return
     if (operation === undefined) return
-    const scrollbackTruncated = this.scrollback.snapshot().truncated
+    const scrollbackTruncated = this.scrollback.truncated
     if (retainOwnership) {
     if (retainOwnership) {
       this.stopPolling()
       this.stopPolling()
       this.activeAbort?.()
       this.activeAbort?.()

+ 295 - 0
packages/terminal/terminal-bash/tests/session-buffer.spec.ts

@@ -0,0 +1,295 @@
+import { Buffer } from 'node:buffer'
+import { PassThrough } from 'node:stream'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import type { TerminalReadRequest } from '@deepseek-ai/dsh-terminal'
+import type { ResolvedConfig } from '../src/config.ts'
+import { LocalPtySession } from '../src/session.ts'
+
+class OutputProducer implements SubprocessTerminalHandle {
+  readonly pid = 123
+  readonly output = new PassThrough()
+  private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
+  readonly done = this.outcome.promise
+
+  emit(text: string | Uint8Array): void {
+    this.output.write(typeof text === 'string' ? Buffer.from(text) : text)
+  }
+
+  exit(): void {
+    this.output.end()
+    this.outcome.resolve({ exitCode: 0, signal: null })
+  }
+
+  async write(): Promise<void> {}
+  async resize(): Promise<void> {}
+  async inspectForeground() { return undefined }
+  async signalForeground(): Promise<number> { return this.pid }
+  async terminate(): Promise<void> {
+    this.exit()
+    await this.done
+  }
+}
+
+const sessions: LocalPtySession[] = []
+
+afterEach(async () => {
+  await Promise.all(sessions.splice(0).map(session => session.close('buffer test cleanup')))
+})
+
+function fixture(overrides: Partial<ResolvedConfig> = {}) {
+  const config: ResolvedConfig = {
+    backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
+    scrollbackLines: 10_000, scrollbackMaxBytes: 4 * 1024 * 1024, maxReadBytes: 256 * 1024,
+    pollIntervalMs: 60_000, exactProbeAfterMs: 60_000, idleSilenceMs: 60_000,
+    handoffGraceMs: 60_000, timeoutMs: 60_000, disposeGraceMs: 60_000,
+    ...overrides,
+  }
+  const producer = new OutputProducer()
+  const session = new LocalPtySession(producer, config)
+  sessions.push(session)
+  return { producer, session }
+}
+
+// The reference deliberately retains the eager line-first, then UTF-8-tail algorithm.
+function referenceTail(text: string, maxBytes: number) {
+  if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
+  const chars = Array.from(text)
+  let bytes = 0
+  let start = chars.length
+  while (start > 0) {
+    const size = Buffer.byteLength(chars[start - 1] as string)
+    if (bytes + size > maxBytes) break
+    bytes += size
+    start -= 1
+  }
+  return { text: chars.slice(start).join(''), truncated: true }
+}
+
+class ReferenceBuffer {
+  private text = ''
+  private truncated = false
+
+  constructor(private readonly maxBytes: number, private readonly maxLines?: number) {}
+
+  append(chunk: string): void {
+    if (chunk.length === 0) return
+    this.text += chunk
+    if (this.maxLines !== undefined) {
+      const lines = this.text.split('\n')
+      if (lines.length > this.maxLines) {
+        this.text = lines.slice(-this.maxLines).join('\n')
+        this.truncated = true
+      }
+    }
+    const bounded = referenceTail(this.text, this.maxBytes)
+    this.text = bounded.text
+    this.truncated ||= bounded.truncated
+  }
+
+  snapshot() { return { text: this.text, truncated: this.truncated } }
+
+  consume() {
+    const result = { delta: this.text, truncated: this.truncated }
+    this.text = ''
+    this.truncated = false
+    return result
+  }
+}
+
+function referenceRead(buffer: ReferenceBuffer, maxBytes: number, request: TerminalReadRequest = {}) {
+  const snapshot = buffer.snapshot()
+  const lines = snapshot.text.split('\n')
+  const totalLines = snapshot.text.length === 0 ? 0 : lines.length
+  const offset = request.offset ?? 0
+  if (offset >= totalLines) {
+    return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
+  }
+  const end = totalLines - offset
+  const bounded = referenceTail(lines.slice(Math.max(0, end - (request.count ?? 500)), end).join('\n'), maxBytes)
+  const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
+  return {
+    text: bounded.text, totalLines, lineBegin: offset, lineEnd: offset + returnedLines,
+    truncated: snapshot.truncated || bounded.truncated,
+  }
+}
+
+function deterministicChunks(alphabet: readonly string[], count: number): string[] {
+  let state = 0x12345678
+  return Array.from({ length: count }, () => {
+    state = (Math.imul(state, 1664525) + 1013904223) >>> 0
+    return alphabet[state % alphabet.length] as string
+  })
+}
+
+describe('LocalPtySession incremental output compatibility', () => {
+  it('retains the exact tail across the default 4 MiB scrollback limit while consuming active output', async () => {
+    const limit = 4 * 1024 * 1024
+    const { producer, session } = fixture({ maxReadBytes: limit })
+    const operation = session.startSend({ text: '', submit: false })
+    const chunk = 'a'.repeat(4096)
+    for (let index = 0; index < limit / chunk.length; index += 1) producer.emit(chunk)
+    expect(session.read({})).toEqual({
+      text: 'a'.repeat(limit), totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false,
+    })
+
+    producer.emit('界😀TAIL')
+    const retained = `${'a'.repeat(limit - 11)}界😀TAIL`
+    expect(session.read({})).toEqual({
+      text: retained, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: true,
+    })
+    expect(operation.readOutput()).toEqual({ delta: retained, truncated: true })
+    expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
+    producer.emit('é')
+    expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false })
+    producer.emit('done')
+    producer.exit()
+    await expect(operation.done).resolves.toEqual({
+      viewport: 'done', waitReason: 'session_exit',
+      sessionStatus: { kind: 'exited', exitCode: 0, signal: null }, truncated: true,
+    })
+    expect(operation.readOutput()).toEqual({ delta: 'done', truncated: false })
+    expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
+  })
+
+  it('counts trailing empty lines and keeps scrollback truncation sticky after empty reads', () => {
+    const { producer, session } = fixture({ scrollbackLines: 3, scrollbackMaxBytes: 64, maxReadBytes: 64 })
+    producer.emit('a\nb\n')
+    expect(session.read({})).toEqual({ text: 'a\nb\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: false })
+    producer.emit('\n')
+    expect(session.read({})).toEqual({ text: 'b\n\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: true })
+    expect(session.read({ count: 1 })).toEqual({ text: '', totalLines: 3, lineBegin: 0, lineEnd: 0, truncated: true })
+    expect(session.read({ offset: 2, count: 1 }).text).toBe('b')
+    expect(session.read({ offset: 3 }).truncated).toBe(true)
+    producer.emit('')
+    producer.emit('c')
+    expect(session.read({}).text).toBe('b\n\nc')
+    expect(session.read({}).truncated).toBe(true)
+  })
+
+  it('decodes split UTF-8 before byte eviction and bounds an oversized multibyte chunk', async () => {
+    const { producer, session } = fixture({ scrollbackMaxBytes: 11, maxReadBytes: 11 })
+    const operation = session.startSend({ text: '', submit: false })
+    producer.emit('abc')
+    const encoded = Buffer.from('界😀éz')
+    producer.emit(encoded.subarray(0, 5))
+    producer.emit(encoded.subarray(5, 7))
+    producer.emit(encoded.subarray(7))
+    expect(session.read({}).text).toBe('c界😀éz')
+    expect(operation.readOutput()).toEqual({ delta: 'c界😀éz', truncated: true })
+    producer.emit('界😀'.repeat(1000) + 'éEND')
+    expect(session.read({}).text).toBe('😀éEND')
+    expect(operation.readOutput()).toEqual({ delta: '😀éEND', truncated: true })
+    expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
+    producer.emit('ok')
+    producer.exit()
+    expect((await operation.done).viewport).toBe('ok')
+  })
+
+  it('resets operation truncation independently of retained scrollback', async () => {
+    const { producer, session } = fixture({ scrollbackMaxBytes: 128, maxReadBytes: 5 })
+    const operation = session.startSend({ text: '', submit: false })
+    for (let index = 0; index < 4; index += 1) {
+      producer.emit('123456')
+      expect(operation.readOutput()).toEqual({ delta: '23456', truncated: true })
+      expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
+      producer.emit('é')
+      expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false })
+    }
+    producer.exit()
+    expect(await operation.done).toMatchObject({ viewport: '', truncated: false, waitReason: 'session_exit' })
+    expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
+  })
+
+  it.each([[1, 1, 1], [17, 7, 3], [64, 13, 5]])(
+    'matches eager reads and active output with byte caps %i/%i and %i lines',
+    async (scrollbackMaxBytes, maxReadBytes, scrollbackLines) => {
+      const { producer, session } = fixture({ scrollbackMaxBytes, maxReadBytes, scrollbackLines })
+      const scrollback = new ReferenceBuffer(scrollbackMaxBytes, scrollbackLines)
+      const output = new ReferenceBuffer(maxReadBytes)
+      const operation = session.startSend({ text: '', submit: false })
+      const chunks = deterministicChunks(['a', 'bc', '\n', '\n\n', '界', '😀', 'éz', '', 'long line\nend\n'], 120)
+      for (const [index, chunk] of chunks.entries()) {
+        producer.emit(chunk)
+        scrollback.append(chunk)
+        output.append(chunk)
+        for (const request of [{}, { offset: 1, count: 2 }, { offset: 9, count: 1 }]) {
+          expect(session.read(request)).toEqual(referenceRead(scrollback, maxReadBytes, request))
+        }
+        if (index % 7 === 0) expect(operation.readOutput()).toEqual(output.consume())
+      }
+      producer.exit()
+      const expected = output.snapshot()
+      expect(await operation.done).toMatchObject({
+        viewport: expected.text, truncated: expected.truncated || scrollback.snapshot().truncated,
+        waitReason: 'session_exit',
+      })
+      expect(operation.readOutput()).toEqual(output.consume())
+      expect(operation.readOutput()).toEqual(output.consume())
+    },
+  )
+
+  it('retains tiny producer chunks through multiple coalesced-node rollovers', async () => {
+    const limit = 5000
+    const { producer, session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit })
+    const operation = session.startSend({ text: '', submit: false })
+    const text = '0123456789'.repeat(1000)
+    const checkpoints = new Set([1, 4096, 4097, 5000, 5001, 8193, text.length])
+    for (let index = 0; index < text.length; index += 1) {
+      producer.emit(text[index] as string)
+      if (checkpoints.has(index + 1)) {
+        expect(session.read({})).toEqual({
+          text: text.slice(Math.max(0, index + 1 - limit), index + 1),
+          totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: index + 1 > limit,
+        })
+      }
+    }
+    expect(operation.readOutput()).toEqual({ delta: text.slice(-limit), truncated: true })
+    expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
+    producer.emit('fresh')
+    producer.exit()
+    expect(await operation.done).toMatchObject({ viewport: 'fresh', truncated: true, waitReason: 'session_exit' })
+  })
+
+  it('preserves split surrogate pairs when copied coalesced text reaches the eviction head', () => {
+    const limit = 4100
+    const { session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit })
+    // Lone UTF-16 halves cannot pass through the session's TextDecoder unchanged.
+    const buffer = session['scrollback']
+    const reference = new ReferenceBuffer(limit, 10_000)
+    const chunks = ['H', 'a'.repeat(4095), '\ud83d', '\ude00', 'xy', 'b'.repeat(4094), 'z', '\ud800', '\udfff']
+    for (const chunk of chunks) {
+      buffer.append(chunk)
+      reference.append(chunk)
+      expect(buffer.snapshot()).toEqual(reference.snapshot())
+    }
+    expect(buffer.snapshot()).toEqual({ text: `y${'b'.repeat(4094)}z\ud800\udfff`, truncated: true })
+    expect(buffer.consume()).toEqual(reference.consume())
+    expect(buffer.consume()).toEqual(reference.consume())
+  })
+
+  it.each([1, 2, 3, 4, 5, 8, 17])('preserves lone and split surrogates with a %i-byte limit', (maxBytes) => {
+    const { session } = fixture({ scrollbackMaxBytes: maxBytes, maxReadBytes: maxBytes, scrollbackLines: 3 })
+    // TextDecoder replaces lone surrogates; only these UTF-16 cases use the private buffer.
+    const buffer = session['scrollback']
+    const reference = new ReferenceBuffer(maxBytes, 3)
+    const chunks = [
+      '\ud83d', '\ude00', 'x', '\ud83d', '', '\ude00', '\n', '\ud800', 'abc', '\udfff',
+      'prefix'.repeat(20) + '\ud800', '\udfff', '\n\n\n',
+      ...deterministicChunks(['a', '\ud800', '\udfff', '\ud83d\ude00', '\n', 'é', '界', '', '\n\n'], 150),
+    ]
+    for (const [index, chunk] of chunks.entries()) {
+      buffer.append(chunk)
+      reference.append(chunk)
+      expect(buffer.snapshot()).toEqual(reference.snapshot())
+      if (index % 19 === 18) {
+        expect(buffer.consume()).toEqual(reference.consume())
+        expect(buffer.consume()).toEqual(reference.consume())
+      }
+    }
+    expect(buffer.consume()).toEqual(reference.consume())
+    buffer.append('x')
+    reference.append('x')
+    expect(buffer.snapshot()).toEqual(reference.snapshot())
+  })
+})

+ 21 - 0
packages/terminal/terminal-bash/tests/session.spec.ts

@@ -155,6 +155,27 @@ async function initialize(session: LocalPtySession, terminal: FakeTerminal): Pro
 }
 }
 
 
 describe('LocalPtySession readiness and output', () => {
 describe('LocalPtySession readiness and output', () => {
+  it('polls startup and settles sends without assembling scrollback for status checks', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const session = new LocalPtySession(terminal, config())
+    const snapshot = vi.spyOn(session['scrollback'], 'snapshot')
+    try {
+      const pending = session.initialize()
+      await vi.advanceTimersByTimeAsync(20)
+      expect(snapshot).not.toHaveBeenCalled()
+      terminal.emitData('x'.repeat(200) + '\x1b]133;D;0\x07dsh> ')
+      await vi.advanceTimersByTimeAsync(20)
+      await pending
+      expect(snapshot).not.toHaveBeenCalled()
+      expect(session.read({})).toMatchObject({ text: 'x'.repeat(59) + 'dsh> ', truncated: true })
+      expect(snapshot).toHaveBeenCalledTimes(1)
+    } finally {
+      snapshot.mockRestore()
+      await session.close('status check cleanup')
+    }
+  })
+
   it('answers split cursor-position queries before publishing prompt readiness', async () => {
   it('answers split cursor-position queries before publishing prompt readiness', async () => {
     vi.useFakeTimers()
     vi.useFakeTimers()
     const terminal = new FakeTerminal()
     const terminal = new FakeTerminal()

+ 9 - 0
pnpm-lock.yaml

@@ -766,6 +766,12 @@ importers:
       '@deepseek-ai/dsh-subagent':
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../packages/subagent/subagent
         version: link:../packages/subagent/subagent
+      '@deepseek-ai/dsh-subprocess':
+        specifier: workspace:^
+        version: link:../packages/subprocess/subprocess
+      '@deepseek-ai/dsh-terminal':
+        specifier: workspace:^
+        version: link:../packages/terminal/terminal
       '@deepseek-ai/dsh-token-meter':
       '@deepseek-ai/dsh-token-meter':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../packages/llm/token-meter
         version: link:../packages/llm/token-meter
@@ -775,6 +781,9 @@ importers:
       '@deepseek-ai/dsh-typert-protocol':
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../packages/typert/protocol
         version: link:../packages/typert/protocol
+      '@xterm/headless':
+        specifier: ^6.0.0
+        version: 6.0.0
       playwright:
       playwright:
         specifier: ^1.49.0
         specifier: ^1.49.0
         version: 1.61.1
         version: 1.61.1