Merge remote-tracking branch 'origin/master' into codex/pr-1037-resolution

# Conflicts:
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
Turtle
2026-08-03 16:49:18 +08:00
1391 changed files with 126134 additions and 10332 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40
2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md
2026-07-20-config-hot-reload-resilience.md: 0f15bb0aaacb6e06c416cbe35b44155279497eee
2026-07-20-config-hot-reload-resilience.zh.md: 8a185c1915b5247150d8bb1dd5c42d69bd4f2a35

View File

@@ -6,33 +6,36 @@ English | [中文](2026-07-20-config-hot-reload-resilience.zh.md)
## Problem
The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones.
An invalid `cordis.yml` edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, including a file or parent directory created after startup.
## Decision
Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers:
The vendored Cordis lifecycle and Loader plugins provide an awaited, compensating config transaction, logged as local modifications 6, 8, and 9 in [vendor/README.md](../../../../vendor/README.md).
- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down.
- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged".
- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay.
`Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang.
Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`.
Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation starts candidates concurrently, awaits every outcome, and restores changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree.
Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse.
HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` method watches one exact path from the nearest existing ancestor, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Both exact-path and ordinary config-file refreshes use that queue. A failure is normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed(filename, error)` event; rejecting observers are logged without stopping later refreshes. Creation, change, and removal are observed.
## Alternatives considered
**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include.
**Contain failures inside `Include.refresh()`.** Rejected because it prevents an HMR host from broadcasting the failure and still permits Loader reconciliation to hide partial application. Include owns candidate parsing and commit; HMR owns containment and observation.
**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place.
**Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition.
**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file).
**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Awaited application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions.
## Consequences
- A bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred.
- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base.
- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync.
- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree).
- A failed live refresh rejects internally, retains or restores the last-good tree when compensation succeeds, and broadcasts one typed failure without becoming an unhandled rejection.
- A rollback failure is visible and may leave an entry unavailable; the event and log do not claim otherwise.
- Fibers waiting on declared dependencies remain valid pending entries: lifecycle settlement means no current work failed, not that every dependency exists.
- Exact config watchers add filesystem resources only for registered paths and release them with their owning HMR fiber.
- The vendored Loader, Include, HMR, and core event typing diverge further from upstream; the complete divergence is maintained in the vendor manifest.
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include.
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections.

View File

@@ -6,33 +6,36 @@ Status: implemented
## Problem
各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。
无效的 `cordis.yml` 编辑不得杀死运行中的 agent(智能体);但若一次看似有效的更新先部分替换 Loader 树,后续配置项才失败,仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新,同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求:HMR(热模块替换)必须观察其模块根目录之外的一个确切文件,包括启动后才创建的文件或父目录。
## Decision
加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方:
vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的配置事务,并在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 6、8、9 条。
- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。
- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。
- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。
`Fiber.update()` 返回其 `internal/update` waterfall(瀑布式事件)的结果。配置校验保持同步,而默认 continuation 返回重启 promise。因此,Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber,并在 fiber 已结算为失败时 reject;等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。
启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。
Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账会并发启动各候选项,等待每项结果,并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。
Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。
HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer;该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 `Error`、记入日志,并通过并行事件 `hmr/config-update-failed(filename, error)` 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。
## Alternatives considered
**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。
**在 `Include.refresh()` 内收容失败。** 已否决,因为这会使 HMR 宿主无法广播失败,却仍允许 Loader 对账掩盖部分应用。Include 负责候选内容的解析与提交;HMR 负责收容和观察。
**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。
**每次编辑配置都重启进程。** 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。
**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。
**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。等待应用完成并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。
## Consequences
- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at <file>`,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。
- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。
- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。
- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。
- 实时刷新失败会在内部 reject;补偿成功时会保留或恢复上一份完好的树,并广播一次类型化失败,而不会成为未处理的 rejection。
- 回滚失败可见,并可能使一个配置项不可用;事件和日志不会误称其已恢复。
- 等待已声明依赖的 fiber 仍是有效的 pending 配置项:生命周期完成只表示当前工作均未失败,而不表示每项依赖都存在。
- 确切配置 watcher 只为已注册路径增加文件系统资源,并随其所属 HMR fiber 一起释放。
- vendor 中的 Loader、Include、HMR 与核心事件类型定义进一步偏离上游;全部分叉均维护在 vendor manifest(元数据清单)中。
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。
`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md
2026-07-28-web-agent-runtime-context.md: 449c9d4ba2b144d02dee4b98ae80c86815aec5c1
2026-07-28-web-agent-runtime-context.zh.md: def1674be5f193739bfb214a24f34590ee075d5f

View File

@@ -0,0 +1,33 @@
# Agent Note: Web agents receive explicit runtime context
Status: implemented
English | [中文](2026-07-28-web-agent-runtime-context.zh.md)
## Problem
The shared CLI base configured an empty deployment persona, the Web overlay did not replace it, and the Web launcher added no source or interaction-surface section. A session header recorded its working directory for tools and persistence, but the model prompt did not state that directory or identify the DeepSeek Harness Web GUI. A request such as “change this page's theme” therefore made the agent search the selected project for an unspecified page, even when the user meant the GUI running the session.
## Decision
The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The launcher registers that setup before mounting the config tree; its `systemPrompt` injection therefore installs both sections before later prompt consumers such as the agent loop can activate and emit a request header. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other.
The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant.
## Verification
The focused startup-order test registers a later `systemPrompt` consumer and proves that it observes both launcher sections on its first activation. The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, registers the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order.
## Alternatives considered
**Send URL, DOM, or screenshots with every prompt.** The observed failure needed stable product orientation, while the current root URL does not identify a selected component and no visual capture exists in the message contract. Adding dynamic page state would require a separate logged model-input design and is not implied by this fix.
**Require the session Workspace to be the harness checkout.** Workspace cwd is the user's task target and may legitimately be an empty project or another repository. Conflating it with the application's source location would break that boundary and leave installed or externally launched sessions ambiguous.
**Put Web wording in the global harness identity.** `dsh-system-prompt` serves TUI, ACP, SDK, and custom deployments that do not run in a browser. The composing Web app owns this surface fact.
**Change the existing source-location section for every CLI surface.** The source section is shared with TUI and states only the checkout fact. Keeping Web orientation separate preserves that reusable contract and avoids telling headless or terminal agents that they are in a browser.
## Consequences
Web requests gain a short stable prompt prefix and may invalidate provider prefix caches once when this change is deployed. Agents can distinguish the GUI source checkout from the selected Workspace and resolve ordinary references to the current app without a clarification round trip. References to a specific visual state remain bounded by the explicit no-DOM/no-route/no-screenshot statement and may still require a path, description, or attachment.

View File

@@ -0,0 +1,33 @@
# Agent Note: Web agent 获得显式运行时上下文
Status: implemented
[English](2026-07-28-web-agent-runtime-context.md) | 中文
## 问题
CLI 共享 base 配置了空的部署 persona,Web overlay 没有替换它,而 Web 启动器既未添加源码提示词段,也未添加交互界面提示词段。会话 header 会记录工作目录,供工具与持久化使用,但模型提示词既不说明该目录,也不标识 DeepSeek Harness Web GUI。因此,当用户提出「修改这个页面的主题」之类的请求时,即使用户指的是承载当前会话的 GUI,agent 也只能在所选项目中搜索一个未明确说明的页面。
## 决策
`apps/cli/config/web.cordis.yml` 这份 Web/无头共享 overlay 提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。启动器会在挂载配置树前注册这项设置;因此,它的 `systemPrompt` 注入会在 agent loop(智能体循环)等后续提示词消费方激活并发出 request header 之前安装这两个提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。
Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时,它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文,使模型能够识别产品,但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。
## 验证
聚焦启动顺序的测试会注册一个后续的 `systemPrompt` 消费方,并证明该消费方首次激活时就能观察到启动器的两个提示词段。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay,注册与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。
## 考虑过的替代方案
**每次提示词都发送 URL、DOM 或截图。** 本次故障只需要稳定的产品定位;当前根 URL 无法标识所选组件,消息契约中也不存在视觉捕获内容。添加动态页面状态需要另行设计可记录的模型输入,不属于本次修复的隐含范围。
**要求会话 Workspace 必须是 harness checkout。** Workspace cwd 是用户任务的目标,可以合理地指向空项目或其他仓库。将其与应用源码位置混为一谈会破坏这一边界,并且仍无法消除已安装版本或外部启动会话中的歧义。
**把 Web 文案放入全局 harness 身份。** `dsh-system-prompt` 还服务于 TUI、ACP、SDK 和不在浏览器中运行的自定义部署。该界面事实应由组装 Web 应用负责。
**为所有 CLI 界面修改现有源码位置提示词段。** TUI 也复用源码位置提示词段,而该段只陈述 checkout 事实。单独保留 Web 界面定位可以维持这份可复用契约,避免错误地告诉无头或终端 agent 它们正处于浏览器中。
## 影响
Web 请求会增加一段较短且稳定的提示词前缀;部署此变更时,模型提供方的前缀缓存可能失效一次。agent 可以区分 GUI 源码 checkout 与所选 Workspace,并且无需再经过一轮澄清即可解析对当前应用的一般指代。对特定视觉状态的指代仍受「无 DOM/无路由/无截图」这一显式边界约束,必要时仍需用户提供路径、描述或附件。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
2026-07-28-web-gui-feedback-loop.md: 039d2aebeeef903d10838a46b48e5172f0195126
2026-07-28-web-gui-feedback-loop.zh.md: 34b6b26d4ce7c7e194e641536fffc503c013188b

View File

@@ -0,0 +1,39 @@
# Agent Note: Web GUI changes close the loop on the existing URL
Status: implemented
English | [中文](2026-07-28-web-gui-feedback-loop.zh.md)
## Problem
The Web agent could identify neither the GUI hosting its session nor the URL the user was viewing. The [runtime-context decision](2026-07-28-web-agent-runtime-context.md) supplies the first fact, but a GUI edit still had no executable acceptance target: source edits, artifact builds, a listening process, and the user's existing page were unrelated observations. Repository affordances made a wrong substitute look valid because `apps/web/package.json` exposed `vite` as its `dev` script and bare Vite returned HTTP 200 even though it could not inject `window.__DSH_BOOT__`.
The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md) owns the event-log timeline and why the original checks accepted the wrong page, process, and port.
## Decision
`dsh web` publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address.
The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked.
The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged.
No server restart or replacement is required merely because static artifacts changed. The host reads `index.html` and static assets on each request, while client bundles are also served from their current files with `no-cache`; a refresh of the existing URL is therefore the acceptance path after the relevant shell and plugin bundles are rebuilt. Starting a separate server proves only that a separate server works. If the user explicitly requests another long-running server, the existing managed background-task contract owns its lifecycle and completion notices; shell `&` is not an alternative lifecycle.
## Verification
The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement.
## Alternatives considered
**Extend only the system prompt.** Rejected because it would leave the target unavailable to tools, preserve the misleading bare-Vite path, and fail to prove how an existing process observes rebuilt artifacts.
**Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail.
**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition.
**Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive.
## Consequences
Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one.

View File

@@ -0,0 +1,39 @@
# Agent Note: Web GUI 改动在现有 URL 上闭环
Status: implemented
[English](2026-07-28-web-gui-feedback-loop.md) | 中文
## 问题
Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道用户正在查看哪个 URL。[运行时上下文决策](2026-07-28-web-agent-runtime-context.md)提供前一项事实,但 GUI 编辑仍然没有可执行的验收目标:源码编辑、产物构建、监听中的进程与用户已打开的页面只是互不关联的观察结果。仓库提供的入口让错误的替代方案显得合理,因为 `apps/web/package.json` 将 `vite` 暴露为 `dev` 脚本,而裸 Vite 即使无法注入 `window.__DSH_BOOT__`,仍会返回 HTTP 200。
[事故复盘](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md)集中记录事件日志时间线,并解释原有检查为何会接受错误的页面、进程和端口。
## 决策
`dsh web` 发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。
按模式区分的提示词让 agent 而非用户负责隐藏的启动契约。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。
静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器,则现有受管后台任务契约负责其生命周期和完成通知;shell `&` 不能替代这套生命周期机制。
## 验证
无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 和 `$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发契约。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
## 考虑过的替代方案
**仅扩展系统提示词。** 不予采纳,因为这样会让工具仍然无法获得目标,保留具有误导性的裸 Vite 路径,并且无法证明现有进程如何观察重新构建的产物。
**删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。
**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合。
**每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。
## 影响
Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md
2026-07-29-human-transcript-append-origin.md: a296b93d538d9c28bd61ee8fd0530863b4bfd878
2026-07-29-human-transcript-append-origin.zh.md: 96e0cd1038fe8904dfd4c1eceaae9b25339c5dca
2026-07-29-human-transcript-append-origin.md: a47dd49dd831cdd32d520137417bf47d2c056a09
2026-07-29-human-transcript-append-origin.zh.md: 31639bd9aac5d6dace80392004f37c747bff2c36

View File

@@ -24,9 +24,9 @@ No persisted event, RPC envelope, compaction transaction, or model-visible surfa
## Deferred
The browser client still builds its conversation from the model surface through `FoldAdapter`, so compaction still collapses web history to a single context row. The same predicate is the fix there, together with an append-order transcript projection and a marker component; that work is a separate change against `packages/client/runtime` and `packages/client/ui-conversation`.
The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint's provenance group, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page.
That work must handle a page whose checkpoint cites a `surfaceOp.start` outside the window: pagination no longer spends quota on the checkpoint, so it never cuts on the checkpoint's provenance group, and `FoldAdapter` pads absent events with a non-surface sentinel — so `SurfaceManager` rejects the range and `nodes()` falls back to `degradedSeqs()` with a logged error. The hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page. `degradedSeqs()` — every surface-eligible event in append order — is already close to the transcript projection A2 needs, which is the shape to build deliberately rather than reach as a degradation. Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is likewise out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home.
The terminal's [live compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction.
## Alternatives considered

View File

@@ -24,9 +24,9 @@ Status: implemented
## Deferred
浏览器客户端仍通过 `FoldAdapter` 从模型 surface 构建会话,因此压缩在 Web 端仍会把历史折叠成一行上下文。那里的修复用的是同一个谓词,另需按追加顺序的记录投影与一个标记组件;该工作是针对 `packages/client/runtime` 与 `packages/client/ui-conversation` 的独立变更。
浏览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中单独修复:它按日志顺序投影同一份 append 来源记录并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会按检查点的溯源分组切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。
该工作必须处理这样一页:其检查点引用的 `surfaceOp.start` 落在窗口之外。分页不再为检查点消耗额度,因此永远不会按检查点的溯源分组切分;而 `FoldAdapter` 会用一个非 surface 的哨兵事件填补缺失事件——于是 `SurfaceManager` 拒绝该范围,`nodes()` 退化为 `degradedSeqs()` 并记录一条错误。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。`degradedSeqs()`——按追加顺序的每个 surface 可入事件——已经很接近 A2 所需的记录投影,因此那正是应当刻意构建的形态,而不是作为退化路径被动落到的结果。渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,同样不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处。
终端的[实时压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件。
## Alternatives considered

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md
2026-07-30-composer-context-stack-order.md: 9c269bcaf7fa360b6a5d0e16fd8cda48aa285d66
2026-07-30-composer-context-stack-order.zh.md: 47288141ea4591b29adde0f85e810fc797740488
2026-07-30-composer-context-stack-order.md: 150225ce27fa8459703170622cef754b7694e2fc
2026-07-30-composer-context-stack-order.zh.md: f13ffc3c1136707a86ba5636a5b69304a132df01

View File

@@ -10,7 +10,7 @@ Goal, Todo, and Queue contribute independently to the same `conversation.input.d
## Decision
The composer context stack has one canonical ascending order: Goal at `0`, Todo at `10`, and Queue at `20`, followed by the composer bar outside the list. The gaps leave room for future entries to declare their intended position without relying on plugin activation order.
The [Todo-first alignment decision](2026-08-02-todo-first-composer-context-order.md) owns the current ascending order. This note retains the stack contract around that order: numeric gaps leave room for future entries to declare their intended position without relying on plugin activation order, and the composer bar follows the list.
`ConversationRoot` owns the 6px space between independent context cards. Goal is a standalone 752×36px card and collapsed Todo is a standalone 752×44px card. Queue is the terminal dock entry: its 776px wrapper contains the same 752px panel column and subtracts the shared gap plus a named 5px layout overlap, so the later composer card paints over only the queue edge. Empty entries render null and consume no gap.
@@ -18,7 +18,7 @@ The order and overlap are separate contracts. Registration order establishes sem
## Verification
Registration tests pin all three order values. Browser screenshots cover the full Goal/Todo/Queue matrix, Goal+Todo without Queue, and Queue alone; together they exercise every adjacency: Goal–Todo, Todo–Queue, and Queue–Composer.
Registration tests pin all three order values. The keyless Queue browser scenario renders Todo, Goal, and Queue together, pins their accessibility order, and checks their visible card edges; focused Goal and Queue scenarios cover their independent states.
## Alternatives considered
@@ -30,4 +30,4 @@ Registration tests pin all three order values. Browser screenshots cover the ful
## Consequences
The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Goal `0`, Todo `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary.
The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Todo `0`, Goal `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary.

View File

@@ -10,7 +10,7 @@ Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表
## 决策
composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo 为 `10`,Queue 为 `20`,随后是位于列表外的 composer bar。顺序值之间的空档使未来条目可以声明预期位置,不必依赖插件激活顺序。
[Todo 优先的对齐决策](2026-08-02-todo-first-composer-context-order.md)规定当前的升序排列。本记录保留围绕该顺序的堆栈契约:数值间隔使未来条目可以声明预期位置,不必依赖插件激活顺序;composer bar 位于列表之后。
`ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null,不占用间距。
@@ -18,7 +18,7 @@ composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo
## 验证
注册测试固定了三个顺序值。浏览器截图覆盖完整的 Goal/Todo/Queue 组合矩阵、没有 Queue 的 Goal+Todo,以及仅有 Queue 的情况;这些场景共同覆盖全部相邻关系:Goal–Todo、Todo–Queue 与 Queue–Composer。
注册测试固定了三个顺序值。无密钥 Queue 浏览器场景同时渲染 Todo、Goal 和 Queue,固定它们的无障碍顺序,并检查其可见卡片边缘;聚焦的 Goal 与 Queue 场景覆盖各自的独立状态。
## 考虑过的替代方案
@@ -30,4 +30,4 @@ composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo
## 后果
所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Goal `0`、Todo `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。
所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Todo `0`、Goal `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md
2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1
2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275

View File

@@ -0,0 +1,35 @@
# Agent Note: Hover popup pointer grace
Status: implemented
English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md)
## Problem
Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back.
## Decision
`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup.
`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping `disabled` dismiss immediately, ahead of the grace.
`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time.
## Alternatives considered
**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content.
**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize.
**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached.
**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart.
## Consequences
The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set.
## Testing
`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model.

View File

@@ -0,0 +1,35 @@
# Agent Note: 悬浮弹层的指针宽限期
Status: implemented
[English](2026-07-30-hover-popup-pointer-grace.md) | 中文
## 问题
工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。
## 决策
`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。
`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。
`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。
## 考虑过的替代方案
**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。
**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。
**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。
**让两个原子组件各自持有计时器。** 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。
## 后果
悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。
## 测试
`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md
2026-07-30-source-checkout-workdir-distinction.md: ba6d9dd12b55a54d4ae8d2e91ad83ac3c1dc47fd
2026-07-30-source-checkout-workdir-distinction.zh.md: ffc2ac7baa2b1bb8ce54607638c35869fc338825

View File

@@ -0,0 +1,33 @@
# Agent Note: Source checkout paths do not define working directories
Status: implemented
English | [中文](2026-07-30-source-checkout-workdir-distinction.zh.md)
## Problem
The `harness:source` prompt section follows the [source-location decision](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md), but its original wording called the checkout “your own source code” without distinguishing that path from the session workspace. In a normal TUI configuration that does not state `{{cwd}}` in its persona, this may be the only fixed absolute path near the start of the system prompt. DeepSeek V4 could therefore answer “what's the workdir?” with the harness checkout instead of determining the session's current working directory.
A blanket statement that the checkout is not the working directory would also be false. `dsh meta` intentionally makes the source checkout both values.
## Decision
The section identifies the path as the “DeepSeek Harness implementation checkout.” It says that the checkout location and current working directory are separate values that may differ, forbids inferring the working directory from the checkout path, directs the model to use `pwd`, and limits the checkout's purpose to inspecting or extending DSH itself.
The path derivation, global `harness:source` ownership, and `-99` ordering remain unchanged. Describing the values as conceptually separate rather than always unequal keeps the instruction accurate in both ordinary project sessions and `dsh meta`.
## Verification
The `dsh-app-boot` unit test pins the exact text and its ordering. The CLI keyless PTY smoke inspects the assembled request header. The TUI `source-checkout-workdir` snapshot mounts the section with `/opt/dsh-source`, asks “what's the workdir?” through a recorded DeepSeek V4 turn, and requires the replayed transcript to run `pwd` and report the generated workspace rather than the checkout.
## Alternatives considered
**Say that the checkout is never the working directory.** Rejected because `dsh meta` deliberately makes them the same path.
**Put the current working directory in the global source section.** Rejected because the source section is launcher-global while the working directory belongs to each session; combining them would duplicate the loop's `cwd` ownership and make a stable source fact vary per agent.
**Remove the source path from the prompt.** Rejected because self-referential DSH tools still need a reliable checkout location when the launcher starts from an unrelated project.
## Consequences
The prompt is longer and a direct working-directory question may spend one inexpensive `pwd` tool call. In exchange, the model no longer treats the harness implementation path as an implicit task workspace, while meta mode remains truthful when both values coincide.

View File

@@ -0,0 +1,33 @@
# Agent Note: 源码 checkout 路径不定义工作目录
Status: implemented
[English](2026-07-30-source-checkout-workdir-distinction.md) | 中文
## Problem
`harness:source` 提示词段遵循[源码位置决策](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md),但原有措辞把 checkout 称为“你自己的源代码”,却没有区分该路径与会话 workspace。在 persona 不声明 `{{cwd}}` 的普通 TUI 配置中,这可能是系统提示词开头附近唯一固定的绝对路径。因此,DeepSeek V4 可能会直接用 harness checkout 回答“what's the workdir?”,而不是确定会话的当前工作目录。
直接断言 checkout 不是工作目录同样不准确。`dsh meta` 会有意让源码 checkout 同时充当这两个值。
## Decision
该提示词段将路径标识为“DeepSeek Harness implementation checkout”。它说明 checkout 位置与当前工作目录是两个可能不同的值,禁止从 checkout 路径推断工作目录,指示模型使用 `pwd`,并限定该 checkout 只用于检查或扩展 DSH 自身。
路径推导方式、全局 `harness:source` 所有权和 `-99` 顺序均保持不变。将两者描述为概念上独立、而不是始终不相等,使这条指令在普通项目会话和 `dsh meta` 中都准确。
## Verification
`dsh-app-boot` 单元测试固定了完整文本及其顺序。CLI 无密钥 PTY 冒烟测试检查组装后的请求 header。TUI 的 `source-checkout-workdir` 快照把该提示词段挂载为 `/opt/dsh-source`,通过录制的 DeepSeek V4 turn 提问“what's the workdir?”,并要求回放 transcript 运行 `pwd`,报告生成的 workspace 而不是 checkout。
## Alternatives considered
**声明 checkout 永远不是工作目录。**拒绝:`dsh meta` 会有意让它们指向同一路径。
**把当前工作目录写入全局源码提示词段。**拒绝:源码提示词段由 launcher 全局持有,而工作目录属于各个会话;将两者合并会重复 loop 对 `cwd` 的所有权,还会让稳定的源码事实随 agent 变化。
**从提示词中删除源码路径。**拒绝:launcher 从无关项目启动时,自引用 DSH 工具仍需要可靠的 checkout 位置。
## Consequences
提示词会变长,直接询问工作目录时可能多花一次廉价的 `pwd` 工具调用。作为交换,模型不再把 harness 实现路径当作隐含的任务 workspace;当 meta 模式使两个值重合时,提示词仍然准确。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md
2026-07-30-tui-adapter-registration-race.md: fd08e7b6130bc8f7e3cd5287a9970f5fb47244a8
2026-07-30-tui-adapter-registration-race.zh.md: 0c6bba4bbc8c3303d9c471c3164faa816438b333

View File

@@ -0,0 +1,29 @@
# Agent Note: TUI model-context resolution defers on the adapter-registration race
Status: implemented
English | [中文](2026-07-30-tui-adapter-registration-race.zh.md)
## Problem
Cordis activates plugins by service availability, not configuration order, so the TUI (whose `inject` requires only the `llm` service) can mount before a configured adapter plugin such as `dsh-llm-pi-ai` finishes registering its provider routes. The TUI's model controller resolves the selected model's context window immediately on mount; when the agent's route pointed at a not-yet-registered provider, `resolveModelInfo` rejected with `NO_ADAPTER` and every fresh session printed `Could not resolve model context: no adapter registered for provider "…"` — a spurious error for a fully working configuration (the adapter registered milliseconds later, and chatting worked).
## Decision
The TUI model controller treats a `NO_ADAPTER` rejection of its context-window resolution as a transient state rather than an error: it parks the resolution silently and re-resolves on the next `llm/adapters-updated` commit — the payload-free registry notification `LlmService` already fires at every route commit point. A commit that still lacks the route parks the wait again, so unrelated topology changes stay silent. Any target change re-enters the resolution and clears the pending wait, so the deferred state can never go stale against the current selection; every other resolution error still prints the notice.
## Alternatives considered
**Have the TUI wait for boot to settle before resolving.** The TUI has no Loader dependency (tests and embedders run without one) and "settled" is not observable from inside a plugin; adding a Loader coupling for one cosmetic resolution inverts the dependency direction.
**Poll or retry with a timer.** A timer guesses at activation latency, still mis-prints on a slow adapter, and adds a tunable with no owner. The registry already announces every commit through `llm/adapters-updated`; subscribing is precise and free.
**Order the config so adapters load first.** Row order carries no load semantics in the Loader (activation is service-driven by design), so this cannot be expressed in configuration.
**Suppress NO_ADAPTER errors entirely.** A permanently missing adapter (typo in the provider name) would then never surface in the context-window path. Deferring keeps the signal: a wrong provider name still shows `model unset`-like behavior in the selector and fails loudly at dispatch, while the startup race resolves itself.
**Resolve the context window per submitted message instead of at mount.** The send path already resolves per step (`prepareCall()`), and the indicator is displayed continuously, not only when sending; per-submit display resolution would leave the indicator blank until the first message and re-run adapter I/O for a value that only changes on route changes.
## Consequences
A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked; the listener's disposer is released by the channel's `detachListeners()` through the controller's `detach()`, symmetric with the sibling channel listeners. Covered by three TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, a target change drops the stale wait, and after channel detach a registry commit no longer re-enters resolution.

View File

@@ -0,0 +1,29 @@
# Agent Note: TUI 模型上下文解析在适配器注册竞争时延后重试
Status: implemented
[English](2026-07-30-tui-adapter-registration-race.md) | 中文
## Problem
Cordis 按服务可用性而非配置顺序激活插件,因此 TUI(其 `inject` 只要求 `llm` 服务)可能在 `dsh-llm-pi-ai` 这类已配置的适配器插件完成提供方路由注册之前就挂载。TUI 的模型控制器在挂载时立即解析所选模型的上下文窗口;当 agent 的路由指向尚未注册的提供方时,`resolveModelInfo` 以 `NO_ADAPTER` 拒绝,于是每个新会话都会打印 `Could not resolve model context: no adapter registered for provider "…"` —— 对一份完全正常的配置报出的虚假错误(适配器几毫秒后就完成注册,对话也一切正常)。
## Decision
TUI 模型控制器把上下文窗口解析中的 `NO_ADAPTER` 拒绝视为瞬态状态而非错误:静默搁置这次解析,并在下一次 `llm/adapters-updated` 提交时重新解析——这是 `LlmService` 本就在每个路由提交点发出的无载荷注册表通知。若某次提交仍缺少该路由,等待会被再次搁置,因此无关的拓扑变化保持沉默。任何目标变更都会重新进入解析并清除挂起的等待,因此延后状态绝不会相对当前选择变陈旧;其他所有解析错误仍照常打印通知。
## Alternatives considered
**让 TUI 等启动结算后再解析。** TUI 不依赖 Loader(测试和嵌入方在没有 Loader 的环境下运行),而且"已结算"在插件内部不可观测;为一次外观性的解析引入 Loader 耦合会颠倒依赖方向。
**用定时器轮询或重试。** 定时器只能猜测激活延迟,遇到慢适配器仍会误报,还会引入一个没有归属者的可调参数。注册表本就通过 `llm/adapters-updated` 公告每次提交;订阅它既精确又零成本。
**调整配置顺序让适配器先加载。** Loader 中行顺序不承载加载语义(激活按设计由服务驱动),因此这无法用配置表达。
**彻底压制 NO_ADAPTER 错误。** 那样的话,永久缺失的适配器(提供方名字拼错)在上下文窗口路径上就永远不会暴露。延后重试保留了信号:错误的提供方名字仍会在选择器中表现出类似 `model unset` 的行为,并在分派时大声失败,而启动竞争则自行化解。
**改为在每次提交消息时解析上下文窗口,而不是在挂载时。** 发送路径本就按步解析(`prepareCall()`),且指示器是持续显示的,不只在发送时;按提交解析显示值会让指示器在首条消息之前一直空白,并为一个仅在路由变化时才变的值反复执行适配器 I/O。
## Consequences
真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作;监听器的 disposer 经由控制器的 `detach()` 在频道的 `detachListeners()` 中释放,与同级频道监听器保持对称。由三个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待;频道 detach 之后注册表提交不再重新进入解析。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
2026-07-30-web-transcript-log-ordered-projection.md: 4bc629bfa619a2858ec0335a6136d3a5a24b025a
2026-07-30-web-transcript-log-ordered-projection.zh.md: d011ce9453bc1dbb9fdb62372aa1126c95ffb143

View File

@@ -0,0 +1,72 @@
# Agent Note: The browser conversation is a log-ordered human transcript
Status: implemented
English | [中文](2026-07-30-web-transcript-log-ordered-projection.zh.md)
## Problem
The browser client built its conversation from the model-visible surface: `FoldAdapter` ran the core `SurfaceManager` over the history window and read `surface.nodes`. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the web flow collapsed every message it shadowed into a single dim context row — conversation the user had already read. Nothing was lost from the log; the defect was entirely in the projection, and [the terminal and the host gateway were fixed the same way](2026-07-29-human-transcript-append-origin.md) while the browser was left for this change.
Surface order made two further problems structural. It is not seq-ascending after a replacement — `SurfaceManager` splices the high-seq checkpoint into the position of the range it shadows — so log-only nodes merged into that array by numeric seq (slash-command rows, interrupted frozen nodes) could be flushed ahead of the checkpoint and never interleave into the retained tail again. And because pagination no longer spends `maxMessages` quota on replacement copies, a page can now carry a checkpoint whose `surfaceOp.start` lies outside the window; the core fold rejects that range, so `nodes()` fell back to a lenient linear scan behind a `console.error` and published a `foldDegraded` flag describing the failure.
## Decision
`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the raw window in log order: every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends.
Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged.
`foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended.
The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text.
No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
## Recognizing a checkpoint: one declaration, pinned at compile time
Recognition needs all three conditions, as in the terminal: `event.type === 'user/message'`, the compaction seam's checkpoint plugin source, **and** `isReplacementSurfaceEvent(event)`. A plugin-sourced `user/message` that *appends* is injected context — a session-reference card — not a compaction.
What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root**, not the package. The root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against the client's `sessions: ISessions` — `TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout) — and that holds for a type-only import too, because the collision is a compiler fact rather than a bundler one.
The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
```
Renaming the seam's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it.
The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type.
## What #835's positional anchors were for, and why they are dissolved rather than lost
The unmerged manual-compaction-queueing branch fixes the same interleaving bug by recording a per-event anchor — the surface tail at append time — and retargeting shadowed anchors onto the checkpoint. That mechanism exists to make positional anchors survive surface **reordering**. The human transcript is never re-ordered, so anchors have nothing to retarget: the precondition is removed, not the fix discarded. The mechanism is absent from this base and is not authored here.
## Alternatives considered
**Value-import the predicate** from the new leaf and add `dsh-compact` to the client `INLINE_SAFE` allowlist. Rejected: the client needs the plugin id, not the predicate — a type is enough, and an erased import never reaches the purity gate, so nothing has to be admitted to it. The allowlist would only matter for a value import, and there it is a poor trade: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root along with the leaf.
**A bare shape rule** — any replacement `user/message` is a compaction. Rejected: correct today only because compaction is the sole producer of replacement `user/message`s, with nothing to catch it if that changes. The pinning spec costs one file and removes exactly that risk.
**Tag the checkpoint host-side** through the projection or wire contract. Rejected: most aligned with the "collaborate through cordis services" rule, but the client folds raw `SessionEvent`s today, so it means a wire contract change out of proportion to one pure predicate.
**Move frozen-node ownership into the adapter** (`nodes(extraNodes)`), as the unmerged branch does. Rejected: the interrupted nodes come from the `turn/end` sweep `Session` already runs over the window, and with a seq-monotonic transcript the simple shape is correct — the adapter returns nodes, the session merges frozen ones by seq. Widening the adapter's signature would buy nothing and split the sweep from its product.
**Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract.
## Consequences
Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files.
`ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor.
The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves.
The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface.
## Deferred
The terminal's [compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress.

View File

@@ -0,0 +1,72 @@
# Agent Note: 浏览器会话是按日志顺序投影的人类对话记录
Status: implemented
[English](2026-07-30-web-transcript-log-ordered-projection.md) | 中文
## Problem
浏览器客户端从模型可见的 surface 构建会话:`FoldAdapter` 在历史窗口上运行核心 `SurfaceManager` 并读取 `surface.nodes`。一次成功的压缩会用一个检查点节点替换一段 surface 范围,因此该替换一落地,Web 流就把它所遮蔽的每条消息折叠成一行灰暗的上下文——那是用户已经读过的对话。日志中什么都没丢失;缺陷完全在投影层,而[终端与宿主历史网关已按同一方式修复](2026-07-29-human-transcript-append-origin.md),浏览器留给了本次变更。
surface 顺序还让另外两个问题成为结构性的。一次替换之后它并非按 seq 升序——`SurfaceManager` 把高 seq 的检查点拼接到它所遮蔽范围的位置上——因此按数值 seq 归并进该数组的仅日志节点(斜杠命令行、被打断的冻结节点)可能被冲刷到检查点之前,再也无法交错回保留下来的尾部。而且由于分页不再为 replacement 副本消耗 `maxMessages` 额度,一页现在可以携带一个 `surfaceOp.start` 落在窗口之外的检查点;核心 fold 拒绝该范围,于是 `nodes()` 退回到一次宽容的线性扫描、打印一条 `console.error`,并发布一个描述该失败的 `foldDegraded` 标志。
## Decision
`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影原始窗口:每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话,标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者现在在两个前端上都已分离。
节点顺序天然按 seq 单调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折叠成 `CommandNode`,按 seq 插入一个本已单调的数组——无锚点,无重排。`Session` 保留被打断的冻结节点的归属,用一次普通排序按其分数 seq 归并,而这现在恰好就是流顺序。检查点所引被遮蔽范围落在窗口之外的窗口没有范围需要解析,因此标记正常渲染且不打印任何日志。
`foldDegraded` 从 `ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。
标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。
没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。
## 识别检查点:同一份声明,在编译期钉住
识别需要三个条件同时成立,与终端一致:`event.type === 'user/message'`、压缩缝隙的检查点插件来源,**以及** `isReplacementSurfaceEvent(event)`。一条 append 的插件来源 `user/message` 是注入上下文——跨会话引用卡片——不是压缩。
从 `packages/client/*` 程序无法到达的是 `dsh-compact` 的**根部**,而不是这个包。根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与客户端的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则;这一点对仅类型导入同样成立,因为该冲突是编译器事实而非打包器事实。
本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
```
重命名缝隙的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。
因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。
## #835 的位置锚点是为什么而存在,以及为什么它是被溶解而非丢失
尚未合并的排队式手动压缩分支用另一种方式修同一个交错缺陷:为每个事件记录一个锚点——追加时的 surface 尾部——并把被遮蔽的锚点重定向到检查点上。该机制的存在是为了让位置锚点在 surface **重排**中存活。人类对话记录永不被重排,因此锚点没有任何东西需要重定向:前提被移除,修复并未被丢弃。该机制在本基线上并不存在,本次也不撰写它。
## Alternatives considered
**从新叶子值导入该谓词**,并把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单。已拒绝:客户端需要的是插件 id,不是谓词——一个类型就够了,而被擦除的导入根本不会抵达纯度门禁,因此无需向它放行任何东西。白名单只在值导入时才有意义,而在那里它是笔糟糕的交换:`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包会连它那个会导入 cordis 的根部一起放行。
**一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它今天正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。
**在宿主侧给检查点打标**,经投影或线协议。已拒绝:这最贴合“经 cordis 服务协作”的规则,但客户端今天折叠的是原始 `SessionEvent`,因此这意味着一次线协议契约变更——为一个纯谓词付出的代价不成比例。
**把冻结节点的归属移进适配器**(`nodes(extraNodes)`),像那个未合并分支所做的那样。已拒绝:被打断的节点来自 `Session` 已经在窗口上运行的 `turn/end` 清扫,而在按 seq 单调的记录之上,简单形态就是正确的——适配器返回节点,会话按 seq 归并冻结节点。加宽适配器签名什么也换不到,还会把清扫与它的产物拆开。
**把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。
## Consequences
压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。
`ConversationNode` 增加第八个分支,因此每个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无单元格”分支,使标记不贡献单元格但仍推进耗时游标。
性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。
Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。
## Deferred
终端的[压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。

View File

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

View File

@@ -0,0 +1,28 @@
# Agent Note: Compaction checkpoints use an English engineering register
Status: implemented
English | [中文](2026-07-31-english-compaction-checkpoints.zh.md)
## Problem
A compaction checkpoint becomes part of the next model request's durable prefix. When a multilingual conversation leads the compactor to preserve its narrative material in the conversation language, the checkpoint can introduce a large amount of a language that is absent from the code, tool output, and existing reasoning prefix. That language then persists across later compaction cycles and can influence the conversation model's reasoning register.
## Decision
`COMPACTION_INSTRUCTION` requires an English-language internal engineering checkpoint. It asks the model to translate narrative source material as needed while preserving exact literals, including paths, commands, errors, identifiers, signatures, and quoted wording when exactness matters. The checkpoint's headings and terse engineering bullets remain the existing structured format.
The requirement is integrated into the first sentence of the trailing compaction instruction. The replayed system prompt, tools, and conversation history remain byte-identical to the routed request, so the change retains the prefix-cache reuse owned by the [compaction summary prefix-cache note](2026-07-21-compaction-summary-prefix-cache-reuse.md).
## Alternatives considered
- **Leave checkpoint language to the replayed conversation** — rejected: the checkpoint is a durable prompt prefix, so preserving a transient conversational register can amplify it across later compactions.
- **Constrain the conversation model's language** — rejected: the policy is for an internal checkpoint, not the user's visible conversation, and a conversation-wide rule would unnecessarily change normal interaction.
- **Require ASCII-only output** — rejected: ASCII is a character-set constraint rather than an engineering-register constraint and would unnecessarily distort legitimate literals and technical material.
- **Append a separate final English-only sentence** — rejected: stating the requirement in the instruction's opening output contract is shorter and ties it directly to the checkpoint being requested.
## Consequences
- New checkpoints normalize narrative context into English while retaining the exact strings that future tool use and code work depend on.
- Existing checkpoint structure, compaction routing, and cache alignment are unchanged; only the final user instruction is different.
- The direct summarization call remains outside transcript snapshots because it emits no `assistant/chunk` events. The real-loop regression instead asserts the exact final instruction received by the summarization request.

View File

@@ -0,0 +1,28 @@
# Agent Note: 压缩检查点使用英语工程文体
Status: implemented
[English](2026-07-31-english-compaction-checkpoints.md) | 中文
## 问题
压缩(compaction)检查点会成为下一次模型请求中持久存在的前缀。当多语言对话使压缩器以对话语言保留叙述性材料时,检查点可能引入大量代码、工具输出和既有推理(reasoning)前缀中均未出现的语言内容。该语言随后会在后续压缩周期中持续存在,并影响对话模型的推理文体。
## 决策
`COMPACTION_INSTRUCTION` 要求生成英语的内部工程检查点。它要求模型在必要时翻译叙述性源材料,同时保留精确的字面量;这包括路径、命令、错误、标识符、签名,以及精确性重要时的引用措辞。检查点的标题及简洁的工程项目符号仍沿用既有的结构化格式。
这项要求被整合到尾部压缩指令的第一句话中。回放的系统提示词、工具和对话历史与已路由请求保持字节级一致,因此该变更保留 [compaction summary prefix-cache note](2026-07-21-compaction-summary-prefix-cache-reuse.md) 所确立的前缀缓存复用。
## 考虑过的替代方案
- **让回放的对话决定检查点语言**——不采纳:检查点是持久的提示词前缀,保留短暂的对话文体可能在后续压缩中放大这种影响。
- **约束对话模型的语言**——不采纳:该策略针对内部检查点,而不是用户可见的对话;对整个对话施加规则会不必要地改变正常交互。
- **要求仅输出 ASCII**——不采纳:ASCII 是字符集约束,而非工程文体约束,并会不必要地扭曲合法的字面量和技术材料。
- **在末尾追加一句独立的仅限英语要求**——不采纳:在指令开头的输出契约中说明该要求更简洁,也直接将其与所请求的检查点绑定。
## 后果
- 新检查点会将叙述性上下文规范化为英语,同时保留未来工具使用和代码工作所依赖的精确字符串。
- 既有检查点结构、压缩路由和缓存对齐保持不变;只有最后一条 user 指令不同。
- 直接摘要调用仍不纳入 transcript(文本记录)快照,因为它不会发出 `assistant/chunk` 事件。真实循环回归改为断言摘要请求收到的精确最终指令。

View File

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

View File

@@ -0,0 +1,59 @@
# Agent Note: fail-loud releases the terminal before exiting
Status: implemented
English | [中文](2026-07-31-fail-loud-releases-the-terminal.zh.md)
## Problem
A `dsh` launch whose config failed validation printed its diagnostic and returned the user to a broken shell. Typing was invisible, and the next command was mangled by stray text:
```
dsh: fatal load failure: ValidationError: invalid config:
- $.providers expected object but got [object Object] (at providers)
$ 1;2;4cecho hello
zsh: command not found: 4cecho
```
The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above.
The `/exit` path was never affected, because it disposes the tree and reaches the TUI's own `shutdown()`, which calls `drainInput()` (absorbing the pending reply) and then `ui.stop()`. The defect was that a *failed boot* had no path to that same teardown.
## Decision
`installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit:
- The diagnostic is written **before** the release, so a hanging or failing disposer cannot swallow the reason.
- A latch, not an uninstall, keeps the first rejection the reported one. Removing the listener during teardown would let a second concurrent rejection become uncaught, and Node would kill the process mid-teardown — stranding exactly the terminal state this restores. Later rejections, including the release's own, fall through to the pending exit.
- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. That timer stays **referenced**: an `unref()`ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because an `unhandledRejection` listener suppresses the default fatal exit.
- Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged.
`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back.
The launcher captures the root context in `boot()`'s `prepare` hook rather than from its return value. The rejection arrives while `boot()` is still in flight, so `app.current` assigned after the `await` would still be `undefined` at exactly the moment the hook needs it. `prepare` runs after the Loader installs and before any config-tree entry mounts, which covers the whole window in which an entry can reject.
## Alternatives considered
**Reset the terminal from the fail-loud handler** (write `ESC [ ? 2004 l`, pop the keyboard protocol, clear raw mode). This duplicates pi-tui's teardown in a package that owns no terminal, and would drift as pi-tui's startup sequence changes. It also cannot absorb the in-flight Device Attributes reply, which is what corrupts the next prompt — only draining stdin while it is still raw does that.
**Register a `process.on('exit')` terminal reset in the TUI.** Exit handlers are synchronous, so they cannot await `drainInput()`; the stray reply would still land. It also puts teardown on a global hook rather than the disposal path that already exists.
**Have the TUI refuse to start until the tree settles.** This serializes a deliberately concurrent Loader and delays first paint for every healthy launch to fix a failure path.
**Reorder config entries so `llm-pi-ai` mounts before `ui-tui`.** Ordering is not a guarantee the Loader makes, and any future entry could fail after the TUI mounts.
## Consequences
A failed boot now costs one tree disposal (bounded at 2s) before exit, and the exit code stays 1. In exchange, a misconfigured `dsh` returns a usable shell instead of one needing `stty sane` or `reset`.
The guarantee belongs to whichever bin owns the terminal: a surface that grabs terminal state and does not pass `release` reintroduces this defect. `installFailLoud` cannot detect that on its own, since it has no view of what a mounted plugin did to the process.
## Testing
`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes.
Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the labelled boot rejection (`dsh: plugin tree failed to load:`) and `ESC[?2004l`. The same case pins the boot path end to end: it caught the [HMR initial-scan boot deadlock](2026-08-03-hmr-initial-scan-boot-deadlock.md) that silently exited 13 with the terminal stranded.
Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit.

View File

@@ -0,0 +1,59 @@
# Agent Note:fail-loud 在退出前释放终端
Status: implemented
[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文
## Problem
配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱:
```
dsh: fatal load failure: ValidationError: invalid config:
- $.providers expected object but got [object Object] (at providers)
$ 1;2;4cecho hello
zsh: command not found: 4cecho
```
Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。
在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行释放部分构建的上下文;release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
## Decision
`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待:
- 诊断信息在 release **之前**写出,因此卡住或失败的 disposer 无法吞掉失败原因。
- 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。
- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。
- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。
`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。
启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。
## Alternatives considered
**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。
**在 TUI 中注册 `process.on('exit')` 终端重置。** exit 处理函数是同步的,无法等待 `drainInput()`,残留响应依旧会落到 shell;而且这把拆卸挂到全局钩子上,而非已经存在的释放路径。
**让 TUI 等整棵树结算后再启动。** 这会把刻意并发的 Loader 串行化,并为修复一条失败路径而拖慢每一次正常启动的首次绘制。
**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。
## Consequences
启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。
这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。
## Testing
`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。
这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含带标签的启动 rejection(`dsh: plugin tree failed to load:`)与 `ESC[?2004l`。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态被残留的 [HMR 初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)。
测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md
2026-07-31-fork-anchor-floors-to-event-seq.md: 2cc8d66942442aca69c930be880cc41d56bced13
2026-07-31-fork-anchor-floors-to-event-seq.zh.md: 9f443f3d5165c01c6585d33bd6a37e90e9b3bf95

View File

@@ -0,0 +1,35 @@
# Agent Note: Fork anchor floors to an event seq
Status: implemented
English | [中文](2026-07-31-fork-anchor-floors-to-event-seq.zh.md)
## Problem
The fork button on a stopped assistant message did nothing at all — no child session, no error, no visible reaction.
The frozen node behind that message is not a log event. Both the live projection and the history replay mint it with a flow-ordering seq of `turnEnd.seq - 0.9`, placing it strictly after every event of the aborted turn and before the next one, and the chat view hands that node seq to the fork entry point unchanged. `session.fork` accepts a non-negative integer on the wire, so a fractional anchor is rejected as invalid-params before the request reaches the host, and the chat entry's fork call swallows failures. Nothing distinguished the rejection from an inert button.
The host's cut rule was never the obstacle. An aborted turn ends with a logged `turn/end` carrying reason `aborted`, so it is a completed prefix like any other and the anchor simply never arrived.
## Decision
`SessionsService.fork` floors `atSeq` before the RPC. The fractional-seq convention belongs to `dsh-client-runtime`, which mints it in both the live and replay projections, so the same package converts it back to a real event seq at the wire boundary instead of every UI caller remembering to. Integer anchors are unaffected.
Flooring lands inside the anchor's own turn rather than clipping backward: every turn opens with `turn/start`, so `turnEnd.seq - 1` cannot itself be an earlier turn's `turn/end`. The host's first-`turn/end`-at-or-after rule then closes on the turn the reader clicked, matching the whole-turn semantics the message-level fork button already promised for completed turns.
The apiproxy fork suite pins the host half of the contract: a floored anchor inside an aborted turn cuts through that turn and seeds the child with it.
## Alternatives considered
**Accept fractional `atSeq` on the wire.** Rejected because the host contract is an event seq, not a position on a continuum; the fractional form is one client's rendering convention, and admitting it would leave `atSeq` alone among the seq-carrying payloads in taking non-integers.
**Hide the fork button on interrupted messages.** Rejected because forking a turn the reader deliberately stopped is one of the strongest reasons to fork at all, and the capability worked host-side the whole time.
**Floor in the chat entry's `forkAt` adapter.** Rejected because `ui-conversation` consumes the fractional convention without owning it; any second fork entry point would have to rediscover the same conversion.
## Consequences
Forking from a stopped turn produces a child seeded through that turn's `turn/end`. The frozen partial text is reconstructed from chunk events and was never an `assistant/message`, so it stays out of the child's model transcript exactly as it stays out of the source's on resume — the child resumes from the same context the source would.
Fork failures stay silent in the chat entry. This bug survived because that call site discards its rejection; surfacing fork errors in the UI is a separate change.

View File

@@ -0,0 +1,35 @@
# Agent Note: fork 锚点向下取整到事件 seq
Status: implemented
[English](2026-07-31-fork-anchor-floors-to-event-seq.md) | 中文
## 问题
在已停止的助手消息上点 fork 毫无反应——没有子会话,没有报错,也没有任何可见变化。
这条消息背后的冻结节点并不是日志事件。实时投影和历史回放都用 `turnEnd.seq - 0.9` 这个排序坐标来生成它,让它严格落在被中断轮次的所有事件之后、下一轮之前,而 chat 视图原样把这个节点 seq 交给 fork 入口。`session.fork` 在 wire 上只接受非负整数,因此分数锚点在抵达 host 之前就被判为 invalid-params,而 chat 入口的 fork 调用又吞掉了失败。于是被拒绝和按钮失灵在表现上毫无区别。
host 的切分规则从来不是障碍。被中止的轮次会记录一条 reason 为 `aborted` 的 `turn/end`,它和其他轮次一样是可切分的完整前缀——只是锚点根本没送到。
## 决策
`SessionsService.fork` 在发起 RPC 前对 `atSeq` 向下取整。分数 seq 这个约定属于 `dsh-client-runtime`,实时投影和回放投影都由它生成,因此也由同一个包在跨出 wire 边界时把它换回真实事件 seq,而不是要求每个 UI 调用方各自记得转换。整数锚点不受影响。
向下取整落在锚点自身所在的轮次内,不会回退:每一轮都以 `turn/start` 开头,所以 `turnEnd.seq - 1` 不可能是上一轮的 `turn/end`。host 随后按「首个位于锚点或其之后的 `turn/end`」收口,命中的正是读者点击的那一轮,与消息级 fork 按钮在已完成轮次上一贯承诺的整轮语义一致。
apiproxy 的 fork 用例固定了 host 这一侧的契约:落在被中止轮次内的取整锚点会切穿该轮,并把它种进子会话。
## 备选方案
**让 wire 接受分数 `atSeq`。** 否决:host 契约要的是事件 seq,而不是连续坐标上的某个位置;分数形式只是某一个客户端的渲染约定,一旦放行,`atSeq` 会成为所有携带 seq 的载荷中唯一容忍非整数的字段。
**在已中断的消息上隐藏 fork 按钮。** 否决:从读者主动叫停的那一轮分叉,恰恰是最需要 fork 的场景之一,而 host 侧这个能力一直是好的。
**在 chat 入口的 `forkAt` 适配器里取整。** 否决:`ui-conversation` 只是分数约定的消费方,并不拥有它;将来任何第二个 fork 入口都得把同样的转换重新发现一遍。
## 影响
从已停止的轮次 fork 会得到一个种子切到该轮 `turn/end` 的子会话。被冻结的残缺文本是从 chunk 事件重建出来的,从未成为 `assistant/message`,因此它不会进入子会话的模型上下文——正如源会话恢复时它也不会进入一样,子会话拿到的上下文与源会话一致。
fork 失败在 chat 入口仍然是静默的。这个 bug 能存活至今,正是因为该调用点丢弃了自己的 rejection;把 fork 错误呈现到 UI 上是另一件事。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md
2026-07-31-hero-visible-while-blank-session-opens.md: b39963beffa403ef6fa44735aa88395a99139751
2026-07-31-hero-visible-while-blank-session-opens.zh.md: b451d7c00ec7eb8d736134e738d72e5e07fd1b04

View File

@@ -0,0 +1,35 @@
# Agent Note: Hero stays visible while a blank session opens
Status: implemented
English | [中文](2026-07-31-hero-visible-while-blank-session-opens.zh.md)
## Problem
The conversation root has a `settling` phase for a session that is still opening while its composer reads `blank`: the hero-versus-docked outcome is unknowable until history arrives, so the composer seat is hidden (`visibility:hidden`) rather than flashing the centered hero and snapping to the docked bar. Startup auto-selection turned that guard into the defect it was meant to prevent. From the no-workspace hero, `WorkspacesService.startInitialSelection` connects the most recent workspace and opens its blank session; `openState` flips to `loading` the moment `open()` lands, so the center column went blank for the whole history round-trip and then repainted, which reads as a full-page refresh on every launch.
## Decision
`ConversationRoot` reads the session list summary's `blank` flag alongside the conversation snapshot and exempts summary-proven blank sessions from settling: `settling` additionally requires `summaryBlank !== true`, and `hero` accepts a blank composer whenever the summary proves the session blank, in every open state rather than only `loading`. A session the list already reports as blank can only land on the hero, so hiding buys nothing and costs the visible flash; the same proof holds before the open starts (`cold`) and after one fails (`error`), where the previous conditions fell through to the active phase and rendered a docked bare composer under chrome `ConversationSession` hides for blank sessions. Whenever the summary does not prove the session blank — a row reporting `blank: false`, or no row at all because the list has not caught up — `summaryBlank` is not `true` and the conservative settling hide is unchanged.
The summary flag and the snapshot's own `blank` are distinct sources: the snapshot describes the session being opened, the summary is the list row that already exists before the open resolves. Only the latter is available early enough to decide the phase.
## Alternatives considered
**Drop the settling phase entirely.** Rejected because it still earns its keep for a session with no summary row: without a prior claim about emptiness, hero-versus-docked is genuinely unknowable and the flash it prevents is the worse one.
**Delay the `loading` flip until history returns.** Rejected because `openState` is authoritative about the open operation; deferring it to suppress a presentation artifact would misreport the data state to every other consumer.
**Cross-fade or otherwise animate the settling hide.** Rejected because the column has nothing to show during the round-trip either way — the fix is to not hide content whose outcome is already known, not to decorate the hiding.
## Deferred
The no-session→session tree relocation in `ConversationRoot` (the hero/composer subtree moves into the `conversation.session` outlet) still rebuilds the composer DOM on the same transition; removing it means moving `conversation.session` to `session-maybe` scope, a slot-contract change that needs its own proposal.
Object-layer reference churn found while diagnosing this — no-op projections minting fresh snapshots, the create path projecting twice, `select()` using `notifyNow` from async continuations — is real but independent of the visible flash.
## Consequences
Startup auto-selection renders the hero immediately and keeps the composer seat and header visible through the history round-trip, so launching into a recent workspace no longer looks like a page reload. Sessions whose summary does not prove them blank keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin all three summary shapes: a row reporting `blank: false` settles, an absent row settles, and a summary-proven blank session opening under `loading` renders hero chrome with a live textarea.
The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane): it registers a workspace, holds the `session.history` response open at the browser's network boundary, and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes it a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`.

View File

@@ -0,0 +1,35 @@
# Agent Note: 空白会话打开期间保持 hero 可见
Status: implemented
[English](2026-07-31-hero-visible-while-blank-session-opens.md) | 中文
## 问题
会话根节点为"正在打开且 composer 处于 `blank`"的会话保留了一个 `settling` 阶段:在历史记录返回之前,hero 与 docked 的归属不可知,因此宁可隐藏 composer 座位(`visibility:hidden`),也不要先闪出居中的 hero 再跳到底部输入条。启动时的自动选择把这道防护变成了它本要防止的缺陷。从无工作区的 hero 进入时,`WorkspacesService.startInitialSelection` 会连接最近的工作区并打开其空白会话;`open()` 一落地 `openState` 立即翻为 `loading`,中间栏因此在整个历史往返期间保持空白,随后重绘一次——每次启动看起来都像整页刷新。
## 决策
`ConversationRoot` 在读取会话快照的同时读取会话列表摘要的 `blank` 标志,并让"摘要已证明为空白"的会话豁免 settling:`settling` 额外要求 `summaryBlank !== true`,而 `hero` 在摘要证明会话为空白时接受处于 blank 的 composer——覆盖全部 open state,而非仅 `loading`。列表已报告为空白的会话只可能落到 hero,因此隐藏毫无收益,只换来一次可见闪烁;同一份证明在打开开始之前(`cold`)与打开失败之后(`error`)同样成立,而此前的条件会在这两种状态下落到 active 阶段,在 `ConversationSession` 为空白会话隐藏的外壳之下渲染出一条停靠的裸 composer。只要摘要没有证明会话为空白——无论是报告 `blank: false` 的行,还是列表尚未跟上因而根本没有该行——`summaryBlank` 都不为 `true`,保守的 settling 隐藏行为保持不变。
摘要标志与快照自身的 `blank` 是两个不同来源:快照描述正在打开的这个会话,摘要则是在打开操作完成之前就已存在的列表行。只有后者足够早,可用于决定阶段。
## 备选方案
**彻底移除 settling 阶段。** 否决,因为对没有摘要行的会话它仍有价值:在缺少任何关于"是否为空"的先验断言时,hero 与 docked 的归属确实不可知,而它所防止的那种闪烁更糟糕。
**推迟 `loading` 的翻转,直到历史返回。** 否决,因为 `openState` 是打开操作的权威状态;为了压制一个呈现层瑕疵而推迟它,会向其他所有消费者误报数据状态。
**为 settling 的隐藏加交叉淡入或其他动画。** 否决,因为无论如何该栏在往返期间都没有内容可展示——正确的修复是不隐藏结局已知的内容,而不是把隐藏装饰得好看些。
## 推迟事项
`ConversationRoot` 中"无会话→有会话"的树位置迁移(hero/composer 子树移入 `conversation.session` 出口)仍会在同一次转换中重建 composer 的 DOM;消除它意味着把 `conversation.session` 移到 `session-maybe` 作用域,这是一次插槽契约变更,需要单独立项。
诊断期间发现的对象层引用抖动——空操作投影铸造出新的快照、创建路径重复投影一次、`select()` 在异步续体中使用 `notifyNow`——确实存在,但与这次可见闪烁相互独立。
## 影响
启动自动选择会立即渲染 hero,并在整个历史往返期间保持 composer 座位与 header 可见,因此启动进入最近工作区不再像页面重载。摘要未证明为空白的会话保持原有的 settling 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了摘要的三种形态:报告 `blank: false` 的行进入 settling;根本没有该行同样进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。
组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道):它注册一个工作区,在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是它成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md
2026-07-31-same-basename-workspace-adoption.md: ed53804ea64df0d61db16e579c3d65af803dbb97
2026-07-31-same-basename-workspace-adoption.zh.md: 82cfb7d90afca28f8e666742a758fda0202909f3

View File

@@ -0,0 +1,37 @@
# Agent Note: Same-basename Workspace adoption
Status: implemented
English | [中文](2026-07-31-same-basename-workspace-adoption.zh.md)
## Problem
A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its basename-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id.
## Decision
`ctx.workspace.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record still derives its title from `basename(path)` without suffixing or rewriting it.
The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation.
Explicit naming remains stricter. `workspace.create({ name })` and `workspace.rename` continue to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md).
The durable schema does not change: Workspace records already store id, path, and title independently, bootstrap can derive equal basenames, and startup validates duplicate paths rather than titles.
## Verification
Workspace registry and Host API tests create two real directories under different parents with the same final segment and assert distinct ids, paths, and durable order. The picker component renders equal labels as separate id-keyed entries. The keyless Web browser scenario adopts both directories through the composed directory flow and observes two registered and rendered Workspaces.
## Alternatives considered
**Keep title uniqueness and reject the second directory.** A display label would remain an accidental identity key and ordinary multi-root layouts would stay impossible to register.
**Suffix colliding titles automatically.** A generated label such as `xx (2)` would no longer be the directory-derived title, would need stable allocation rules across deletion and reload, and would add state solely to conceal an identity mistake.
**Use the full path as every Workspace title.** This removes the collision but makes the primary navigation label unnecessarily long. The full path remains available in the hover detail while the concise basename stays useful.
**Permit collisions from explicit rename and create-by-name operations too.** The registry supports that state, but those operations intentionally ask the user to choose a display name. Retaining their conflict response preserves the existing naming guard without blocking filesystem-selected paths.
## Consequences
Two Workspace rows may carry the same visible title. They remain independently selectable and actionable because ids own identity; users can inspect the path or rename either row to disambiguate it. An explicit rename cannot select another row's current title, including a title that arose from same-basename adoption. No storage migration or compatibility path is required.

View File

@@ -0,0 +1,37 @@
# Agent Note: 接纳 basename 相同的 Workspace
Status: implemented
[English](2026-07-31-same-basename-workspace-adoption.md) | 中文
## 问题
Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按 basename 派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。
## 决策
`ctx.workspace.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录仍从 `basename(path)` 派生标题,不添加后缀,也不改写标题。
Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。
显式命名仍采用更严格的规则。`workspace.create({ name })` 和 `workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。
持久化 schema 未变:Workspace 记录本就分别存储 id、path 和 title,引导初始化可以派生出相同的 basename,启动校验检查的是重复路径而非重复标题。
## 验证
Workspace 注册表与 Host API 测试会在不同父目录下创建两个末级名称相同的真实目录,并断言其 id 和路径互不相同,且持久顺序正确。选择器组件将相同标签渲染为按 id 区分的独立条目。无密钥 Web 浏览器场景通过组合而成的目录流程接纳这两个目录,并观察到两个 Workspace 均已注册且完成渲染。
## 考虑过的替代方案
**保持标题唯一,并拒绝第二个目录。** 显示标签仍会意外充当身份键,普通的多根目录布局仍无法注册。
**自动为冲突标题添加后缀。** 像 `xx (2)` 这样的生成标签将不再是从目录派生的标题;系统还需要制定跨删除与重载保持稳定的分配规则,并且只为掩盖身份判定错误而增加状态。
**将完整路径用作每个 Workspace 的标题。** 这会消除冲突,却使主导航标签不必要地过长。完整路径仍可在悬停详情中查看,而简洁的 basename 仍有价值。
**也允许显式重命名和按名称创建操作产生重名。** 注册表支持这种状态,但这些操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。
## 后果
两个 Workspace 行可能显示相同的可见标题。id 负责身份,因此两行仍可独立选择和操作;用户可以查看路径或重命名任一行以作区分。显式重命名不能采用另一个行当前使用的标题,即使该标题源自 basename 相同的目录接纳。无需存储迁移或兼容路径。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md
2026-07-31-tui-diff-context-line-accounting.md: d1bc72ea030abd46f809ca3e746e6043f717baeb
2026-07-31-tui-diff-context-line-accounting.zh.md: a2a1dcce1325bca68c92cb4f206bfa35671e9d86
2026-07-31-tui-diff-context-line-accounting.md: d465568d5f6cad15ef4647be7ef936c2f5824bba
2026-07-31-tui-diff-context-line-accounting.zh.md: dd1a3eb1acf03477059d48461219700b224b2f96

View File

@@ -10,7 +10,7 @@ Result-time filesystem diffs carry the applied change with three surrounding con
## Decision
The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison.
The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. A tool result clears the pending-view cache before deriving the settled view, including when a presenter mutates and reuses the same view object.
When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row.
@@ -30,4 +30,4 @@ This remains a consumer-side interpretation of the existing `FileDiff` contract.
TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering.
The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, and cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states.
The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, result-time cache invalidation, and redraw cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states.

View File

@@ -10,7 +10,7 @@ Status: implemented
## 决策
TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。
TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。工具结果会在派生已结算视图前清除待处理视图缓存,即使 presenter 修改并复用同一个视图对象也一样。
当 `oldText` 为 `null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。
@@ -30,4 +30,4 @@ TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删
TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。
聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退和缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。
聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退、结果到达时的缓存失效和重绘缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md
2026-07-31-web-stop-preserves-queue.md: b8bba45009c126495a8b03ee36d6eb2cccac9a93
2026-07-31-web-stop-preserves-queue.zh.md: 0e1fffae8819bea81b91fae10c76dbadc78474fe

View File

@@ -0,0 +1,37 @@
# Agent Note: Web stop preserves pending Queue
Status: implemented
English | [中文](2026-07-31-web-stop-preserves-queue.zh.md)
## Problem
The Web stop button reached `session.cancel`, which mapped to broad `agent.cancel({ kind: 'user' })`. During an active turn, ordinary composer submissions are already accepted as independently addressable Queue occurrences. Broad cancellation discarded every occurrence when the user intended to stop only the current generation, conflating turn interruption with the Queue's explicit delete operation.
The browser cannot repair that loss by resending visible rows. It does not own their live `InboxItemId`, wake policy, or claim race, and a resend can duplicate work that the Host has already claimed.
## Decision
`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries.
The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued occurrence through its existing FIFO driver. That claim emits `agent/inbox/dequeue`, so the Host's authoritative `session/queue` snapshot retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this handoff until it settles.
This mapping changes only the Host `session.cancel` endpoint used by Web clients. The default `Agent.cancel()` contract remains broad, ACP and TUI retain their existing cancellation policies, and `AgentHandle.dispose()` still clears pending work during teardown. Queue row removal remains the explicit Web action for discarding one pending occurrence.
## Alternatives considered
**Keep broad cancellation for the stop button.** Rejected because stopping one generation should not destroy independently queued user intent; the Queue already owns explicit deletion.
**Resend the next row from the browser after cancellation.** Rejected because the Host owns occurrence identity and claim order. Client resubmission can duplicate work, reorder the FIFO, or race an authoritative dequeue.
**Start the next turn before cancelled work reaches quiescence.** Rejected because two turns would concurrently mutate one session log and share Agent-owned resources. Cooperative cancellation waits truthfully for the active work to settle.
**Add a wire option for broad versus preserving cancellation.** Rejected until the Web product has a separate “stop and clear Queue” interaction. The existing stop button has one policy, while per-row delete already supplies the current discard control.
## Verification
AgentLoop coverage holds an active model stream, queues two waking turns, cancels with `keepInbox`, and pins the aborted-then-completed turn reasons, FIFO user-message order, absence of discard events, and eventual idle state. The keyless Web scenario drives the built composition over HTTP/SSE: it stops one hung turn, observes the next queued occurrence start while the tail remains visible, stops that turn, and observes the final queued occurrence complete. Its accessibility snapshot pins the intermediate preserved-Queue state.
## Consequences
Web stop preserves accepted queued intent and advances it automatically after truthful cancellation settlement. Queue rows may remain visible while uncooperative active work winds down, and external steering preserved by the same inbox option can enter the next admitted turn even though Web does not render steering in QueueDock. A future bulk-clear interaction requires an explicit product action rather than overloading stop.

View File

@@ -0,0 +1,37 @@
# Agent Note: Web 停止操作保留待处理 Queue
Status: implemented
[English](2026-07-31-web-stop-preserves-queue.md) | 中文
## 问题
Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({ kind: 'user' })`。在活动轮次期间,普通 composer 提交已经被接纳为可独立寻址的 Queue 入队项。用户只想停止当前生成时,广义取消却会丢弃所有入队项,混淆了轮次中断与 Queue 的显式删除操作。
浏览器无法通过重发可见行修复这一损失。它不拥有这些行的实时 `InboxItemId`、唤醒策略或认领竞态;重发还可能重复 Host 已认领的工作。
## 决策
`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent;否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项。
AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。
该映射只更改 Web 客户端使用的 Host `session.cancel` 端点。`Agent.cancel()` 默认契约仍为广义取消,ACP 和 TUI 保留既有取消策略,`AgentHandle.dispose()` 在拆卸期间仍会清除待处理工作。移除 Queue 行仍是用于丢弃单个待处理入队项的显式 Web 操作。
## 考虑过的替代方案
**停止按钮继续使用广义取消。** 之所以否决:停止一次生成不应销毁已独立排队的用户意图;Queue 已拥有显式删除操作。
**取消后由浏览器重发下一行。** 之所以否决:Host 拥有入队项标识和认领顺序。客户端重新提交可能重复工作、重排 FIFO,或与权威出队产生竞态。
**被取消工作达到完全停稳之前启动下一轮次。** 之所以否决:两个轮次会并发修改同一会话日志,并共享 Agent 拥有的资源。协作式取消会如实等待活动工作结算。
**为广义取消与保留式取消添加协议选项。** 之所以否决:在 Web 产品提供独立的「停止并清空 Queue」交互之前,不需要此选项。现有停止按钮只有一项策略,而逐行删除已提供当前的丢弃控件。
## 验证
AgentLoop 覆盖会保持一个活动模型流,将两个可唤醒轮次排队,使用 `keepInbox` 取消,并固定验证先中止、后完成的轮次原因,FIFO 用户消息顺序,不存在 discard 事件,以及最终空闲状态。无密钥 Web 场景通过 HTTP/SSE 驱动已组装组合:它停止一个卡住的轮次,观察队尾保持可见时下一个 queued 入队项开始,再停止该轮次,并观察最后一个 queued 入队项完成。其可访问性快照固定了中间的 Queue 保留状态。
## 后果
Web 停止会保留已接纳的排队意图,并在取消如实结算后自动推进。不配合取消的活动工作收尾时,Queue 行可能仍保持可见;由同一 inbox 选项保留的外部 steering 可以进入下一个已接纳轮次,尽管 Web 不会在 QueueDock 中渲染 steering。未来的批量清空交互需要显式的产品操作,而不是过载停止。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md
2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b
2026-08-02-goal-round-wrapup-message.zh.md: 0a504b4dcc61feb932775b3d9ffd8424f3b0597d

View File

@@ -0,0 +1,31 @@
# Agent Note: Goal-round wrap-up message
Status: implemented
English | [中文](2026-08-02-goal-round-wrapup-message.zh.md)
## Problem
An autonomous goal round that reported `update_goal` `complete` or `blocked` concluded the physical turn at the tool result, so the model never spoke after the call. Sessions ended on a bare `update_goal` card, and internal testers read that as the agent stopping mid-sentence: the model's pre-call text routinely announces a report ("goal achieved, marking complete:") that never arrives, because the standard tool-use expectation is one more assistant message after a tool result and neither the goal-round prompt nor the tool description said the call was terminal. The hard stop came from the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md), whose turn-stop clause this note supersedes.
## Decision
A goal-round `complete` or `blocked` success no longer calls `concludeTurn()`. Instead the tool defers one wrap-up context onto its own result: a `{ kind: 'plugin', plugin: 'tool-goal' }`-sourced user message carrying a `<goal_complete>`/`<goal_blocked>` instruction to write a grounded closing message to the user and call no more tools. The turn then ends through the agent loop's ordinary no-tool-calls stop, so no new loop primitive exists and steering semantics are untouched. Direct-human mutations remain uninstructed exactly as before. The cost is one additional model request per goal lifecycle, not per round.
The instruction wording was selected by A/B sampling on `deepseek-v4-pro` with a reconstructed goal-round transcript: a structured instruction (outcome, verification, artifacts, next steps) consistently beat a minimal "summarize" one on completeness; adding a session-grounding clause shifted unsupported detail from asserted fact to hedged suggestion; and the no-instruction control produced high-variance closings, including confidently fabricated file-level detail.
Scripting the keyless proof required one snapshot-harness addition: `dsh-llm-replay` resolves `{{fromRequest:<regex>}}` placeholders in scripted entries against the live request, because a static sidecar cannot know the randomly minted goal id the model must echo into `update_goal`.
## Verification
`tool-goal` package tests pin the injected context (source, tag, objective, no-more-tools clause) and the absent `concludesTurn` for both terminal actions, plus the uninstructed direct-human pause and complete paths, at 100% file coverage. `llm-replay` unit tests pin the placeholder contract: last-match-wins capture, whole-match fallback, and loud failures for unmatched, invalid, and unterminated patterns. The new keyless ACP snapshot `goal-wrapup` drives the shipped application through create → round one → autonomous complete and asserts the plugin-sourced wrap-up injection, the same-turn closing assistant message, and the `completed` turn end in both the durable session log and the ACP stdout stream.
## Alternatives considered
- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text today, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message.
- **Keep `concludeTurn()` and add a "one more text-only step" loop primitive** — rejected: new `agent-loop` machinery for behavior the ordinary stop already provides once nothing concludes the turn.
- **Instruct inside the tool result content** — rejected: the goal tools' canonical output is compact JSON consumed programmatically; a prose instruction block inside it would mix the model-facing contract with the tool's replayable value.
## Consequences
Every autonomous goal ends with a user-facing closing message instead of a bare tool card, at the cost of one model request per goal lifecycle. `concludeTurn()` keeps its loop semantics but loses its only first-party caller outside subagent structured output. Snapshot scenarios can now script values that only exist at run time via `{{fromRequest:...}}`, which unblocks keyless coverage of any echo-an-id tool flow, goal or otherwise.

View File

@@ -0,0 +1,31 @@
# Agent Note:Goal Round 收尾消息
Status: implemented
[English](2026-08-02-goal-round-wrapup-message.md) | 中文
## 问题
自主 Goal Round 报告 `update_goal` `complete` 或 `blocked` 时,物理轮次在工具结果处直接终结,模型在调用之后再无发言机会。会话终止在一张裸的 `update_goal` 卡片上,内测同学的观感是 agent 话说到一半戛然而止:模型调用前的文本通常预告了一份汇报(“目标达成,标记完成:”)却永远没有下文,因为标准 tool-use 预期是工具结果之后还有一条 assistant 消息,而 Goal Round 提示词与工具描述都没有说明这次调用是终点。硬停止来自 [goal 工具决策](../feature/2026-07-19-model-facing-goal-tools.md),本 note 取代其中的轮次停止条款。
## 决策
Goal Round 的 `complete` 或 `blocked` 成功不再调用 `concludeTurn()`。工具改为在自己的结果上附带一条收尾上下文:以 `{ kind: 'plugin', plugin: 'tool-goal' }` 为 source 的 user 消息,携带 `<goal_complete>`/`<goal_blocked>` 指令,要求模型向用户写出有依据的收尾消息且不再调用工具。之后轮次经由 agent loop 常规的无工具调用停止路径结束,因此不存在新的 loop 原语,steering 语义不受影响。人类直接变更保持原样、不注入指令。代价是每个 goal 生命周期一次额外模型请求,而非每轮一次。
指令措辞通过在 `deepseek-v4-pro` 上用重构的 Goal Round 转录做 A/B 采样选定:结构化指令(结果、验证、产物、后续)在完整度上稳定优于极简“总结一下”;补充“以会话内证据为准”的 grounding 条款让无依据细节从断言事实退为带保留的建议;而无指令对照组的收尾方差很大,包括言之凿凿的文件级细节编造。
为让 keyless 证明可脚本化,快照设施补了一项能力:`dsh-llm-replay` 会针对实时请求解析脚本条目中的 `{{fromRequest:<regex>}}` 占位符,因为静态伴随文件不可能预知模型必须回填进 `update_goal` 的随机生成 goal id。
## 验证
`tool-goal` 包测试钉住两个终态 action 注入的上下文(source、标签、objective、禁止再调工具条款)与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符契约:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete,并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。
## 曾考虑的替代方案
- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。
- **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。
- **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧契约和工具的可回放值搅在一起。
## Consequences
每个自主 goal 都以一条面向用户的收尾消息结束,而非一张裸工具卡片,代价是每个 goal 生命周期一次模型请求。`concludeTurn()` 保留其 loop 语义,但在 subagent 结构化输出之外失去了唯一的一方调用者。快照场景现在可以通过 `{{fromRequest:...}}` 脚本化只在运行时才存在的值,为任何“回显 id”类工具流程(不限于 goal)解锁 keyless 覆盖。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md
2026-08-02-todo-first-composer-context-order.md: 26962426c763c6911fc61acab09cdb96725922b1
2026-08-02-todo-first-composer-context-order.zh.md: a81401ffeb8df5855b584a5727e24067d9fb1e70

View File

@@ -0,0 +1,33 @@
# Agent Note: Todo-first composer context order
Status: implemented
English | [中文](2026-08-02-todo-first-composer-context-order.zh.md)
## Problem
The composer context stack rendered Goal before Todo even though the Harness design orders the current task plan before its ongoing goal and pending Queue. Todo also used the Queue wrapper's 776px width as its visible card width, while Goal and the Queue panel rendered on the shared 752px card column. The result inverted the intended information hierarchy and left Todo wider than both adjacent panels.
## Decision
The `conversation.input.dock` list uses one ascending product order: Todo at `0`, Goal at `10`, and Queue at `20`, followed by the composer bar outside the list. Registration order remains the semantic source of truth; the renderer does not hardcode known component ids or repair their order with CSS.
Todo, Goal, and the visible Queue panel share the 752px card column inside the 800px composer cap. Queue retains a 776px wrapper with 12px transparent inset on each side because that wrapper owns the composer overlap. Todo is a standalone card rather than a wrapper, so its responsive width and maximum width subtract both inset layers directly. Goal uses the same responsive column and caps its inner bar at 752px, preserving matching edges below the desktop cap.
The [composer stack contract](2026-07-30-composer-context-stack-order.md) continues to own inter-card spacing and Queue's exclusive overlap with the composer. This decision supersedes only that note's Goal-first order.
## Verification
Todo and Goal registration tests pin orders `0` and `10`; Queue remains pinned at `20`. The keyless Queue browser scenario renders all three panels concurrently, records their Todo–Goal–Queue accessibility order, and compares their visible bounding boxes at the 1680px desktop baseline and a 640px sub-cap viewport before exercising Queue mutations.
## Alternatives considered
**Reorder the known panels inside `ConversationRoot`.** Rejected because `conversation.input.dock` is an extensible ordered list; a hardcoded component inventory would make plugin activation order and rendered order disagree.
**Use CSS `order` to move Todo visually.** Rejected because accessibility and keyboard order must match the visual hierarchy, and the slot ledger already owns semantic order.
**Keep Todo at the Queue wrapper width.** Rejected because the Queue wrapper's transparent inset is layout infrastructure for its composer overlap, not part of the visible panel column.
## Consequences
The standing task plan appears before the ongoing goal, pending Queue work remains closest to the composer, and all three visible cards share one horizontal edge. Future input-dock plugins choose an explicit position relative to Todo `0`, Goal `10`, and Queue `20`; only Queue owns the terminal wrapper overlap.

View File

@@ -0,0 +1,33 @@
# Agent Note: Todo 优先的 composer 上下文顺序
Status: implemented
[English](2026-08-02-todo-first-composer-context-order.md) | 中文
## 问题
composer 上下文堆栈将 Goal 渲染在 Todo 之前,但 Harness 设计稿把当前任务计划排在进行中的目标和待处理 Queue 之前。Todo 还把 Queue 包装层的 776px 宽度用作自身的可见卡片宽度,而 Goal 和 Queue 面板则渲染在共享的 752px 卡片列上。结果既颠倒了预期的信息层级,也让 Todo 比相邻两个面板更宽。
## 决策
`conversation.input.dock` 列表采用统一的产品顺序,升序依次为 Todo `0`、Goal `10`、Queue `20`,随后是位于列表外的 composer bar。注册顺序仍是语义真源;渲染器不会硬编码已知组件 id,也不会使用 CSS 修正它们的顺序。
Todo、Goal 与可见的 Queue 面板共用 800px composer 宽度上限内的 752px 卡片列。Queue 保留 776px 包装层,并在两侧各留 12px 透明内缩,因为该包装层负责与 composer 重叠。Todo 是独立卡片,而非包装层,因此其响应式宽度和最大宽度都会直接扣除两层内缩。Goal 使用相同的响应式卡片列,并将内层横条的宽度上限设为 752px,从而在低于桌面宽度上限时也保持边缘对齐。
[composer 堆栈契约](2026-07-30-composer-context-stack-order.md)继续规定卡片间距,以及仅限 Queue 与 composer 重叠。本决策只取代该记录中 Goal 优先的顺序。
## 验证
Todo 与 Goal 的注册测试分别固定顺序 `0` 和 `10`;Queue 仍固定为 `20`。无密钥 Queue 浏览器场景同时渲染三个面板,记录 Todo–Goal–Queue 的无障碍顺序,并在 1680px 桌面基线和低于宽度上限的 640px 视口下比较其可见边界框,随后再执行 Queue 变更。
## 考虑过的替代方案
**在 `ConversationRoot` 内重新排列已知面板。** 不予采纳,因为 `conversation.input.dock` 是可扩展的有序列表;硬编码的组件清单会使插件激活顺序与渲染顺序不一致。
**使用 CSS `order` 移动 Todo 的视觉位置。** 不予采纳,因为无障碍顺序和键盘顺序必须与视觉层级一致,而 slot 账本已经负责语义顺序。
**让 Todo 保持 Queue 包装层的宽度。** 不予采纳,因为 Queue 包装层的透明内缩是其与 composer 重叠所需的布局基础设施,不属于可见面板列。
## 后果
当前有效的任务计划显示在进行中的目标之前,待处理 Queue 工作仍最靠近 composer,三张可见卡片共用相同的横向边缘。未来的 input-dock 插件必须相对于 Todo `0`、Goal `10` 和 Queue `20` 选择明确位置;仅 Queue 负责末端包装层与 composer 的重叠。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md
2026-08-03-hmr-initial-scan-boot-deadlock.md: 4b3e259c216d258c321ab06c41225b33ed240d19
2026-08-03-hmr-initial-scan-boot-deadlock.zh.md: ce1bc8396ac6e7fb6ecb1647fe2b29cdc788c7e1

View File

@@ -0,0 +1,41 @@
# Agent Note: HMR's initial scan deadlocked a failing boot into a silent exit 13
Status: implemented
English | [中文](2026-08-03-hmr-initial-scan-boot-deadlock.zh.md)
## Problem
A `dsh` launch whose config-tree failed validation exited 13 (unsettled top-level await) with no diagnostic at all, and left the TUI's terminal state stranded on the shell — the exact symptom the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) fixed, reintroduced through a different mechanism after the [transactional config reload](2026-07-20-config-hot-reload-resilience.md).
Two defects compounded:
1. **Concurrent Include applies corrupt the transactional group update.** The HMR main watcher's chokidar initial scan re-announces every existing file as `add`. Its `add` for the config file triggered `Include.refresh()` while the Include's initial apply was still in flight (`this.content`, the changed-content dedup key, commits only after apply). Two concurrent `EntryGroup.update` calls on one group interleave create and rollback on the same entries, and the Include fiber never settles — `loader.create` hangs, `boot()` neither resolves nor rejects, and Node exits 13 once the loop drains.
2. **Serialized applies alone deadlock the failure rollback.** With Include mutations queued, a failing initial apply rolls back by disposing every mounted entry — including `hmr`, whose teardown drains its refresh tasks. The scan-triggered refresh task sits in the Include queue behind the very apply whose rollback is disposing HMR: rollback waits on HMR, HMR waits on the refresh, the refresh waits on the apply.
## Decision
Both halves are fixed in the vendored packages (logged in `vendor/README.md`):
- `include/src/index.ts` funnels every child-tree mutation — initial apply, refresh, and `internal/update` patch re-application — through one per-Include promise queue. The group's transactional `update` is not reentrant, so serialization is a correctness requirement, not a throughput choice. `refresh()` also reads inside the queue so its changed-content check compares against the predecessor's committed state.
- `hmr/src/index.ts` passes `ignoreInitial: true` to the main watcher. The initial scan only re-announces files boot has just consumed; suppressing it removes both the boot-time refresh and the spurious `add` events for already-loaded modules. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply exactly once.
With both in place a failing boot follows the intended path: the single apply fails, the rollback disposes the tree (running the TUI's own shutdown, restoring the terminal), `loader.create` rejects, and `boot()` rethrows the labelled diagnostic with exit 1.
## Alternatives considered
**Only `ignoreInitial: true`.** Removes the trigger but leaves the corruption: any genuinely concurrent refresh (a config edit racing a slow apply) still interleaves two group updates and strands the fiber.
**Only serialization.** Converts the corruption into the rollback deadlock described above; the process still exits 13 silently.
**Cancel queued refreshes on HMR teardown.** Requires cancellation plumbing through `refreshConfig`'s task loop and the Include queue for a case `ignoreInitial` already removes from every boot; not worth the machinery until a real trigger remains.
## Consequences
A config file edit landing inside the watcher's startup scan window is now picked up by the next `change` event rather than the scan itself; steady-state reload behavior is unchanged.
One latent gap remains: a config edit made during a *failing* initial apply can still queue a refresh that the rollback's HMR teardown waits on — the same deadlock shape with a human-scale trigger window of one failing boot. If that ever bites, the fix is refresh-task cancellation at HMR teardown.
## Testing
The `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts` pins the end-to-end contract: exit 1, the labelled `dsh: plugin tree failed to load:` diagnostic naming `$.providers`, and the bracketed-paste reset proving the tree was disposed. Before this fix the same case observed exit 13 with no diagnostic. Reload behavior stays covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/ui/app-boot/tests/hmr-config.spec.ts`.

View File

@@ -0,0 +1,41 @@
# Agent Note:HMR 初始扫描使失败的启动死锁为静默的 exit 13
状态:已实现
[English](2026-08-03-hmr-initial-scan-boot-deadlock.md) | 中文
## 问题
当 `dsh` 启动时配置树校验失败,进程以 13 退出(未结算的顶层 await),不输出任何诊断,并把 TUI 的终端状态残留在 shell 上——这正是 [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) 修复过的症状,在[事务化配置重载](2026-07-20-config-hot-reload-resilience.md)之后经由另一条机制重新出现。
两个缺陷叠加:
1. **并发的 Include apply 破坏事务化的 group update。** HMR 主 watcher 的 chokidar 初始扫描会把每个已存在的文件重新宣告为 `add`。其中配置文件的 `add` 在 Include 的首次 apply 尚未结束时触发了 `Include.refresh()`(内容去重键 `this.content` 只在 apply 完成后才提交)。同一 group 上两个并发的 `EntryGroup.update` 会在相同条目上交错执行 create 与回滚,导致 Include fiber 永远无法结算:`loader.create` 挂起,`boot()` 既不 resolve 也不 reject,事件循环排空后 Node 以 13 退出。
2. **仅序列化 apply 会让失败回滚死锁。** 将 Include 的变更排入队列后,首次 apply 失败时的回滚会释放每个已挂载条目——包括 `hmr`,而它的拆卸会等待自身的 refresh 任务排空。扫描触发的 refresh 任务正排在 Include 队列中、位于正在回滚的那次 apply 之后:回滚等 HMR,HMR 等 refresh,refresh 等 apply。
## 决定
两处修复都落在 vendored 包中(记录于 `vendor/README.md`):
- `include/src/index.ts` 将每次子树变更——首次 apply、refresh、`internal/update` 补丁重应用——汇入每个 Include 一条的 promise 队列。group 的事务化 `update` 不可重入,因此序列化是正确性要求,而不是吞吐取舍。`refresh()` 也在队列内读取文件,使其内容变更判断与前一任务提交后的状态比较。
- `hmr/src/index.ts` 给主 watcher 传入 `ignoreInitial: true`。初始扫描只会重新宣告启动刚刚消费过的文件;抑制它同时消除了启动期 refresh 和对已加载模块的多余 `add` 事件。`registerConfig()` 保留自己 `ignoreInitial: false` 的 watcher,因为注册时已存在的个人配置必须恰好应用一次。
两者齐备后,失败的启动走上预期路径:唯一一次 apply 失败,回滚释放整棵树(执行 TUI 自身的 shutdown、恢复终端),`loader.create` reject,`boot()` 重新抛出带标签的诊断并以 1 退出。
## 曾考虑的替代方案
**只加 `ignoreInitial: true`。** 消除了触发条件,但保留了破坏本身:任何真正并发的 refresh(配置编辑与缓慢的 apply 竞争)仍会交错两次 group update 并使 fiber 悬置。
**只做序列化。** 把破坏转化为上述回滚死锁;进程仍然静默地以 13 退出。
**在 HMR 拆卸时取消排队中的 refresh。** 需要在 `refreshConfig` 的任务循环和 Include 队列中铺设取消机制,而 `ignoreInitial` 已把该场景从每次启动中移除;在真实触发条件出现之前不值得引入这套机构。
## 后果
落在 watcher 启动扫描窗口内的配置文件编辑,现在由下一个 `change` 事件而非扫描本身拾取;稳态的重载行为不变。
仍留有一个潜在缺口:在一次*失败的*首次 apply 期间进行的配置编辑,仍可能排入一个被回滚的 HMR 拆卸所等待的 refresh——同样的死锁形态,但触发窗口缩小到一次失败启动的人力尺度。若它真的发生,修复方向是在 HMR 拆卸时取消 refresh 任务。
## 测试
`apps/cli/tests/tui-keyless-smoke.e2e.ts` 中 `dsh` 无效 provider 的 PTY 用例钉住了端到端契约:以 1 退出、带标签的 `dsh: plugin tree failed to load:` 诊断指明 `$.providers`、以及证明整棵树已被释放的 bracketed-paste 复位序列。此修复之前,同一用例观察到的是无诊断的 exit 13。重载行为仍由 `packages/ui/app-boot/tests/config-reload.spec.ts` 与 `packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖。