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

Merge remote-tracking branch 'origin/master' into HEAD

lsdsjy 2 недель назад
Родитель
Сommit
79bd986fa7
91 измененных файлов с 657 добавлено и 65 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  5. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  6. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml
  8. 51 0
      .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md
  9. 51 0
      .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md
  10. 6 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml
  11. 39 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md
  12. 39 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md
  13. 2 2
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
  14. 2 2
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
  15. 2 2
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md
  16. 8 0
      .github/workflows/ci.yml
  17. 13 0
      .oxlintrc.json
  18. 2 2
      docs/config-catalog.i18n.yaml
  19. 1 1
      docs/config-catalog.md
  20. 1 1
      docs/config-catalog.zh.md
  21. 2 2
      docs/subsystems/session.i18n.yaml
  22. 6 0
      docs/subsystems/session.md
  23. 6 0
      docs/subsystems/session.zh.md
  24. 2 0
      packages/api/session-controller/src/commands.ts
  25. 1 0
      packages/api/session-controller/src/history.ts
  26. 1 0
      packages/api/session-controller/src/index.ts
  27. 1 0
      packages/bundle/headless/src/index.ts
  28. 6 3
      packages/compaction/compaction-basic/src/region.ts
  29. 1 0
      packages/compaction/compaction-tool-result-pruner/src/index.ts
  30. 1 0
      packages/compaction/compaction/src/invariant.ts
  31. 1 0
      packages/compaction/compaction/src/tool-pairing.ts
  32. 2 0
      packages/context/agent-instructions/src/index.ts
  33. 1 0
      packages/context/agent-instructions/src/state.ts
  34. 1 0
      packages/context/time-context/src/index.ts
  35. 2 0
      packages/context/time-context/src/invariant.ts
  36. 1 0
      packages/core/agent-loop/src/index.ts
  37. 1 0
      packages/core/agent-loop/src/invariant.ts
  38. 2 0
      packages/core/agent-loop/src/runtime-context.ts
  39. 2 2
      packages/core/session/README.i18n.yaml
  40. 1 1
      packages/core/session/README.md
  41. 1 1
      packages/core/session/README.zh.md
  42. 10 0
      packages/core/session/src/index.ts
  43. 1 0
      packages/core/session/src/invariant.ts
  44. 1 0
      packages/core/tools/src/invariant.ts
  45. 1 0
      packages/experimental/agent-team/src/mailbox.ts
  46. 2 0
      packages/experimental/agent-team/src/roster.ts
  47. 21 8
      packages/experimental/code-runtime-python/tests/runtime.spec.ts
  48. 2 0
      packages/feedback/message-feedback/src/index.ts
  49. 2 0
      packages/goal/goal-round-driver/src/invariant.ts
  50. 1 0
      packages/goal/goal/src/invariant.ts
  51. 1 0
      packages/goal/tool-goal/src/authority.ts
  52. 1 0
      packages/hooks/hook-protocol/src/invariant.ts
  53. 35 9
      packages/host/directory-picker-native/tests/win32-dialog.spec.ts
  54. 2 0
      packages/interaction/commands/src/invariant.ts
  55. 1 0
      packages/interaction/permission-presets/src/invariant.ts
  56. 2 0
      packages/interaction/user-approval/src/index.ts
  57. 1 0
      packages/interaction/user-approval/src/invariant.ts
  58. 3 0
      packages/llm/llm-retry/src/invariant.ts
  59. 2 1
      packages/llm/token-meter/src/index.ts
  60. 1 0
      packages/plan/plan-mode/src/invariant.ts
  61. 1 0
      packages/sandbox/sandbox-policy/src/invariant.ts
  62. 1 0
      packages/schedule/schedule/src/index.ts
  63. 3 0
      packages/schedule/schedule/src/invariant.ts
  64. 1 0
      packages/schedule/schedule/src/runtime.ts
  65. 1 0
      packages/schedule/schedule/src/tools.ts
  66. 1 0
      packages/session-query/session-query-sqlite/src/index.ts
  67. 2 0
      packages/session-query/session-query/src/corpus.ts
  68. 1 0
      packages/session-query/session-query/src/observation.ts
  69. 3 0
      packages/session/session-log-deepseek/src/index.ts
  70. 1 0
      packages/session/session-log-deepseek/src/invariant.ts
  71. 3 0
      packages/session/session-projection/src/index.ts
  72. 1 0
      packages/session/session-telemetry-otel/src/index.ts
  73. 1 0
      packages/session/session-telemetry/src/coordinator.ts
  74. 2 0
      packages/session/session-title/src/index.ts
  75. 2 0
      packages/session/session-title/src/invariant.ts
  76. 1 0
      packages/skill/tool-skill/src/index.ts
  77. 15 5
      packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
  78. 1 0
      packages/subagent/subagent-fork-in-process/src/index.ts
  79. 1 0
      packages/subagent/subagent-in-process-driver/src/index.ts
  80. 1 0
      packages/subagent/subagent/src/lifecycle.ts
  81. 1 0
      packages/subagent/tool-subagent/src/index.ts
  82. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  83. 1 1
      packages/subprocess/subprocess-local/README.md
  84. 1 1
      packages/subprocess/subprocess-local/README.zh.md
  85. 47 4
      packages/subprocess/subprocess-local/src/linux-scope.ts
  86. 77 0
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  87. 22 2
      packages/subprocess/subprocess-local/tests/local.spec.ts
  88. 1 0
      packages/todo/tool-todo/src/invariant.ts
  89. 1 0
      packages/workflow/tool-workflow/src/invariant.ts
  90. 1 1
      scripts/lint-rule-fingerprint.spec.ts
  91. 93 0
      scripts/oxlint-contract.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
-2026-06-11-dev-invariants-over-deep-readonly.md: 9adb741db1e26e3a9f750fed60524351159cfeaa
-2026-06-11-dev-invariants-over-deep-readonly.zh.md: d352eca985b6594ec5d10c9b73c2ecc18ea458b5
+2026-06-11-dev-invariants-over-deep-readonly.md: 0784960397f4d198e136e284ef9232b4ed3e2c2c
+2026-06-11-dev-invariants-over-deep-readonly.zh.md: 7183813130560bf82426c8719570b668ba86b7e5

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md

@@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel
 
 `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
 
-The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
+The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` reads the current length without materializing an array. Synchronous historical readers are deprecated under the [event-read policy](2026-09-09-deprecate-synchronous-session-event-reads.md). Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
 
 This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
 
@@ -53,7 +53,7 @@ Detaching `deriveMessages()` would protect the most common request path but leav
 ## Consequences
 
 - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
-- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array.
+- Existing `snapshotEvents()` and `eventAt()` callers retain immutable read results while their migration is deferred; `seq` reads the log length without copying the array.
 - Request-side mutation cannot reach stored history through derived messages.
 - Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability.
 - `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package.

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md

@@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
 
 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
 
-被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。
+被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。`seq` 无需物化数组即可读取当前长度。同步历史读取方法按[事件读取策略](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)弃用。种子记录在构造成功前经过相同的验证、快照与冻结边界。
 
 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
 
@@ -53,7 +53,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
 ## 后果
 
 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
-- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径。
+- 现有 `snapshotEvents()` 和 `eventAt()` 调用方在暂缓迁移期间仍获得不可变的读取结果;`seq` 无需复制数组即可读取日志长度。
 - 请求侧的修改无法通过派生消息触及已存储的历史。
 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。
 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 8e1e12a8536f56c9ccb515cec4c07c730c8b9b84
-2026-08-28-subprocess-native-containment.zh.md: 83bc20a29d937bca9530ca107712e4a7b39d8d4d
+2026-08-28-subprocess-native-containment.md: 7523f9d66e7a302f6ce9c77d93671a5b1303a5aa
+2026-08-28-subprocess-native-containment.zh.md: 252a8e9fd058cf37a1a7a70f09394a6811d58580

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e
 
 The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
-Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
+Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads `LoadState`, `ActiveState`, and `TasksCurrent`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal, except that once termination was requested and the launcher has exited, a still-active unit reporting no processes is an empty range: the manager ends a scope only on the populated-to-empty transition, so a payload killed before it entered the cgroup never triggers one. That conclusion stops the leftover unit so transient units cannot accumulate, while an unreported or `[not set]` process count stays unknown and keeps waiting. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
 The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
 
@@ -54,7 +54,7 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
-- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
+- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
 - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
 - A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement.
 - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
-request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
+request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询读取 `LoadState`、`ActiveState` 与 `TasksCurrent`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态;例外是:一旦请求过终止且 launcher 已经退出,报告没有任何进程却仍 active 的 unit 就是空 range——manager 只在观测到 populated→empty 转变时才结束 scope,因此进入 cgroup 前就被杀死的 payload 永远不会触发该转变。该判定会 stop 掉这个遗留 unit,使 transient unit 不会累积;未上报或为 `[not set]` 的进程数仍视为未知并继续等待。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
 
@@ -54,7 +54,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
-- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
+- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
 - 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。
 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md
+2026-09-09-deprecate-synchronous-session-event-reads.md: a2a86b0d6500269531ca1088738aaa612076bfb4
+2026-09-09-deprecate-synchronous-session-event-reads.zh.md: 38468708a5323b6ebf94377f3cc8f2c86dc95dde

+ 51 - 0
.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md

@@ -0,0 +1,51 @@
+# Agent Note: Deprecate synchronous reads of arbitrary Session events
+
+Status: implemented
+
+English | [中文](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)
+
+## Problem
+
+Synchronous access to arbitrary event positions makes consumers depend on the complete Session event sequence being immediately available in memory. The storage direction is to stop retaining that complete sequence in memory. Once historical events require storage I/O, the runtime cannot preserve the same synchronous read guarantee without retaining the history or blocking on storage.
+
+New callers increase that dependency even when they read only one old event. Repeated history scans after resume also make ordinary domain logic depend on historical storage instead of the state it actually needs.
+
+## Decision
+
+All operations that synchronously read arbitrary positions or ranges of Session event history are deprecated, including `Session.eventAt()`, `Session.snapshotEvents()`, and `Session.ownEvents()`. Existing logic may remain unmigrated for now, but new calls are prohibited. New aliases or wrappers that expose the same synchronous historical access are prohibited as well.
+
+The three methods carry this rule in `@deprecated` JSDoc. This is an API-use decision; the current Session implementation still retains the complete event sequence in memory.
+
+Repository test files, including `scripts/**/*.spec.{ts,tsx}`, may call these three readers to inspect emitted events and exercise Session history behavior. The test-file lint override allows `snapshotEvents`, `eventAt`, and `ownEvents`; all other deprecated names remain errors. This allowance also covers unrelated declarations with the same three names under the current linter. It does not apply to production source or non-test repository scripts.
+
+### State needed after resume
+
+Design durable event fields and Session projections together so each domain can reconstruct the state its consumers need. Restore that state during resume, then maintain it incrementally from newly committed events. After resume, ordinary logic reads the projection or processes the delivered current event instead of looking back through historical events. Reading already-maintained projection state synchronously does not require arbitrary access to the event log.
+
+Historical content presented on demand uses explicit asynchronous pagination and progressive loading, with each read limited to the requested window. Loading the complete sequence behind a synchronous helper preserves the dependency this decision removes.
+
+### Operations that require complete history
+
+Fork and a small number of operations may genuinely need a complete historical sequence or inherited prefix. Their need for those records remains valid and requires an explicit storage read. It does not require the whole sequence to remain resident or grant an exception for new calls to deprecated synchronous readers. Each such consumer must establish why its result requires the complete sequence rather than projected state or a limited historical window.
+
+## Alternatives considered
+
+**Keep synchronous single-event reads while deprecating only full snapshots.** A single requested event may also be absent from memory. Restricting the result size does not remove the storage dependency, and helpers such as `ownEvents()` retain the same assumption for a suffix.
+
+**Keep the complete sequence resident to preserve the read APIs.** This lets consumer convenience dictate Session memory retention and prevents the intended storage design. Projections preserve required state, while explicit historical reads preserve access to the records themselves.
+
+**Require every existing caller to migrate immediately.** Existing logic may defer migration under this decision. Preventing new dependencies bounds the remaining work without making every existing consumer part of the same change.
+
+**Disable deprecation lint throughout test files or exempt production readers by name.** Tests only need the three reader names; other deprecated APIs must remain errors. Production code retains the prohibition on new synchronous reads.
+
+**Add a separate test-only reader API.** The three existing readers already expose the observations these tests need. Wrapping them adds an API and production-import checks without changing those observations.
+
+## Consequences
+
+New domain behavior must make its event data and projected state sufficient for resumed execution. User-requested history may still load progressively, and genuine full-history operations still have a storage path to design. This decision does not claim that resume or fork already avoids loading the complete log.
+
+Calls outside test files carry line-scoped `typescript/no-deprecated` waivers that identify deferred migration or delegation between deprecated readers. Remove a waiver when its deprecated call is removed; copying a waiver to a new production call violates this policy. The executable lint check accepts test reads and existing waived reads, rejects unwaived production reads, and rejects unrelated deprecated APIs in tests. Documentation checks verify the source-equivalent API declarations and bilingual records.
+
+## Related decisions
+
+The [session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md) continues to own event ownership and freezing; this decision supersedes its recommendation to use synchronous historical readers. The [required projection reader decision](2026-08-19-session-projection-mandatory-seam.md) continues to own missing-state failures and typed state reads. The [recallable compaction proposal](../../proposed/feature/2026-07-06-recallable-compaction.md) retains its recall design while replacing its proposed synchronous history access with paged reads.

+ 51 - 0
.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: 弃用对会话任意位置事件的同步读取
+
+Status: implemented
+
+[English](2026-09-09-deprecate-synchronous-session-event-reads.md) | 中文
+
+## 问题
+
+同步访问任意事件位置,使消费方依赖完整会话事件序列在内存中随时可用。存储的演进方向是不再将完整序列保存在内存中。历史事件一旦需要存储 I/O,运行时若不继续保留历史或阻塞等待存储,就无法维持相同的同步读取保证。
+
+即使只读取一个旧事件,新增调用也会加深这种依赖。恢复后反复扫描历史,还会使普通领域逻辑依赖历史存储,而不是它真正需要的状态。
+
+## 决策
+
+弃用所有同步读取会话事件历史任意位置或区间的操作,包括 `Session.eventAt()`、`Session.snapshotEvents()` 和 `Session.ownEvents()`。现有逻辑可以暂不迁移,但禁止新增调用。同样禁止新增暴露相同同步历史访问能力的别名或包装层。
+
+这三个方法通过 `@deprecated` JSDoc 声明该规则。这是 API 使用决策;当前 Session 实现仍在内存中保留完整事件序列。
+
+仓库测试文件(包括 `scripts/**/*.spec.{ts,tsx}`)可以调用这三个读取方法,以检查已发出的事件并验证 Session 历史行为。测试文件 lint override 允许 `snapshotEvents`、`eventAt` 和 `ownEvents`,其他弃用名称仍报错。在当前 linter 下,此豁免也覆盖使用这三个名称的其他声明。它不适用于生产源码或非测试仓库脚本。
+
+### 恢复后需要的状态
+
+结合设计持久事件字段与会话投影,使每个领域都能重建其消费方所需的状态。在恢复期间还原这些状态,随后通过新提交的事件增量维护。恢复后,普通逻辑读取投影或处理当前收到的事件,而不是回头查找历史事件。同步读取已经维护好的投影状态,不需要任意访问事件日志。
+
+按需展示的历史内容采用显式异步分页和渐进式加载,每次读取限制在请求的窗口内。在同步辅助方法背后加载完整序列,仍保留了本决策要消除的依赖。
+
+### 确实需要完整历史的操作
+
+fork 等少数操作可能确实需要完整历史序列或继承前缀。它们对这些记录的需求仍然成立,需要通过显式存储读取来满足。这不要求完整序列常驻内存,也不构成新增已弃用同步读取调用的例外。每个此类消费方都必须说明,为什么其结果需要完整序列,而不能只使用投影状态或局部历史窗口。
+
+## 曾考虑的替代方案
+
+**只弃用完整快照,保留同步单事件读取。** 单个被请求的事件也可能不在内存中。限制返回结果大小并不能消除存储依赖,`ownEvents()` 等辅助方法对后缀序列也保留着相同假设。
+
+**为保留读取 API 而让完整序列常驻内存。** 这会让消费方的便利性决定会话的内存保留方式,阻碍预期的存储设计。投影保留所需状态,显式历史读取则保留对记录本身的访问能力。
+
+**要求立即迁移所有现有调用方。** 本决策允许现有逻辑推迟迁移。禁止新增依赖可以限制剩余工作规模,无需将每个现有消费方都纳入同一次修改。
+
+**在整个测试文件中禁用弃用 lint,或按名称豁免生产代码中的读取方法。** 测试只需要这三个读取方法名;其他弃用 API 必须继续报错。生产代码仍禁止新增同步读取。
+
+**添加单独的测试专用读取 API。** 三个现有读取方法已经提供这些测试所需的观察结果。包装它们会增加一个 API 及生产代码导入检查,却不会改变这些观察结果。
+
+## 后果
+
+新增领域行为必须使其事件数据与投影状态足以支持恢复后的执行。用户请求的历史仍可渐进式加载,确实需要完整历史的操作仍需设计存储读取路径。本决策不表示恢复或 fork 已经能够避免加载完整日志。
+
+测试文件之外的调用带有逐行的 `typescript/no-deprecated` 豁免,说明暂缓迁移或已弃用读取方法之间的委托。删除已弃用调用时应一并删除其豁免;将豁免复制到新增生产调用违反本策略。实际执行 lint 的检查允许测试读取和已有豁免调用,拒绝未豁免的生产读取,并拒绝测试中其他已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。
+
+## 相关决策
+
+[会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)继续负责事件所有权与冻结;本决策取代其中对同步历史读取方法的使用建议。[投影读取必需性决策](2026-08-19-session-projection-mandatory-seam.zh.md)继续负责缺失状态时的失败规则与类型化状态读取。[可回溯压缩提案](../../proposed/feature/2026-07-06-recallable-compaction.zh.md)保留其回溯设计,并将其中拟议的同步历史访问替换为分页读取。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.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/testing/2026-09-10-hosted-image-test-assumptions.md
+2026-09-10-hosted-image-test-assumptions.md: e6cd1ef37dce318920d75545f816006a98989076
+2026-09-10-hosted-image-test-assumptions.zh.md: e60910aebdc2952a2bfb6ab25963a973ea41ac87

+ 39 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md

@@ -0,0 +1,39 @@
+# Agent Note: Hosted-image assumptions in the coverage suite
+
+Status: implemented
+
+English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md)
+
+## Problem
+
+The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright.
+
+## Decision
+
+Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image.
+
+Terminal cases that drive a mocked PTY exit pin the containment they need (`internals = { platform: 'darwin' }` in `packages/subprocess/subprocess-local/tests/local.spec.ts`); under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. Mocking the `linux-scope.ts` probes to reach the same path was removed: the platform pin skips both probes, so the mock could not change the selected path.
+
+`disposal contains a spawn-failure rejection that races teardown` asserts the settlement contract instead of one winner of the race: a bootstrap that published its pre-exec failure rejects with that failure, and a teardown that stopped the bootstrap first settles as the requested `SIGTERM`. Only the Linux scope records the stopped arm, because the win32 job owner turns a cancelled start into a rejection and the fallback launcher rejects the missing directory.
+
+`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. At 150ms the hosted image escalated while the scope could not take the signal — `systemctl` failed the kill (`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`) and the teardown reported a failure the configuration never asked for. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants.
+
+Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start.
+
+The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host.
+
+The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption.
+
+## Alternatives considered
+
+**Excluding the coverage lanes from the hosted leg.** Rejected: the leg exists to run the same suite on another pool, and the failures named real host dependencies rather than a suite the pool cannot support.
+
+**Raising only the lane's per-test budget.** Rejected: a wider budget does not change the cases whose cost or behaviour is deterministic — a trapped ACP child still waits out both graces, and a coalesced reader still inflates the measured peak.
+
+**Keeping the `linux-scope.ts` probe mocks.** Rejected as inert: the platform pin selects the fallback path before either probe is called, so the mock changed no execution path.
+
+**Cutting the illegal-UTF-8 payloads to keep the cases fast.** Rejected: below the 2048 bound the assertion can no longer fail for the undercount it names, which leaves the regression unguarded.
+
+## Consequences
+
+The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`).

+ 39 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: coverage 套件里的托管镜像假设
+
+Status: implemented
+
+[English](2026-09-10-hosted-image-test-assumptions.md) | 中文
+
+## 问题
+
+[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。
+
+## 决策
+
+每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论。
+
+驱动被 mock 的 PTY 退出的终端用例钉死自己需要的 containment(`packages/subprocess/subprocess-local/tests/local.spec.ts` 中的 `internals = { platform: 'darwin' }`);在宿主的原生 scope 下,被 mock 的退出会与 scope 的 bootstrap 竞争,并以 `terminal scope exited before its bootstrap consumed the launch request` 失败。为走到同一路径而 mock `linux-scope.ts` 的探针已被删除:平台钉死会让两个探针都不被调用,因此该 mock 无法改变选中的路径。
+
+`disposal contains a spawn-failure rejection that races teardown` 断言结算契约,而不是这场竞争的某一方获胜:已经发布其 pre-exec 失败的 bootstrap 以该失败 reject,先停住 bootstrap 的 teardown 则以被请求的 `SIGTERM` 结算。只有 Linux scope 会记录停止这一支,因为 win32 job owner 会把被取消的启动转成 rejection,fallback 启动器则因目录缺失而 reject。
+
+`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。在 150ms 时,托管镜像在 scope 还无法接受信号时就升级了信号——`systemctl` 的 kill 失败(`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`),teardown 上报了一个配置从未要求的失败。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。
+
+`packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。
+
+Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。
+
+Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。
+
+## 备选方案
+
+**把 coverage 通道排除出托管支路。** 否决:该支路的意义就是把同一套测试跑在另一个池上,而这些失败点出的是真实的宿主依赖,不是一个该池无法支撑的套件。
+
+**只抬高通道的每用例预算。** 否决:更宽的预算改变不了那些成本或行为确定的用例——被 trap 的 ACP 子进程仍然会等满两个宽限,被合并的读端仍然会抬高测得的峰值。
+
+**保留 `linux-scope.ts` 的探针 mock。** 因无效而否决:平台钉死会在任一探针被调用前就选中 fallback 路径,因此该 mock 没有改变任何执行路径。
+
+**削减非法 UTF-8 的载荷以让用例更快。** 否决:低于 2048 的界之后,断言再也无法为它所点名的少计而失败,等于让该回归失去守护。
+
+## 后果
+
+套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。

+ 2 - 2
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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/proposed/feature/2026-07-06-recallable-compaction.md
-2026-07-06-recallable-compaction.md: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448
-2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e
+2026-07-06-recallable-compaction.md: 07b89116b72a1c1aac2d79ee5aa41ff6e5d02a4c
+2026-07-06-recallable-compaction.zh.md: 7347cb08c94665a6e5bb88655ec08d04921d08eb

+ 2 - 2
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md

@@ -46,7 +46,7 @@ A new package `@deepseek-ai/dsh-tool-recall` (consumer-only, over the `dsh-sessi
 - `history_read(checkpoint, offset?)` — renders the shadowed span of any checkpoint in the log, including superseded ones, as `User:`/`Assistant:`/`Tool result:` transcript, paginated by a configured budget with a continuation cursor.
 - `history_search(query, checkpoint?, limit?)` — case-insensitive literal scan over every shadowed span; returns snippets with checkpoint ids and coverage metadata (`scanned`/`matched`/`truncated`). The zero-match hint notes the scan is literal and points at direct `history_read` of a plausible checkpoint.
 
-Both read `exec.agent.session.snapshotEvents()` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools.
+Both use explicit asynchronous, paged history reads under the [synchronous event-read deprecation](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md) (non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools.
 
 ### Cache and cost
 
@@ -86,7 +86,7 @@ Deferred until observation calls for them:
 - **One summarize call emitting all outputs** — rejected: the summarize path has no structured-output enforcement; parsing one free-text response apart is the fragile boundary the fail-closed design avoids.
 - **Model-chosen chunk boundaries** — deferred: parse-and-validate cost against unproven value; chunk policy sits behind config.
 - **Model-authored pointers** — rejected: pointers must be exact; deterministic assembly is.
-- **FTS/vector index sidecar** — rejected in-session: the live log is in memory and bounded, a literal scan under budget suffices; an index earns its keep at cross-session scope.
+- **FTS/vector index sidecar** — the rejection based on a resident, bounded live log is superseded by the [event-read policy](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). Reassess the index choice against paged historical reads before implementing recall.
 - **Semantic search fallback / secondary-model extraction in the recall path** — rejected: an LLM or embedding call there breaks keyless replay determinism; recall stays a pure function of the log.
 - **Raw events instead of rendered transcript** — rejected: leaks log-only vocabulary and chunk noise; the model reads what a model once saw.
 - **Doing nothing (resume/fork as recovery)** — rejected: it makes recovery a human act.

+ 2 - 2
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md

@@ -46,7 +46,7 @@ Status: proposed
 - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。
 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。
 
-两个工具都读取 `exec.agent.session.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。
+两个工具都按[同步事件读取弃用规则](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)使用显式异步分页历史读取(拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。
 
 ### 缓存与成本
 
@@ -86,7 +86,7 @@ Status: proposed
 - **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱边界。
 - **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略由配置控制。
 - **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。
-- **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值。
+- **FTS/向量索引伴随存储**:以实时日志常驻内存且大小有界为依据的否决,被[事件读取策略](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)取代。实现回溯前,需要针对分页历史读取重新评估索引选择。
 - **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。
 - **使用原始事件而不是渲染后的 transcript**:不予采纳,因为这会泄漏仅日志可见的词汇与分片噪声;模型应读取模型曾经看到的内容。
 - **什么都不做(用恢复/fork 补救)**:不予采纳,因为这会把恢复变成人工操作。

+ 8 - 0
.github/workflows/ci.yml

@@ -121,6 +121,14 @@ jobs:
       DSH_COVERAGE_MAX_WORKERS: '6'
       DSH_COVERAGE_PARTITIONS: '4'
       DSH_GATE_CONCURRENCY: '3'
+      # Managed-scope teardown cases run past the default per-test budget when
+      # this lane's partitions, workers, and sibling gates share one host: the
+      # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on
+      # the hosted image (run 34449848541) while the same commit stayed inside
+      # the budget on the in-house pool; the lane is green with this value
+      # (run 34457655892). The Windows coverage lane grants the same budget for
+      # the same reason.
+      DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
       # A gate failure aborts the sibling gate instead of waiting out its
       # multi-minute instrumented run.
       DSH_GATE_FAIL_FAST: '1'

+ 13 - 0
.oxlintrc.json

@@ -203,6 +203,19 @@
         "scripts/**/*.spec.{ts,tsx}"
       ],
       "rules": {
+        // Session assertions inspect the log; other deprecated APIs remain errors.
+        "typescript/no-deprecated": [
+          "error",
+          {
+            "allow": [
+              {
+                "from": "file",
+                "name": ["snapshotEvents", "eventAt", "ownEvents"],
+                "path": "packages/core/session/src/index.ts"
+              }
+            ]
+          }
+        ],
         "typescript/no-invalid-void-type": "error",
         "typescript/no-non-null-assertion": "off", // Assertions commonly follow an expect() that proves presence.
         "typescript/no-unnecessary-condition": "off",

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 29d268efb57badd0fa0caf2e2222aad118b3978f
-config-catalog.zh.md: b1be54d2dc2e7a4c0b1a9e1716b1a0928d372892
+config-catalog.md: 8d72cc52dad277a606a923c74723c34d9a2e0a4a
+config-catalog.zh.md: 1b70aabcba62de49619dd8a682a089449c314f9f

+ 1 - 1
docs/config-catalog.md

@@ -3240,7 +3240,7 @@ export interface Config {
 export type ApprovalPolicy = 'ask' | 'never'
 ```
 
-Source: [`packages/interaction/user-approval/src/index.ts:127`](../packages/interaction/user-approval/src/index.ts)
+Source: [`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts)
 
 <a id="deepseek-aidsh-web"></a>
 

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

@@ -3242,7 +3242,7 @@ export interface Config {
 export type ApprovalPolicy = 'ask' | 'never'
 ```
 
-来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts)
+来源:[`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts)
 
 <a id="deepseek-aidsh-web"></a>
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 2b99ca8267d35978f245e6bdbf020226c976e363
-session.zh.md: e6cff2d239caf7cd99be13463b412479a6f6e79b
+session.md: 7157dfd403d005112a75d0bbb46f99ad385b68df
+session.zh.md: 6cb157a8016f236f52d4f8963e6efe070f0b89e3

+ 6 - 0
docs/subsystems/session.md

@@ -473,6 +473,8 @@ declare class Session {
   ): Session;
   /**
    * Return the immutable event stored at one exact sequence number.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param seq - event sequence number.
    * @returns the accepted event, or undefined when the log does not contain it.
    */
@@ -481,6 +483,8 @@ declare class Session {
    * Materialize an immutable snapshot of a half-open event sequence range.
    * A full current snapshot is reused until the next append; every previously
    * returned snapshot remains stable after later appends.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
    * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
    * @returns a frozen array of the selected deeply frozen events.
@@ -491,6 +495,8 @@ declare class Session {
   ): readonly SessionEvent[];
   /**
    * Return this Session's events after its fork-inherited prefix.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @returns a fresh array containing child-owned events in log order.
    */
   ownEvents(): readonly SessionEvent[];

+ 6 - 0
docs/subsystems/session.zh.md

@@ -475,6 +475,8 @@ declare class Session {
   ): Session;
   /**
    * Return the immutable event stored at one exact sequence number.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param seq - event sequence number.
    * @returns the accepted event, or undefined when the log does not contain it.
    */
@@ -483,6 +485,8 @@ declare class Session {
    * Materialize an immutable snapshot of a half-open event sequence range.
    * A full current snapshot is reused until the next append; every previously
    * returned snapshot remains stable after later appends.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
    * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
    * @returns a frozen array of the selected deeply frozen events.
@@ -493,6 +497,8 @@ declare class Session {
   ): readonly SessionEvent[];
   /**
    * Return this Session's events after its fork-inherited prefix.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @returns a fresh array containing child-owned events in log order.
    */
   ownEvents(): readonly SessionEvent[];

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

@@ -541,6 +541,7 @@ export class SessionCommandController {
   private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
     const attached = this.ctx.sessions.get(sessionId)
     if (attached !== undefined) {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       return { id: attached.id, header: attached.header, events: attached.snapshotEvents() }
     }
     const inspected = await inspectApiSession(this.ctx, sessionId)
@@ -587,6 +588,7 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
     return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
   }
   if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   return agent.session.snapshotEvents().some((event) => {
     if (event.type !== 'user/message') return false
     const source = event.data.source

+ 1 - 0
packages/api/session-controller/src/history.ts

@@ -152,6 +152,7 @@ export class SessionHistoryController {
       // Constructor seed events have no session/event notification. Normally
       // only the end-seed suffix is new; if persistence advanced after the
       // opening observation, replay everything beyond that snapshot cursor.
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const suffix = session.snapshotEvents(snapshotCursor === undefined
         ? session.firstLiveSeq
         : SessionLogOffset(snapshotCursor + 1))

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

@@ -207,6 +207,7 @@ export class SessionController extends TypertRemoteService {
       return Promise.resolve({
         meta: attached.header,
         inheritedEventCount: attached.inheritedEventCount,
+        // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
         events: attached.snapshotEvents(),
       })
     }

+ 1 - 0
packages/bundle/headless/src/index.ts

@@ -74,6 +74,7 @@ function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome {
   let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
   const length = session.seq
   for (let seq = firstSeq; seq < length; seq++) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = session.eventAt(SessionSeq(seq))
     if (event === undefined) {
       throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`)

+ 6 - 3
packages/compaction/compaction-basic/src/region.ts

@@ -98,7 +98,8 @@ interface TransactionFailure {
  */
 function systemHead(session: Session, headSeq: SessionSeq): SessionEvent<'system/message'> | undefined {
   // Surface nodes are current log seqs, so the event exists.
-  // oxlint-disable-next-line typescript/no-non-null-assertion
+  // Existing Session history read; migration deferred.
+  // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
   const head = session.eventAt(headSeq)!
   return head.type === 'system/message' ? head : undefined
 }
@@ -536,7 +537,8 @@ function buildSummarizationInput(
   const system = head === undefined ? null : session.deriveEventMessage(head)
   const regionMessages = shadowedSeqs
     // shadowedSeqs are current surface seqs, so each is a valid log index.
-    // oxlint-disable-next-line typescript/no-non-null-assertion
+    // Existing Session history read; migration deferred.
+    // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
     .map(seq => session.deriveEventMessage(session.eventAt(seq)!))
     .filter((message): message is Message => message !== null)
   return {
@@ -553,7 +555,8 @@ function inspectCompactionEntryState(session: Session): CompactionEntryState {
   let compactionEntryStateKnown = false
   let latestEndSeedSeq: SessionSeq | undefined
   for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
-    // oxlint-disable-next-line typescript/no-non-null-assertion
+    // Existing Session history read; migration deferred.
+    // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
     const event = session.eventAt(SessionSeq(seq))!
     if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
       latestEndSeedSeq = event.seq

+ 1 - 0
packages/compaction/compaction-tool-result-pruner/src/index.ts

@@ -136,6 +136,7 @@ export class ToolResultPruner extends Service {
   pruneSession(session: Session): PruneResult {
     const candidates: SnapshotCandidate[] = []
     for (const seq of [...session.surface.nodes]) {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const event = session.eventAt(seq)
       /* v8 ignore next -- surface seqs are validated contiguous log references. */
       if (event?.type === 'tool/result') candidates.push({ seq, event })

+ 1 - 0
packages/compaction/compaction/src/invariant.ts

@@ -298,6 +298,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
       surface: new SurfaceManager(surfaceEvents),
     }
     traces.set(session, trace)
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const events = session.snapshotEvents()
     const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events)
     for (const event of events) {

+ 1 - 0
packages/compaction/compaction/src/tool-pairing.ts

@@ -50,6 +50,7 @@ function extendCache(
   const pendingCuts: boolean[] = []
   let inProgressToolCalls = cache.inProgressToolCalls
   for (const seq of tail) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = session.eventAt(seq)
     if (event === undefined || event.seq !== seq) {
       throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)

+ 2 - 0
packages/context/agent-instructions/src/index.ts

@@ -53,6 +53,7 @@ function visibleBaselineSource(
     }
   }
   for (const seq of agent.session.surface.nodes.toReversed()) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = agent.session.eventAt(seq)
     if (event?.type === 'user/message'
       && event.data.source.kind === 'agent-instructions'
@@ -228,6 +229,7 @@ export function apply(ctx: Context, config: Config): void {
     const alreadySupplied = desired !== undefined && (
       claimed.some(message => sameContextPayload(message, desired))
       || agent.session.surface.nodes.some((seq) => {
+        // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
         const event = agent.session.eventAt(seq)
         return event?.type === 'user/message' && sameContextPayload(event.data, desired)
       })

+ 1 - 0
packages/context/agent-instructions/src/state.ts

@@ -139,6 +139,7 @@ function visibleInstructionChanges(
 ): Map<string, AgentInstructionChange> {
   const visible = new Map<string, AgentInstructionChange>()
   for (const seq of agent.session.surface.nodes) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = agent.session.eventAt(seq)
     if (event?.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
     const changes = workspaceInstructionChanges(event.data.source)

+ 1 - 0
packages/context/time-context/src/index.ts

@@ -80,6 +80,7 @@ function formatDuration(elapsedMs: number): string {
 function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
   const entered: UserMessage[] = []
   for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = agent.session.eventAt(SessionSeq(seq))
     if (event?.type === 'turn/start' && event.data.turn === turn) {
       return [...entered.reverse(), ...proposed]

+ 2 - 0
packages/context/time-context/src/invariant.ts

@@ -161,6 +161,7 @@ function validateReading(
 /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
 /** Validate all package-owned readings already present in one session. */
 function validateSession(session: Session, fail: InvariantFailure): void {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   const events = session.snapshotEvents()
   for (const [index, event] of events.entries()) {
     if (event.type !== 'user/message'
@@ -180,6 +181,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
     if (event.type !== 'user/message'
       || event.data.source.kind !== 'plugin'
       || event.data.source.plugin !== SOURCE_NAME) return
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     validateReading(session.snapshotEvents(), event, fail)
   }, { global: true })
 }, { inject: ['sessions'] })

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

@@ -748,6 +748,7 @@ export class AgentLoop extends Service implements AgentFactory {
    */
   private async appendUnstoredSuffix(stored: StoredSession | undefined, session: Session): Promise<void> {
     if (stored === undefined) return
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount))
     if (suffix.length > 0) await stored.handle.append(suffix)
     // Advance by what was stored, not to `session.seq`: an event appended

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

@@ -28,6 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
       fail('a loop-built request must carry a frozen messages array')
     }
 
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const events = session.snapshotEvents()
     if (!events.some(event => event.type === 'step/start')) {
       return fail('a loop-built request with no step/start in its session log')

+ 2 - 0
packages/core/agent-loop/src/runtime-context.ts

@@ -45,6 +45,7 @@ export interface SystemPromptDecisionInput {
 
 /** Committed events from the newest backward; the restore scans stop at the first match. */
 function eventsNewestFirst(session: Session): readonly SessionEvent[] {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   return session.snapshotEvents().toReversed()
 }
 
@@ -63,6 +64,7 @@ export class SystemPromptProjection {
   private systemNodes(): { seq: SessionSeq; text: string | undefined }[] {
     const nodes: { seq: SessionSeq; text: string | undefined }[] = []
     for (const seq of this.session.surface.nodes) {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const event = this.session.eventAt(seq)
       if (event?.type !== 'system/message') continue
       const content = event.data.message.content

+ 2 - 2
packages/core/session/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/session/README.md
-README.md: 6ed688e475c0b1bc759d6ab8bd84e6537608bb40
-README.zh.md: fcadf48c048d8eba9a9dfb31a098eca27f2ac27b
+README.md: a18fd0de73a6aa7dc881f1472d991c133f81ad55
+README.zh.md: 84cc0aa35da11a5a782e1949e32852785fb681c4

+ 1 - 1
packages/core/session/README.md

@@ -55,7 +55,7 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and
 
 ### Read the log
 
-`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`.
+`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. `eventAt()`, `snapshotEvents()`, and `ownEvents()` are deprecated: existing logic may remain unmigrated for now, but new production calls are prohibited. Repository test files may use these three readers under their scoped lint allowance ([policy](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Callers that only need a length use `seq`.
 
 Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers.
 

+ 1 - 1
packages/core/session/README.zh.md

@@ -55,7 +55,7 @@ session.deriveMessages()         // the derived model history
 
 ### 读取日志
 
-`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。
+`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。`eventAt()`、`snapshotEvents()` 和 `ownEvents()` 已弃用:现有逻辑可以暂不迁移,但禁止新增生产调用。仓库测试文件可以在限定范围的 lint 豁免下使用这三个读取方法([策略](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md))。只需要长度的调用方使用 `seq`。
 
 会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,brand 在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。
 

+ 10 - 0
packages/core/session/src/index.ts

@@ -615,6 +615,8 @@ export class Session {
 
   /**
    * Return the immutable event stored at one exact sequence number.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param seq - event sequence number.
    * @returns the accepted event, or undefined when the log does not contain it.
    */
@@ -626,6 +628,8 @@ export class Session {
    * Materialize an immutable snapshot of a half-open event sequence range.
    * A full current snapshot is reused until the next append; every previously
    * returned snapshot remains stable after later appends.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
    * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
    * @returns a frozen array of the selected deeply frozen events.
@@ -643,9 +647,12 @@ export class Session {
 
   /**
    * Return this Session's events after its fork-inherited prefix.
+   * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
+   * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
    * @returns a fresh array containing child-owned events in log order.
    */
   ownEvents(): readonly SessionEvent[] {
+    // oxlint-disable-next-line typescript/no-deprecated -- Deprecated reader delegates to the deprecated range read.
     return this.snapshotEvents(this.inheritedEventCount)
   }
 
@@ -1211,6 +1218,7 @@ export class SessionStore extends Service {
   }
 
   private _forkSeed(session: Session, requestedBoundary: SessionSeq | undefined): readonly SessionEvent[] {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const lastEvent = session.snapshotEvents().at(-1)
     let boundary: SessionSeq
     if (requestedBoundary !== undefined) {
@@ -1233,6 +1241,7 @@ export class SessionStore extends Service {
       )
     }
 
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const boundaryEvent = session.eventAt(boundary)
     if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
       throw new SessionForkError(
@@ -1240,6 +1249,7 @@ export class SessionStore extends Service {
         'INVALID_BOUNDARY',
       )
     }
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1))
     const lastTurnBoundary = events
       .findLast(event => event.type === 'turn/start' || event.type === 'turn/end')

+ 1 - 0
packages/core/session/src/invariant.ts

@@ -210,6 +210,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
   const seedSession = (session: Session): SessionTrace => {
     const trace = freshTrace()
     traces.set(session, trace)
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) {
       applyTransition(trace, validateEvent(trace, event, fail))
     }

+ 1 - 0
packages/core/tools/src/invariant.ts

@@ -58,6 +58,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
   const seed = (session: Session): number | null => {
     let openTurn: number | null = null
     dispatchRoots.set(session, new Map())
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) {
       validateDispatch(session, event)
       commitDispatch(session, event)

+ 1 - 0
packages/experimental/agent-team/src/mailbox.ts

@@ -299,6 +299,7 @@ export class TeamMailbox {
 
   /** Whether a target Session already contains the durable message identity. */
   private targetRecorded(session: Session, messageId: TeamMessageId): boolean {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const suffix = session.snapshotEvents(session.inheritedEventCount)
     return messageAccepted(suffix, message => message.source.kind === 'team-message'
       && message.source.messageId === messageId)

+ 2 - 0
packages/experimental/agent-team/src/roster.ts

@@ -375,6 +375,7 @@ export class TeamRoster {
       try {
         signal.throwIfAborted()
         await this.ctx.sessions.flush(session)
+        // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
         const suffix = session.snapshotEvents(session.inheritedEventCount)
         if (messageAccepted(suffix, message => message.id === messageId)) return
         if (this.ctx.sessions.get(childId) !== session) continue
@@ -482,6 +483,7 @@ export class TeamRoster {
 
   /** Whether a Session's own suffix identifies a provider-owned subagent child. */
   private subagentDescriptor(agent: Agent): boolean {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.inheritedEventCount)) !== undefined
   }
 }

+ 21 - 8
packages/experimental/code-runtime-python/tests/runtime.spec.ts

@@ -1517,10 +1517,14 @@ describe('PythonCodeRuntime — programs and bindings', () => {
       const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
       result = await runtime.run({
         program: [
-          'import os',
-          'for _ in range(6000):',
+          'import os, time',
+          // One byte per chunk on every host: a plain yield lets a loaded
+          // reader coalesce, and the coalesced chunk is what the bound below
+          // measures. The payload stays above the 2048 discriminator, so a
+          // raw-byte undercount still flushes the whole residual at EOF.
+          'for _ in range(3200):',
           '    os.write(1, b"\\xff")',
-          '    os.sched_yield()',
+          '    time.sleep(0.001)',
           'return None',
         ].join('\n'),
         bindings: [],
@@ -1534,7 +1538,10 @@ describe('PythonCodeRuntime — programs and bindings', () => {
     // merged buffer stays well under 2048. A raw-byte undercount would let it
     // reach ~3072 before flushing, so 2048 discriminates.
     expect(maxConcat).toBeLessThan(2048)
-  })
+    // The paced payload costs ~3.2s deterministically, which is above the
+    // 5000ms default the local unit entry grants, so the case carries its own
+    // bound instead of relying on the lane to widen it.
+  }, 20_000)
 
   it('charges a structurally-valid but illegal UTF-8 sequence its U+FFFD-decoded cost', async () => {
     // A CESU-8 lone surrogate `ED A0 80` is structurally well-formed (a 3-byte
@@ -1559,12 +1566,15 @@ describe('PythonCodeRuntime — programs and bindings', () => {
       const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
       result = await runtime.run({
         program: [
-          'import os',
+          'import os, time',
           'seq = (0xed, 0xa0, 0x80)',
-          'for _ in range(2000):',
+          // 1100 sequences are 3300 raw bytes, past the 3072-byte budget a
+          // raw-byte undercount reaches, so the undercount flushes above the
+          // 2048 discriminator instead of only at EOF.
+          'for _ in range(1100):',
           '    for b in seq:',
           '        os.write(1, bytes((b,)))',
-          '        os.sched_yield()',
+          '        time.sleep(0.001)',
           'return None',
         ].join('\n'),
         bindings: [],
@@ -1579,7 +1589,10 @@ describe('PythonCodeRuntime — programs and bindings', () => {
     // largest merged buffer stays well under 2048. Charging the structural width
     // 3 would need ~1024 raw bytes, tripling the peak past 2048.
     expect(maxConcat).toBeLessThan(2048)
-  })
+    // The paced payload costs ~3.3s deterministically, which is above the
+    // 5000ms default the local unit entry grants, so the case carries its own
+    // bound instead of relying on the lane to widen it.
+  }, 20_000)
 
   it('charges a lone surrogate its full six escaped bytes, not three', async () => {
     // A forged `log` frame carrying `\ud800` escapes materializes lone

+ 2 - 0
packages/feedback/message-feedback/src/index.ts

@@ -230,10 +230,12 @@ export class MessageFeedbackService extends TypertRemoteService {
     }
     const live = this.ctx.sessions.get(sessionId)
     if (live !== undefined) {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       return operation(live.snapshotEvents(), async (event) => {
         if (event !== undefined) {
           live.append(event.type, event.data)
         }
+        // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
         const last = live.snapshotEvents().at(-1)
         if (!(await this.ctx.sessions.flush(live))) {
           throw new Error(

+ 2 - 0
packages/goal/goal-round-driver/src/invariant.ts

@@ -61,6 +61,7 @@ function validateEvent(
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   for (const session of ctx.sessions.list()) {
     const prior: SessionEvent[] = []
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) {
       validateEvent(prior, event, fail)
       prior.push(event)
@@ -70,6 +71,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
   ctx.on('internal/dispatch', (_mode, eventName, args) => {
     if (eventName !== 'session/event') return
     const [session, event] = args as [Session, SessionEvent]
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     validateEvent(session.snapshotEvents(), event, fail)
   }, { global: true })
 }, { inject: ['sessions'] })

+ 1 - 0
packages/goal/goal/src/invariant.ts

@@ -43,6 +43,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
 
   const seed = (session: Session): GoalFoldState => {
     const state = emptyGoalFoldState()
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) applyChecked(state, event, fail)
     states.set(session, state)
     return state

+ 1 - 0
packages/goal/tool-goal/src/authority.ts

@@ -30,6 +30,7 @@ function openTurnEvents(
   ctx: Context,
   agent: Agent,
 ): Pick<GoalToolExecution, 'events' | 'openTurnStartSeq'> {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   const events = agent.session.snapshotEvents()
   const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')
   if (boundary === undefined || boundary.openTurnStartSeq === null) {

+ 1 - 0
packages/hooks/hook-protocol/src/invariant.ts

@@ -74,6 +74,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
   const seed = (session: Session): HookTrace => {
     const trace: HookTrace = { openTurn: null, pending: new Map() }
     traces.set(session, trace)
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) {
       if (event.type === 'turn/start') trace.openTurn = event.data.turn
       else if (event.type === 'turn/end') trace.openTurn = null

+ 35 - 9
packages/host/directory-picker-native/tests/win32-dialog.spec.ts

@@ -1,11 +1,12 @@
 /**
  * Driver tests: the child-process message protocol mapped onto the promise,
  * the WM_CLOSE abort service (including the show-race retry and the kill
- * last resort) against fakes, plus the real spawn plumbing — POSIX hosts
- * prove the default path rejects cleanly (koffi cannot load ole32 there),
- * and win32 hosts briefly open and auto-abort a real dialog.
+ * last resort) against fakes, plus the real spawn plumbing: hosts that cannot
+ * open the folder dialog prove the default path rejects cleanly and hosts
+ * that can briefly open and auto-abort a real one.
  */
 
+import { spawnSync } from 'node:child_process'
 import { EventEmitter } from 'node:events'
 import { describe, expect, it, vi } from 'vitest'
 import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts'
@@ -41,6 +42,28 @@ function harness(overrides: Partial<Win32DialogInternals> = {}): Harness {
 
 const live = (): AbortSignal => new AbortController().signal
 
+/** CLSID_FileOpenDialog, spelled out here so the probe below owns its own copy. */
+const CLSID_FILE_OPEN_DIALOG = 'dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7'
+
+/**
+ * Whether this host can open the shell's folder dialog. Some Windows Server
+ * images answer `CoCreateInstance(CLSID_FileOpenDialog)` with
+ * CLASS_E_CLASSNOTAVAILABLE (0x80040111) instead of an object, so no dialog
+ * can ever appear there. PowerShell activates the class outside the code
+ * under test, which keeps a GUID or vtable regression in `src` failing the
+ * smoke below instead of silently skipping it. Every exception from that
+ * activation reads as refusal, so a host that fails the probe for another
+ * reason only loses the real-dialog case; a probe that cannot run at all —
+ * no `powershell.exe` — keeps the win32 assumption.
+ */
+const opensRealDialog = process.platform === 'win32' && spawnSync('powershell.exe', [
+  '-NoLogo',
+  '-NoProfile',
+  '-NonInteractive',
+  '-Command',
+  `try { [void][Activator]::CreateInstance([Type]::GetTypeFromCLSID([Guid]'${CLSID_FILE_OPEN_DIALOG}')) } catch { exit 1 }`,
+], { encoding: 'utf8' }).status !== 1
+
 describe('pickWin32Directory', () => {
   it('resolves the selected path and the cancellation null', async () => {
     const first = harness()
@@ -145,15 +168,18 @@ describe('pickWin32Directory', () => {
     expect(close.mock.calls.length).toBeGreaterThan(10)
   })
 
-  // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped
-  // worker spawns, loads koffi, fails to load ole32.dll, and reports the error.
-  it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => {
+  // Hosts without the folder dialog exercise the REAL default plumbing end to
+  // end: the tsx-bootstrapped worker spawns, loads koffi, and reports the
+  // refusal — ole32.dll fails to load on POSIX, and a Windows Server image
+  // that refuses the dialog class reports the same rejection.
+  it.skipIf(opensRealDialog)('rejects through the real worker where the Win32 surface is unavailable', async () => {
     await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed')
   }, 30_000)
 
-  // win32 hosts run the true COM smoke instead: a real dialog opens briefly
-  // and the abort service closes it (the same lever a disconnecting client pulls).
-  it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => {
+  // Hosts with the dialog run the true COM smoke instead: a real dialog opens
+  // briefly and the abort service closes it (the same lever a disconnecting
+  // client pulls).
+  it.skipIf(!opensRealDialog)('opens and abort-closes a real dialog', async () => {
     const controller = new AbortController()
     setTimeout(() => {
       controller.abort()

+ 2 - 0
packages/interaction/commands/src/invariant.ts

@@ -35,6 +35,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
       fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`)
     }
     const source = event.data.sourceEventSeq
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const sourceEvent = source === undefined ? undefined : session.eventAt(source)
     if (source !== undefined
       && (event.data.kind !== 'success'
@@ -46,6 +47,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
     }
   }
   for (const session of ctx.sessions.list()) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) validateEvent(session, event)
   }
   ctx.on('internal/dispatch', (_mode, eventName, args) => {

+ 1 - 0
packages/interaction/permission-presets/src/invariant.ts

@@ -21,6 +21,7 @@ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure
 /** Install validation that loaded and newly appended preset events remain resolvable. */
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   for (const session of ctx.sessions.list()) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) validateEvent(ctx, event, fail)
   }
   ctx.on('internal/dispatch', (_mode, eventName, args) => {

+ 2 - 0
packages/interaction/user-approval/src/index.ts

@@ -76,6 +76,7 @@ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may
  */
 function hasOpenTurn(session: Session): boolean {
   for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const type = session.eventAt(SessionSeq(seq))?.type
     if (type === 'turn/start') return true
     if (type === 'turn/end') return false
@@ -243,6 +244,7 @@ export class ApprovalService extends Service {
    */
   overrideOf(session: Session): ApprovalPolicy | undefined {
     for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const event = session.eventAt(SessionSeq(seq))
       if (event?.type === 'approval/policy') return event.data.policy
     }

+ 1 - 0
packages/interaction/user-approval/src/invariant.ts

@@ -64,6 +64,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
   const seed = (session: Session): ApprovalTrace => {
     const trace: ApprovalTrace = { openTurn: null, pending: new Set() }
     traces.set(session, trace)
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) {
       if (event.type === 'turn/start') trace.openTurn = event.data.turn
       else if (event.type === 'turn/end') trace.openTurn = null

+ 3 - 0
packages/llm/llm-retry/src/invariant.ts

@@ -147,6 +147,7 @@ function validateStarted(
 
 /** Validate every retry record already present in one loaded session. */
 function validateSession(session: Session, fail: InvariantFailure): void {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   const events = session.snapshotEvents()
   for (const [index, event] of events.entries()) {
     if (event.type === 'llm/retry') validateRetry(events.slice(0, index), event, fail)
@@ -161,7 +162,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
   ctx.on('internal/dispatch', (_mode, eventName, args) => {
     if (eventName !== 'session/event') return
     const [session, event] = args as [Session, SessionEvent]
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     if (event.type === 'llm/retry') validateRetry(session.snapshotEvents(), event, fail)
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     else if (event.type === 'llm/retry-started') validateStarted(session.snapshotEvents(), event, fail)
   }, { global: true })
 }, { inject: ['sessions'] })

+ 2 - 1
packages/llm/token-meter/src/index.ts

@@ -229,7 +229,8 @@ export class TokenMeter extends Service {
     }
 
     while (state.consumedEvents < session.seq) {
-      // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log
+      // Contiguous session seqs index the durable log; existing Session history read, migration deferred.
+      // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
       const event = session.eventAt(SessionSeq(state.consumedEvents))!
       this._foldEvent(state, event)
       state.consumedEvents = SessionLogOffset(state.consumedEvents + 1)

+ 1 - 0
packages/plan/plan-mode/src/invariant.ts

@@ -28,6 +28,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
 /** Install validation for loaded and newly appended plan-mode state. */
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   const seed = (session: Session): void => {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) validateEvent(event, fail)
   }
   for (const session of ctx.sessions.list()) seed(session)

+ 1 - 0
packages/sandbox/sandbox-policy/src/invariant.ts

@@ -23,6 +23,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
 /** Install validation for loaded and newly appended sandbox modes. */
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   for (const session of ctx.sessions.list()) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) validateEvent(event, fail)
   }
   ctx.on('internal/dispatch', (_mode, eventName, args) => {

+ 1 - 0
packages/schedule/schedule/src/index.ts

@@ -55,6 +55,7 @@ export function apply(ctx: Context): void {
       const cleanup: OwnerCleanup = agent.ctx.effect(() => {
         const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { runtime.requestDrive() })
         const stopStatus = agent.ctx.on('agent/status', ({ status }) => {
+          // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
           if (status === 'idle' && agent.session.snapshotEvents().some(event => event.type === 'schedule/change')) {
             runtime.requestDrive()
           }

+ 3 - 0
packages/schedule/schedule/src/invariant.ts

@@ -30,15 +30,18 @@ function validate(events: readonly SessionEvent[], fail: InvariantFailure): void
 /** Install replay and pre-append validation for the owned event stream. */
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   for (const session of ctx.sessions.list()) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     validate(session.ownEvents(), fail)
   }
   ctx.on('session/created', (session) => {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     validate(session.ownEvents(), fail)
   }, { global: true })
   ctx.on('internal/dispatch', (_mode, eventName, args) => {
     if (eventName !== 'session/event') return
     const [session, event] = args as [Session, SessionEvent]
     if (event.type !== 'schedule/change') return
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     validate([...session.ownEvents(), event], fail)
   }, { global: true })
 }, { inject: ['sessions'] })

+ 1 - 0
packages/schedule/schedule/src/runtime.ts

@@ -205,6 +205,7 @@ export class ScheduleRuntime {
   /** Fold the current exact runtime suffix and contain a corrupt durable stream. */
   private readFolded(): FoldedSchedules | undefined {
     try {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       return foldScheduleEvents(this.agent.session.ownEvents())
     } catch (error: unknown) {
       this.faulted = true

+ 1 - 0
packages/schedule/schedule/src/tools.ts

@@ -221,6 +221,7 @@ function inputError(error: ScheduleInputError): ScheduleToolError {
 /** Fold only after a successful preflight, mapping corruption to a stable value. */
 function foldForTool(agent: Agent): ReturnType<typeof foldScheduleEvents> | ScheduleToolError {
   try {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     return foldScheduleEvents(agent.session.ownEvents())
   } catch (error: unknown) {
     return error instanceof ScheduleLogError ? corruptLogError() : internalError()

+ 1 - 0
packages/session-query/session-query-sqlite/src/index.ts

@@ -871,6 +871,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar
 }
 
 function observeLive(session: Session): ObservedSession {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   return observeSession(session.header, session.inheritedEventCount, session.snapshotEvents())
 }
 

+ 2 - 0
packages/session-query/session-query/src/corpus.ts

@@ -243,6 +243,7 @@ function projectSource<Value>(
 }
 
 function sourceLive(session: Session): LogicalSessionSource {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   return { header: session.header, events: session.snapshotEvents() }
 }
 
@@ -298,6 +299,7 @@ function snapshotLive(session: Session): LogicalSession {
   return {
     header: structuredClone(session.header),
     inheritedEventCount: session.inheritedEventCount,
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     events: session.snapshotEvents().map(event => structuredClone(event)),
   }
 }

+ 1 - 0
packages/session-query/session-query/src/observation.ts

@@ -289,6 +289,7 @@ export class SessionObservationReader {
         header: session.header,
         inheritedEventCount: session.inheritedEventCount,
         get events() {
+          // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
           materialized ??= session.snapshotEvents(SessionLogOffset(0), seq)
           return materialized
         },

+ 3 - 0
packages/session/session-log-deepseek/src/index.ts

@@ -120,6 +120,7 @@ export function acceptedThrough(session: Session): SessionSeqCursor {
   const length = session.seq
   const start = previous?.scannedEvents ?? SessionLogOffset(0)
   for (let index = start; index < length; index++) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = session.eventAt(SessionSeq(index))
     if (event === undefined) {
       throw new Error(`session-log-deepseek: missing event ${String(index)} below captured length ${String(length)}`)
@@ -164,9 +165,11 @@ export function apply(ctx: Context, config: Config): void {
       if (session === undefined) return undefined
 
       const afterSeq = acceptedThrough(session)
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const snapshot = session.snapshotEvents()
       const throughSeq = snapshot.at(-1)?.seq
       if (throughSeq === undefined) return undefined
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const suffix = session.snapshotEvents(SessionLogOffset(afterSeq + 1))
       const value: DeepSeekSessionLogExtension = {
         version: 1,

+ 1 - 0
packages/session/session-log-deepseek/src/invariant.ts

@@ -44,6 +44,7 @@ function validateDeliveryAccepted(session: Session, event: SessionEvent<'session
 
 /** Validate acceptance watermarks already present in one Session. */
 function validateSession(session: Session, fail: InvariantFailure): void {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   for (const event of session.snapshotEvents()) {
     if (event.type === 'session-log-deepseek/delivery-accepted') validateDeliveryAccepted(session, event, fail)
   }

+ 3 - 0
packages/session/session-projection/src/index.ts

@@ -619,6 +619,7 @@ export class SessionProjectionRegistry extends Service {
         registration.def,
         session.header,
         session.inheritedEventCount,
+        // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
         session.snapshotEvents(),
       )
       registration.cells.set(session, cell)
@@ -637,6 +638,7 @@ export class SessionProjectionRegistry extends Service {
   ): void {
     if (cell.observedSeq >= throughSeq) return
     for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const event = session.eventAt(SessionSeq(seq))
       if (event === undefined || event.seq !== seq) {
         throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`)
@@ -663,6 +665,7 @@ export class SessionProjectionRegistry extends Service {
           registration.def,
           session.header,
           session.inheritedEventCount,
+          // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
           session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(event.seq)),
         )
         registration.cells.set(session, cell)

+ 1 - 0
packages/session/session-telemetry-otel/src/index.ts

@@ -246,6 +246,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
     ctx.on('session/event', (session, event) => {
       if (!isFeedback(session, event)) return
       // Only the canonical appended event authorizes this exact prefix.
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       if (session.eventAt(event.seq) !== event) {
         ctx.logger.warn(NON_CANONICAL_EVENT_WARNING)
         return

+ 1 - 0
packages/session/session-telemetry/src/coordinator.ts

@@ -153,6 +153,7 @@ export class SessionTelemetryCoordinator {
       ?? (this.options.includeHistory === true || session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1))
     // Containment is PER EVENT: one rejected record is withheld fail-closed
     // while the rest of the historical replay proceeds.
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents(SessionLogOffset(cursor + 1))) {
       if (throughSeq !== undefined && event.seq > throughSeq) break
       this.contain(() => {

+ 2 - 0
packages/session/session-title/src/index.ts

@@ -383,6 +383,7 @@ export class SessionTitleService extends Service {
    * @returns latest title snapshot, or `undefined` before eligible input.
    */
   get(session: Session): SessionTitleSnapshot | undefined {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     return foldSessionTitle(session.snapshotEvents())
   }
 
@@ -593,6 +594,7 @@ export class SessionTitleService extends Service {
       this.assertCurrent(session, work)
       await this.ensureFallback(session)
       this.assertCurrent(session, work)
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const messages = collectSessionTitleMessages(session.snapshotEvents(), work.throughSeq)
       const result = await work.registration.provider.generate({
         session,

+ 2 - 0
packages/session/session-title/src/invariant.ts

@@ -46,6 +46,7 @@ function validate(
       fail(`session/title event ${String(event.seq)} repeats message seq ${checked}`)
     }
     seen.add(checked)
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const cited = checked < event.seq ? session.eventAt(checked) : undefined
     if (cited?.type !== 'user/message' || cited.data.source.kind !== 'user') {
       fail(`session/title event ${String(event.seq)} message seq ${checked} must name an earlier human user/message`)
@@ -55,6 +56,7 @@ function validate(
 
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   const validateExisting = (session: Session): void => {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents()) {
       if (event.type === 'session/title') validate(session, event, fail)
     }

+ 1 - 0
packages/skill/tool-skill/src/index.ts

@@ -362,6 +362,7 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
   const visible = new Set(agent.session.surface.nodes)
   let published = false
   for (let index = agent.session.seq - 1; index >= 0; index -= 1) {
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     const event = agent.session.eventAt(SessionSeq(index))
     if (event === undefined) {
       throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`)

+ 15 - 5
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -1412,17 +1412,27 @@ describe('dsh-subagent-acp', () => {
       args: [mockServer],
       permission: 'reject',
       env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
-      disposeEofGraceMs: 150,
-      disposeGraceMs: 150,
+      // The graces are wall-clock budgets for the managed scope's teardown on a
+      // shared host. At 150ms the hosted image escalated while the scope could
+      // not take the signal and systemctl failed the kill ("Failed to send
+      // signal SIGKILL to auxiliary processes: Invalid argument"), surfacing as
+      // a teardown failure the configuration never asked for. The case asserts
+      // that config graces reach the real run, so the value only has to clear
+      // the host's scope handling.
+      disposeEofGraceMs: 5_000,
+      disposeGraceMs: 5_000,
     })
     const run = await ctx.subagents.start('acp', request())
     expect(start).toHaveBeenCalledExactlyOnceWith(expect.anything(), expect.objectContaining({
-      disposeEofGraceMs: 150,
-      disposeGraceMs: 150,
+      disposeEofGraceMs: 5_000,
+      disposeGraceMs: 5_000,
     }))
     await waitForFile(ready, task.timeout)
     await expect(run.dispose()).resolves.toBeUndefined()
-  })
+    // The trapped child refuses stdin EOF, so the ladder waits out the EOF
+    // grace and then the SIGTERM grace (10s) before the SIGKILL settles it.
+    // That fixed cost is above the 5000ms default the local unit entry grants.
+  }, 30_000)
 
   it('rejects a dispose grace outside the Node timer range at load', async () => {
     for (const bad of [

+ 1 - 0
packages/subagent/subagent-fork-in-process/src/index.ts

@@ -46,6 +46,7 @@ export const Config: z<Config> = z.object({
  * @returns the seed events, contiguous from seq 0; empty when no turn has completed.
  */
 function completedTurnPrefix(parent: Agent): SessionEvent[] {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   const events = parent.session.snapshotEvents()
   const lastEnd = events.findLast(e => e.type === 'turn/end')
   if (lastEnd === undefined) return []

+ 1 - 0
packages/subagent/subagent-in-process-driver/src/index.ts

@@ -215,6 +215,7 @@ function readResult(
   cancelled: boolean,
   structured?: { captured?: { value: unknown } | undefined },
 ): SubagentResult {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   const own = child.session.snapshotEvents(boundary)
   // `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its
   // awaited first turn almost immediately, and the owner's own teardown is the

+ 1 - 0
packages/subagent/subagent/src/lifecycle.ts

@@ -198,6 +198,7 @@ export function createActivationObserver(
       emit('subagent/start', identity, parent)
     },
     capture: (child: Agent): void => {
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       const own = child.session.snapshotEvents(boundary)
       const output = finalAssistantOutput(own)
       captured = {

+ 1 - 0
packages/subagent/tool-subagent/src/index.ts

@@ -619,6 +619,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
   }
   const selectForSession = (target: Session): ModelSelectionPolicy | undefined => {
     const freshSession = target.firstLiveSeq === 0
+      // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
       && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed'
     let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target)
     if (allowedModels === undefined) {

+ 2 - 2
packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md
-README.md: 03856c32e6308a1d075ff41b879da144cc772843
-README.zh.md: d1fb5881396963bf5a8d7cb4f44cabae3d1e5a24
+README.md: 3222d7bf9b9b498baf939212ebbf7da9bf599d76
+README.zh.md: e8d2ddc9398ffe186260a1b4ed3a0cd116ff8afb

+ 1 - 1
packages/subprocess/subprocess-local/README.md

@@ -52,7 +52,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina
 
 Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
 
-Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty.
+Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup.
 
 ### What can go wrong
 

+ 1 - 1
packages/subprocess/subprocess-local/README.zh.md

@@ -52,7 +52,7 @@ kind: "package-reference"
 
 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。
 
-Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空。
+Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。
 
 ### 可能出错的地方
 

+ 47 - 4
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -178,6 +178,7 @@ class LinuxScopeStartup {
 class SystemdScopeOwner implements BoundProcessOwner {
   private establishment: 'pending' | 'established' = 'pending'
   private stopped = false
+  private terminationRequested = false
   private observation: Promise<void> | undefined
   private killFailure: Error | undefined
   private wakeGeneration = 0
@@ -195,6 +196,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
 
   signal(signal: 'SIGTERM' | 'SIGKILL'): void {
     if (this.stopped) return
+    this.terminationRequested = true
     if (this.direct.running()) this.startup.terminationSignals.add(signal)
     this.observeRequestConsumption()
     const directFallbackRequired = this.establishment === 'pending'
@@ -256,7 +258,32 @@ class SystemdScopeOwner implements BoundProcessOwner {
     return true
   }
 
-  private parseUnitState(stdout: string): { loadState: string; activeState: string } {
+  /**
+   * Prove an active unit with no processes is the empty managed range rather
+   * than a launch still placing its payload. systemd ends a scope only on the
+   * populated-to-empty transition, so a payload killed before it entered the
+   * cgroup leaves the unit active forever. Requested termination plus a
+   * departed client makes that leftover conclusive: the client forked every
+   * process it will ever fork.
+   */
+  private emptyRange(tasksCurrent: number | undefined): boolean {
+    return this.terminationRequested && tasksCurrent === 0 && !this.direct.running()
+  }
+
+  /** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */
+  private releaseEmptyRange(): void {
+    try {
+      this.runSync(this.systemctl, ['--user', 'stop', this.unit], {
+        env: managerEnvironment(),
+        stdio: 'ignore',
+        timeout: SYSTEMCTL_TIMEOUT_MS,
+      })
+    } catch {
+      // The range is already empty; a failed cleanup leaves only the transient unit.
+    }
+  }
+
+  private parseUnitState(stdout: string): { loadState: string; activeState: string; tasksCurrent: number | undefined } {
     const values = new Map<string, string>()
     for (const line of stdout.split(/\r?\n/u)) {
       if (line === '') continue
@@ -272,10 +299,21 @@ class SystemdScopeOwner implements BoundProcessOwner {
     }
     const loadState = values.get('LoadState')
     const activeState = values.get('ActiveState')
-    if (values.size !== 2 || loadState === undefined || activeState === undefined) {
+    // The manager prints this sentinel for a property the unit does not carry.
+    const reportedTasks = values.get('TasksCurrent')
+    const tasksCurrent = reportedTasks === '[not set]' ? undefined : reportedTasks
+    if (values.size !== (reportedTasks === undefined ? 2 : 3)
+      || loadState === undefined || activeState === undefined) {
       throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`)
     }
-    return { loadState, activeState }
+    if (tasksCurrent !== undefined && !/^\d+$/u.test(tasksCurrent)) {
+      throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`)
+    }
+    return {
+      loadState,
+      activeState,
+      tasksCurrent: tasksCurrent === undefined ? undefined : Number(tasksCurrent),
+    }
   }
 
   private async rangeActive(): Promise<boolean> {
@@ -286,10 +324,11 @@ class SystemdScopeOwner implements BoundProcessOwner {
       this.unit,
       '--property=LoadState',
       '--property=ActiveState',
+      '--property=TasksCurrent',
     ])
     const output = `${result.stdout}\n${result.stderr}`
     if (result.status === 0) {
-      const { loadState, activeState } = this.parseUnitState(result.stdout)
+      const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout)
       if (loadState === 'not-found' && activeState === 'inactive') return this.absentUnit()
       if (loadState !== 'loaded') {
         throw new Error(
@@ -302,6 +341,10 @@ class SystemdScopeOwner implements BoundProcessOwner {
         throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`)
       }
       if (this.killFailure !== undefined) throw this.killFailure
+      if (this.emptyRange(tasksCurrent)) {
+        this.releaseEmptyRange()
+        return false
+      }
       return true
     }
     if (!MISSING_UNIT.test(output)) {

+ 77 - 0
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -84,6 +84,20 @@ function unitState(loadState: string, activeState: string) {
   return { status: 0, stdout: `LoadState=${loadState}\nActiveState=${activeState}\n`, stderr: '' }
 }
 
+function activeUnitWithTasks(tasks: string) {
+  return { status: 0, stdout: `LoadState=loaded\nActiveState=active\nTasksCurrent=${tasks}\n`, stderr: '' }
+}
+
+/** Deny every real process-group signal so a fake child never reaches a live host group. */
+function denyProcessGroups(): void {
+  vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
+}
+
+/** Record the systemctl invocations a scope owner makes, succeeding unless a case overrides it. */
+function recordingSystemctl() {
+  return vi.fn((_command: string, _args: readonly string[]) => ({ status: 0, stdout: '', stderr: '' }))
+}
+
 function spec() {
   return {
     argv: ['tool', 'literal arg'],
@@ -446,6 +460,7 @@ describe('Linux scope establishment and quiescence', () => {
       ['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'],
       ['LoadState=loaded\n', 'incomplete state'],
       ['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'],
+      ['LoadState=loaded\nActiveState=active\nTasksCurrent=0\nOther=value\n', 'incomplete state'],
     ] as const) {
       const launched = launch(async () => ({ status: 0, stdout, stderr: '' }))
       await expect(launched.result.owner.waitForExit()).rejects.toThrow(message)
@@ -453,6 +468,68 @@ describe('Linux scope establishment and quiescence', () => {
     }
   })
 
+  it('rejects a manager process count that is neither numeric nor the unset sentinel', async () => {
+    const launched = launch(async () => activeUnitWithTasks('many'))
+    await expect(launched.result.owner.waitForExit()).rejects.toThrow('non-numeric TasksCurrent')
+    launched.result.owner.cleanup?.()
+  })
+
+  it('releases an active scope left with no processes once its client has gone', async () => {
+    // Regression: the manager's empty cgroup never ends this unit on its own.
+    denyProcessGroups()
+    const spawnSync = recordingSystemctl()
+    const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
+    launched.result.owner.signal('SIGKILL')
+    launched.child.exit(null, 'SIGKILL')
+    await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+    expect(spawnSync.mock.calls.map(call => call[1])).toEqual([
+      ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', expect.stringMatching(/\.scope$/u)],
+      ['--user', 'stop', expect.stringMatching(/\.scope$/u)],
+    ])
+    launched.result.owner.cleanup?.()
+  })
+
+  it('concludes the empty range even when releasing the leftover scope fails', async () => {
+    denyProcessGroups()
+    const spawnSync = recordingSystemctl()
+      .mockImplementationOnce(() => ({ status: 0, stdout: '', stderr: '' }))
+      .mockImplementationOnce(() => { throw new Error('systemctl is gone') })
+    const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
+    launched.result.owner.signal('SIGKILL')
+    launched.child.exit(null, 'SIGKILL')
+    await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+    launched.result.owner.cleanup?.()
+  })
+
+  it('keeps waiting while the client still owns an active scope with no processes', async () => {
+    denyProcessGroups()
+    const spawnSync = recordingSystemctl()
+    const states = [activeUnitWithTasks('0'), unloadedUnit()]
+    const launched = launch(async () => states.shift() ?? unloadedUnit(), {
+      spawnSync: spawnSync as never,
+    })
+    launched.result.owner.signal('SIGTERM')
+    await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+    expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill'])
+    launched.result.owner.cleanup?.()
+  })
+
+  it('keeps waiting for an active empty scope no termination has requested', async () => {
+    const states = [activeUnitWithTasks('0'), unloadedUnit()]
+    const launched = launch(async () => states.shift() ?? unloadedUnit())
+    await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+    expect(launched.spawnSync).not.toHaveBeenCalled()
+    launched.result.owner.cleanup?.()
+  })
+
+  it('treats an unset process count as unknown and keeps waiting', async () => {
+    const states = [activeUnitWithTasks('[not set]'), unloadedUnit()]
+    const launched = launch(async () => states.shift() ?? unloadedUnit())
+    await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+    expect(launched.spawnSync).not.toHaveBeenCalled()
+    launched.result.owner.cleanup?.()
+  })
+
   it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => {
     const spawnSync = vi.fn()
       .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })

+ 22 - 2
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -393,6 +393,8 @@ describe('LocalSubprocessRuntime', () => {
       const ctx = new Context()
       const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
       const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
+      // Pins the containment choice: with the host's native scope a mocked PTY
+      // exit races the scope bootstrap.
       service.internals = { platform: 'darwin' }
       const handle = await ctx.subprocess.spawnTerminal({
         argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
@@ -600,6 +602,8 @@ describe('LocalSubprocessRuntime', () => {
       ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
       const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
       const alive = new Set([124])
+      // Pins the containment choice: with the host's native scope a mocked PTY
+      // exit races the scope bootstrap.
       ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).internals = { platform: 'darwin' }
       ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
         foregroundPgid: () => 123,
@@ -878,10 +882,26 @@ describe('LocalSubprocessRuntime', () => {
     const ctx = new Context()
     const fiber = await ctx.plugin(LocalSubprocessRuntime)
     // Dispose before the rejection continuation removes the handle from the
-    // live set, so teardown itself must swallow the rejected done.
+    // live set, so teardown itself must swallow the rejected done. Two
+    // settlements are valid and the winner is a race: a bootstrap that
+    // publishes its pre-exec failure rejects with that failure, and a teardown
+    // that stops the bootstrap first settles as the requested termination —
+    // the recorded failure only outranks the stop when it was published before
+    // the stop landed.
     const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
     await fiber.dispose()
-    await expect(handle.done).rejects.toThrow()
+    const settlement = await handle.done.then(
+      outcome => ({ kind: 'stopped' as const, outcome }),
+      (error: unknown) => ({ kind: 'failed' as const, error }),
+    )
+    if (settlement.kind === 'failed') {
+      expect(settlement.error).toBeInstanceOf(Error)
+    } else {
+      // Only the Linux scope records a stop this way: the win32 job owner
+      // rejects a cancelled start and the fallback launcher rejects the ENOENT,
+      // so neither can produce the stopped branch.
+      expect(settlement.outcome.signal).toBe('SIGTERM')
+    }
   })
 
   it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {

+ 1 - 0
packages/todo/tool-todo/src/invariant.ts

@@ -60,6 +60,7 @@ function validateEvent(event: SessionEvent, trace: TurnTrace, fail: InvariantFai
 /** Validate one existing log in a single pass and return its tail trace. */
 function seedTrace(session: Session, fail: InvariantFailure): TurnTrace {
   const trace: TurnTrace = { open: false }
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
   for (const event of session.snapshotEvents()) {
     validateEvent(event, trace, fail)
     advanceTrace(trace, event)

+ 1 - 0
packages/workflow/tool-workflow/src/invariant.ts

@@ -135,6 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
 
   const seed = (session: Session): WorkflowTrace => {
     const trace: WorkflowTrace = new Map()
+    // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
     for (const event of session.snapshotEvents().filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail)
     traces.set(session, trace)
     return trace

+ 1 - 1
scripts/lint-rule-fingerprint.spec.ts

@@ -30,7 +30,7 @@ const profiles = {
   test: {
     count: 84,
     indexes: [0, 3, 4, 5],
-    sha256: '7800bf57f0d689a5623e4ad60315b10173158fb9cf988d3f005cb115f2245691',
+    sha256: '7e4e1ed0cfca06ac94eeb26081195b5f753e7527775d5f5b95ccfefac9d2b137',
   },
 } as const satisfies Record<string, Profile>
 

+ 93 - 0
scripts/oxlint-contract.spec.ts

@@ -254,6 +254,99 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
     }
   }, 90_000)
 
+  it('allows Session history reads only in tests or with existing-call waivers', async () => {
+    const suffix = randomUUID()
+    const configPath = await writeContractConfig(suffix)
+    const exampleRoot = `examples/oxlint-contract-${suffix}`
+    const examplePath = `${exampleRoot}/tests/reads.ts`
+    const testPaths = [
+      `packages/core/session/tests/oxlint-contract-${suffix}.ts`,
+      `apps/cli/tests/oxlint-contract-${suffix}.ts`,
+      examplePath,
+      `scripts/oxlint-contract-${suffix}.spec.ts`,
+    ]
+    const productionPaths = [
+      `packages/core/session/src/oxlint-contract-${suffix}.ts`,
+      `scripts/oxlint-contract-${suffix}.ts`,
+    ]
+    const paths = [...testPaths, ...productionPaths]
+    const reads = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session'
+
+export function reads(session: Session): void {
+  session.snapshotEvents()
+  session.eventAt(SessionSeq(0))
+  session.ownEvents()
+}
+`
+    const existing = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session'
+
+export function reads(session: Session): void {
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
+  session.snapshotEvents()
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
+  session.eventAt(SessionSeq(0))
+  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
+  session.ownEvents()
+}
+`
+    const unrelated = `
+/** @deprecated Use the replacement API. */
+function oldApi(): void {}
+
+export function unrelatedRead(): void {
+  oldApi()
+}
+`
+
+    try {
+      await mkdir(join(repositoryRoot, exampleRoot, 'tests'), { recursive: true })
+      await writeFile(join(repositoryRoot, exampleRoot, 'tsconfig.json'), JSON.stringify({
+        extends: '../../tsconfig.base.json',
+        include: ['tests/**/*.ts'],
+      }))
+      await Promise.all([
+        ...testPaths.map(path => writeFile(join(repositoryRoot, path), reads)),
+        ...productionPaths.map(path => writeFile(join(repositoryRoot, path), existing)),
+      ])
+      const args = ['--config', relative(repositoryRoot, configPath), '--format', 'unix', ...paths]
+      const allowed = runRepositoryOxlint(args)
+      expect(allowed.error).toBeUndefined()
+      expect(allowed.signal).toBeNull()
+      expect(allowed.status, normalizedOutput(allowed)).toBe(0)
+
+      await Promise.all([
+        ...testPaths.map(path => writeFile(join(repositoryRoot, path), reads + unrelated)),
+        ...productionPaths.map(path => writeFile(join(repositoryRoot, path), reads)),
+      ])
+      const rejected = runRepositoryOxlint(args)
+      const output = normalizedOutput(rejected)
+      expect(rejected.error).toBeUndefined()
+      expect(rejected.signal).toBeNull()
+      expect(rejected.status, output).toBe(1)
+      const diagnostics = output.split('\n').filter(line => /:\d+:\d+: `\w+` is deprecated\./.test(line))
+      for (const path of testPaths) {
+        const reported = diagnostics.filter(line => line.startsWith(`${path}:`))
+        expect(reported, output).toHaveLength(1)
+        expect(reported[0]).toContain('`oldApi` is deprecated')
+      }
+      for (const path of productionPaths) {
+        expect(diagnostics.filter(line => line.startsWith(`${path}:`)), output).toHaveLength(3)
+      }
+      for (const method of ['snapshotEvents', 'eventAt', 'ownEvents', 'oldApi']) {
+        expect(output).toContain(`\`${method}\` is deprecated`)
+      }
+      expect(output).toContain(
+        'See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).',
+      )
+    } finally {
+      await Promise.all([
+        ...paths.filter(path => path !== examplePath).map(path => rm(join(repositoryRoot, path), { force: true })),
+        rm(join(repositoryRoot, exampleRoot), { recursive: true, force: true }),
+        rm(configPath, { force: true }),
+      ])
+    }
+  }, 90_000)
+
   it('accepts an ignored-only staged selection', () => {
     const result = runOxlint([
       '--fix',