mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into mergebot/pr1008
# Conflicts: # examples/web-cordis/cordis.yml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.module.css # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx
This commit is contained in:
@@ -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-23-client-plugin-loading-model.md: 4028f50bf5cf8a05063df3bf5e2b4b7f45a3c02e
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 7dc96b47c950af07adb71ba7fe131d53e5c0e51d
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
|
||||
2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d
|
||||
2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c
|
||||
|
||||
@@ -116,7 +116,7 @@ One governance implementation runs on both sides of the wire; the browser-specif
|
||||
|
||||
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land.
|
||||
|
||||
Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
|
||||
Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/config/web.cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模
|
||||
|
||||
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。
|
||||
|
||||
名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。
|
||||
名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -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-24-web-config-tree-boot-and-transport-layering.md: 99e5d0d95f320464a6f12857e9d6e90e3587c777
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 89c1a578f0ad0841eea7512538e3de8095b82c4d
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d
|
||||
|
||||
@@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
**Composition is one flat config tree.** `apps/cli/cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
|
||||
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
|
||||
|
||||
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**组合是一棵平铺 config tree。** `apps/cli/cordis.yml` 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。
|
||||
|
||||
**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
|
||||
2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38
|
||||
2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464
|
||||
2026-07-28-directory-picker-capability-seam.md: 495062f910785e1bb2f421dbb25c01c399d45567
|
||||
2026-07-28-directory-picker-capability-seam.zh.md: 62fc87212ab627ea8819dab55e3a769b4a5afc42
|
||||
|
||||
@@ -18,7 +18,9 @@ Placement and policy rulings folded into this decision:
|
||||
|
||||
- **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home.
|
||||
- **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib.
|
||||
- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself.
|
||||
- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself.
|
||||
- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets.
|
||||
- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands.
|
||||
- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption.
|
||||
- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories.
|
||||
- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.
|
||||
@@ -30,10 +32,13 @@ Placement and policy rulings folded into this decision:
|
||||
- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant.
|
||||
- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
|
||||
- **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires.
|
||||
- **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once.
|
||||
- **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together.
|
||||
- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative.
|
||||
- `cordis.yml` chooses the interaction; `apps/cli` mounts the [`-auto` chooser](../feature/2026-07-29-directory-picker-adaptive-default.md), which resolves the host's situation at boot and mounts `-native` or `-browse` itself, one row still swapping backend and UI together; composing a backend row directly pins the interaction.
|
||||
- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests.
|
||||
- A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits.
|
||||
- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service.
|
||||
|
||||
@@ -18,7 +18,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
|
||||
|
||||
- **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。
|
||||
- **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。
|
||||
- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
|
||||
- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
|
||||
- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。
|
||||
- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。
|
||||
- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。
|
||||
- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。
|
||||
- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
|
||||
@@ -30,10 +32,13 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
|
||||
- **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。
|
||||
- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。
|
||||
- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。
|
||||
- **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。
|
||||
- **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。
|
||||
- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。
|
||||
|
||||
## 后果
|
||||
|
||||
- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;`-native` 仍是宿主屏幕方案。
|
||||
- `cordis.yml` 决定交互形态;`apps/cli` 挂 [`-auto` 选择器](../feature/2026-07-29-directory-picker-adaptive-default.md),它在启动时判定宿主处境并自行挂载 `-native` 或 `-browse`,一行仍同时切换后端与 UI;直接组合某个后端行即固定交互。
|
||||
- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。
|
||||
- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。
|
||||
- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md
|
||||
2026-07-28-dsh-native-typescript-source-launch.md: 1ba1dd2663038ad7c49af71f8b428245f7fa3e2b
|
||||
2026-07-28-dsh-native-typescript-source-launch.zh.md: 02f84f34820469ad9e810ae17d79d3fe12b0cd4c
|
||||
2026-07-28-dsh-native-typescript-source-launch.md: 773f831ec2b116d4908fcd5dc818df78c5deee5e
|
||||
2026-07-28-dsh-native-typescript-source-launch.zh.md: 0602aa1ba079fcd5bffb2fe3989da9a4f62763fb
|
||||
|
||||
@@ -20,9 +20,9 @@ The `dsh` TUI, Web, and headless source launches use `node --experimental-transf
|
||||
|
||||
`scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency.
|
||||
|
||||
Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution.
|
||||
Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for the shipped `apps/cli/config/base.cordis.yml` plus its surface overlay lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution.
|
||||
|
||||
`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule.
|
||||
`verify-cordis-config` performs a one-way completeness check on the resolver manifest: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule.
|
||||
|
||||
After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior.
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin
|
||||
|
||||
`scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。
|
||||
|
||||
只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。
|
||||
只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,已交付的 `apps/cli/config/base.cordis.yml` 及其界面覆盖层所需依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。
|
||||
|
||||
`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。
|
||||
`verify-cordis-config` 对该解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。
|
||||
|
||||
Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md
|
||||
2026-07-28-launcher-owned-resume-identity.md: e11431716305ff18e951ca45921c71dcd105974c
|
||||
2026-07-28-launcher-owned-resume-identity.zh.md: e4078023cb1519288f0367f8973788f361aaa0bd
|
||||
@@ -0,0 +1,64 @@
|
||||
# Agent Note: Launcher-owned session identity and exit line
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two facts a launcher owns were shipped as deployment config keys on the TUI app bundle: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows.
|
||||
|
||||
Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured".
|
||||
|
||||
Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume <valid-id>` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause.
|
||||
|
||||
A config key cannot express these facts safely, because the deployment is not the authority on them.
|
||||
|
||||
## Decision
|
||||
|
||||
Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` nor in any plugin's `Config`.
|
||||
|
||||
Both sit beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config. Each slot is declared by the package that consumes it:
|
||||
|
||||
- `CONFIGURED_AGENT_IDENTITIES_KEY` (`dsh-agent-loop`) carries launcher identities keyed by configured-agent `id`, each a `LauncherAgentIdentity` (`{ id: SessionId, resume: boolean }`). `agent-loop` applies the matching identity over its configured agent, replacing both identity keys, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot leaves the configured identity untouched. The `tui` row resolves the same id through its own `sessionId` key, so the front door renders exactly the agent that was bound.
|
||||
- `TUI_GOODBYE_MESSAGE_KEY` (`dsh-tui`) carries the complete line printed once the terminal is released on exit. Absent prints nothing.
|
||||
|
||||
Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md).
|
||||
|
||||
`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume <id>` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout.
|
||||
|
||||
**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly.
|
||||
|
||||
The message is a plain string, not a callback. That forces the launcher to know the id before boot, which is why minting moved out of the app bundle — and it keeps exit free of awaited work after the terminal is released.
|
||||
|
||||
The TUI owns rendering, not wording: it applies `displayText` before its own `palette.muted`, so a hostile `--config` path cannot inject terminal escapes into the exit line. Sanitizing means the launcher cannot embed its own ANSI.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the keys and add built-in defaults in the app bundle.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume.
|
||||
|
||||
**Merge the app bundle into `apps/cli` and delete the slot entirely.** Rejected here, then [adopted later](../simplification/2026-07-29-shared-base-config-overlays.md) in a form this note did not consider: the composition moved into flat config files (`apps/cli/config/base.cordis.yml` plus a per-surface overlay) rather than into CLI code, so it never entered the `v8 ignore` process-wiring block, and the overlay extension points survive as ordinary row patches. The slot itself was not deleted — it moved to `dsh-agent-loop`, because a launcher fact still cannot travel through a replaceable config key.
|
||||
|
||||
**Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots.
|
||||
|
||||
**Have the host supply only the command text and let the TUI keep the `To resume this session:` prefix.** Rejected: the TUI would retain resume vocabulary for a string it no longer understands, and meta mode proves the launcher is the only component that knows what the command should say.
|
||||
|
||||
**Let the TUI keep suppressing the line until the session is durably persisted.** Rejected: that check is why the exit path queried persistence and swallowed listing failures. A plain string cannot consult persistence, and misuse now fails loud through `agent-loop/config-start-failed` rather than silently resuming nothing.
|
||||
|
||||
**A callback (`goodbyeMessage(agent)`) so the host could decide at exit time.** Rejected: it restores async work after `ui.stop()`, reintroducing a hang risk during teardown for a string that is already knowable at boot.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Removing two published `Config` keys is a breaking config change: a stale config naming either now fails schema validation at boot instead of degrading silently. Intended, and acceptable pre-release.
|
||||
- `TuiResumeHost` is unchanged, but `TuiRuntime` gains `goodbyeMessage`; `apps/cli` is the only provider.
|
||||
- The exit line prints even for a session with no log (launch, quit immediately). Using it then fails loud rather than starting a surprise session. This is the deliberate cost of dropping the persistence check.
|
||||
- `dsh-tui` no longer reads `sessionPersistence` at all: `currentResumeCommand`, `listWorkspaceSessions`, and its swallowed-error path are deleted, and the `/resume` selector's `sessionQuery` reads are now the only session discovery in the TUI.
|
||||
- The launcher mints session ids for its own app, so a non-CLI host that provides no slot keeps the bundle's own minting.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/core/agent-loop/tests/` drives the identity slot for the resume, launcher-minted, and no-slot cases.
|
||||
|
||||
The load-bearing coverage is `apps/cli/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `agent-loop` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment.
|
||||
|
||||
Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Agent Note:由启动器持有的会话身份与退出行
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-launcher-owned-resume-identity.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
有两项本应由启动器持有的事实,却被作为 TUI 应用组合包上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。
|
||||
|
||||
把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。
|
||||
|
||||
两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume <valid-id>` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。
|
||||
|
||||
一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。
|
||||
|
||||
## Decision
|
||||
|
||||
会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在任何插件的 `Config` 中。
|
||||
|
||||
这两个槽位与既有的 `tuiResumeHost` 宿主能力并列,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置。每个槽位都由消费它的包声明:
|
||||
|
||||
- `CONFIGURED_AGENT_IDENTITIES_KEY`(`dsh-agent-loop`)按所配置 agent 的 `id` 承载启动器身份,每项为一个 `LauncherAgentIdentity`(`{ id: SessionId, resume: boolean }`)。`agent-loop` 将匹配的身份覆盖到其所配置的 agent 上,替换两个身份键;并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失则保留配置中的身份不变。`tui` 配置项通过自身的 `sessionId` 键解析同一个 id,因此前端入口渲染的正是被绑定的那个 agent。
|
||||
- `TUI_GOODBYE_MESSAGE_KEY`(`dsh-tui`)承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。
|
||||
|
||||
身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。
|
||||
|
||||
`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume <id>`——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。
|
||||
|
||||
**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。
|
||||
|
||||
该消息是一个纯字符串,而非回调。这迫使启动器在启动前就知道 id,也正是铸造从应用 bundle 中移出的原因——并且它让退出在终端释放之后免于任何被 await 的工作。
|
||||
|
||||
TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应用 `displayText`,因此一个恶意的 `--config` 路径无法把终端转义序列注入退出行。做净化意味着启动器无法嵌入自己的 ANSI。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留这些键,并在应用组合包中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。
|
||||
|
||||
**把应用组合包合并进 `apps/cli` 并彻底删除该槽位。** 此处拒绝,但后来以本 note 未曾设想的形式[被采纳](../simplification/2026-07-29-shared-base-config-overlays.md):组合被搬进平铺的配置文件(`apps/cli/config/base.cordis.yml` 加各 surface 一份 overlay),而非搬进 CLI 代码,因此从未进入 `v8 ignore` 进程接线块,overlay 的扩展点也作为普通配置项 patch 保留了下来。槽位本身并未被删除——它迁移到了 `dsh-agent-loop`,因为启动器的事实依然不能经由一个可被整体替换的配置键传递。
|
||||
|
||||
**把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。
|
||||
|
||||
**让宿主只提供命令文本,而由 TUI 保留 `To resume this session:` 前缀。** 拒绝:TUI 将为一个它已不再理解的字符串保留 resume 词汇,而 meta 模式证明启动器才是唯一知道该命令应当说什么的组件。
|
||||
|
||||
**让 TUI 继续在会话被持久化之前抑制该行。** 拒绝:这项检查正是退出路径要查询持久化并吞掉列举失败的原因。一个纯字符串无法查询持久化,而误用现在会经由 `agent-loop/config-start-failed` 明确报错,而不是静默地恢复了个空。
|
||||
|
||||
**用一个回调(`goodbyeMessage(agent)`)让宿主能在退出时决定。** 拒绝:它会在 `ui.stop()` 之后恢复异步工作,为一个在启动时就已可知的字符串,重新引入拆解期间的挂起风险。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 移除两个已发布的 `Config` 键是一次破坏性配置变更:一份命名了任一键的陈旧配置,现在会在启动时的 schema 校验中明确报错,而不再静默降级。这是有意为之,且在预发布阶段可以接受。
|
||||
- `TuiResumeHost` 保持不变,但 `TuiRuntime` 新增 `goodbyeMessage`;`apps/cli` 是唯一的提供方。
|
||||
- 即便某会话没有日志(启动后立即退出),退出行也会打印。此时使用它会明确报错,而不是开启一个意外的会话。这是丢弃持久化检查的有意代价。
|
||||
- `dsh-tui` 完全不再读取 `sessionPersistence`:`currentResumeCommand`、`listWorkspaceSessions` 及其吞错路径都被删除,`/resume` 选择器的 `sessionQuery` 读取如今是 TUI 中唯一的会话发现途径。
|
||||
- 启动器为其自身的应用铸造会话 id,因此一个不提供任何槽位的非 CLI 宿主,仍保留 bundle 自带的铸造逻辑。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/core/agent-loop/tests/` 为 resume、启动器铸造与无槽位三种情形驱动身份槽位。
|
||||
|
||||
承重的覆盖是 `apps/cli/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `agent-loop` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。
|
||||
|
||||
在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md
|
||||
2026-07-28-user-settings-seam.md: bf93f95168b1b6d0dec5a9fc2c9aac5531f0564a
|
||||
2026-07-28-user-settings-seam.zh.md: 8cd4dfcbb2facdd590b2c24d453ad79e9badda4d
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: user-settings seam (`ctx.settings`) and the file provider
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-user-settings-seam.zh.md)
|
||||
|
||||
> Scope: the `packages/settings/` capability family — the abstract seam, the file-backed provider, and the composition boundary between user settings and `cordis.yml`. The [web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) recorded "the profile write path" as a deferral; this seam is that write path's owner. Consumer migrations (theme, locale, default model route) and the web `settings.*` RPC surface are follow-ups, not part of this note's shipped scope.
|
||||
|
||||
## Problem
|
||||
|
||||
User-editable configuration had no owner: `dsh web` read a cwd-anchored profile json through a static whitelist with no write path, the TUI read `$DSH_HOME/config.yaml` raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on separating user preferences from extension composition. Loader-reactive config updates cannot carry this: `fiber.update` swaps entry config in place, so a plugin that read config at construction observes nothing and no callback tells it otherwise.
|
||||
|
||||
## Decision
|
||||
|
||||
**Two planes with a litmus test.** `cordis.yml` (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition `base` (its entry-config subset), then the user document section.
|
||||
|
||||
**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `<DSH_HOME>/settings.yaml`), chokidar watch, read-modify-write persists under a cross-process writer lock with atomic `0600` tmp+rename commits, leaf-level diff patching of the written namespace (comments survive untouched nodes), and content-equality self-write suppression ([write-path integrity note](2026-07-30-settings-write-path-integrity.md)).
|
||||
|
||||
**Registrations are caller-fiber effects.** `register()` runs through the service proxy, so `this.ctx` is the registrant's context and the registration rides `ctx.effect`: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner.
|
||||
|
||||
**Fail loud at rest, last-good in motion.** Boot-time and registration-time validation throw (invalid stored section fails the registering plugin; an existing-but-unparsable document fails provider load). Once live, a bad external edit warns and keeps the last good state per namespace — a hot reload must never take the process down. This asymmetry mirrors `Include.refresh()` and Kimi's safe runtime reload.
|
||||
|
||||
**Consumers stay optional-by-construction.** A consumer registers inside `ctx.inject(['settings'], …)`; without a mounted provider it keeps resolving entry config alone, so every existing composition, demo, and snapshot works unchanged and migration is per-plugin.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Include write-back as the user layer** (per-plugin config pages writing loader entry files, cordis-webui style): write-back would target per-composition files, binding user preferences to one `cordis.yml`; a per-user layer must survive template upgrades and serve TUI and web from one document.
|
||||
- **Loader-reactive `fiber.update` as the propagation channel**: constructor-time reads observe nothing; the seam's explicit `watch()` makes hot-update a consumer contract instead of framework magic.
|
||||
- **A domain-aware settings service** (getters per product area): the coupling objection from design review stands; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema.
|
||||
- **Multi-layer precedence now** (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend.
|
||||
- **A cross-process lockfile now** (Pi's proper-lockfile): initially deferred as "atomic replace plus watcher convergence until real contention shows up" — review showed convergence loses unobserved sibling namespaces, so the deferral is superseded by the [write-path integrity note](2026-07-30-settings-write-path-integrity.md)'s hand-rolled writer lock.
|
||||
|
||||
## Consequences
|
||||
|
||||
Deferred, in dependency order: the web `settings.raw`/`settings.describe`/`settings.update` RPC surface (which must redact `role('secret')` fields before exposure); first consumer migrations (`ui-theme`, locale, api-gateway default route) retiring `PROFILE_MAPPINGS` and the profile json; `${env:VAR}` value indirection for secrets; provider-side layering. The keyless snapshot obligation lands with the first model- or product-user-visible consumer, not with this infrastructure step.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note:用户设置 seam(`ctx.settings`)与文件 provider
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-user-settings-seam.md) | 中文
|
||||
|
||||
> 范围:`packages/settings/` 能力族——抽象 seam、文件 provider,以及用户设置与 `cordis.yml` 的组合边界。[web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) 曾把"profile 写路径"记为延后项;本 seam 就是该写路径的归属。消费者迁移(主题、语言、默认模型路由)与 web `settings.*` RPC 面是后续工作,不在本 note 已交付范围内。
|
||||
|
||||
## 问题
|
||||
|
||||
用户可编辑配置没有归属:`dsh web` 经静态白名单读 cwd 锚定的 profile json 且无写路径,TUI 读 `$DSH_HOME/config.yaml` 裸 loader patch,两者都在启动时冻结。个人设置页(web GUI)需要一个跨 surface 的用户层,带 schema 校验、写路径与热传导——同类产品(Codex、Claude Code、Kimi、OpenCode、Pi)也全部收敛于"用户偏好与扩展组合分离"。Loader 的 reactive 配置更新承载不了这件事:`fiber.update` 原地替换 entry config,构造期读过配置的插件毫无感知,也没有任何回调通知它。
|
||||
|
||||
## 决策
|
||||
|
||||
**两个面,一条判定。**`cordis.yml`(+ Include patches)仍是组合面:有哪些插件、接线、部署配置,归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集;判定是"个人配置页应该能改它吗?"值可同时存在于两个面而不歧义,因为分层就是契约:schema 默认值,然后注册方的组合 `base`(其 entry 配置子集),最后用户文档分节。
|
||||
|
||||
**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `<DSH_HOME>/settings.yaml` 的 YAML/JSON、chokidar 监听、跨进程写锁下以 `0600` tmp+rename 原子提交的读-改-写 persist、对被写 namespace 的叶子级 diff 修补(未触碰节点的注释得以保留)、按内容相等抑制自写([write-path integrity note](2026-07-30-settings-write-path-integrity.md))。
|
||||
|
||||
**注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用,`this.ctx` 即注册方 context,注册挂在 `ctx.effect` 上:dispose 注册方即移除 namespace 及其观察者(HMR disposal 测试证明),而用户的分节继续留在存储中等待下一任 owner。
|
||||
|
||||
**静止时响亮报错,运行中保留最后可用值。**启动期与注册期校验直接抛错(非法存量分节使注册插件加载失败;存在但不可解析的文档使 provider 加载失败)。运行中坏的外部编辑只告警并按 namespace 保留最后可用状态——热重载绝不拖垮进程。该不对称镜像 `Include.refresh()` 与 Kimi 的安全运行时重载。
|
||||
|
||||
**消费者天然可选。**消费者在 `ctx.inject(['settings'], …)` 内注册;不挂 provider 时仍只按 entry 配置解析,因此所有既有组合、demo、snapshot 原样工作,迁移按插件渐进。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **以 Include 写回为用户层**(cordis-webui 式的按插件配置页写 loader entry 文件):写回目标是按组合的文件,会把用户偏好绑死在某个 `cordis.yml` 上;用户层必须在模板升级中存活,并以同一文档服务 TUI 与 web。
|
||||
- **以 Loader reactive `fiber.update` 为传导通道**:构造期读取毫无感知;seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。
|
||||
- **领域化的 settings 服务**(按产品域的 getter):设计评审中的耦合反对成立;服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。
|
||||
- **现在就做多层优先级**(Codex/Claude Code 式 system/managed/project 层级):延后到真实第二层出现;resolve 步骤是分层未来唯一的扩展点。
|
||||
- **现在就上跨进程锁**(Pi 的 proper-lockfile):最初以"原子替换加 watcher 收敛,真实冲突出现再说"为由延后——评审发现收敛会丢失未观察到的同级 namespace,因此该延后已被 [write-path integrity note](2026-07-30-settings-write-path-integrity.md) 的手写写锁取代。
|
||||
|
||||
## 后果
|
||||
|
||||
按依赖顺序延后:web `settings.raw`/`settings.describe`/`settings.update` RPC 面(暴露前必须对 `role('secret')` 字段脱敏);首批消费者迁移(`ui-theme`、语言、api-gateway 默认路由)并退役 `PROFILE_MAPPINGS` 与 profile json;面向密钥的 `${env:VAR}` 值间接引用;provider 侧分层。keyless snapshot 义务随第一个模型或产品用户可见的消费者落地,而非本基础设施步骤。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md
|
||||
2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc
|
||||
2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: settings write-path integrity and observer lifecycle
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-settings-write-path-integrity.zh.md)
|
||||
|
||||
> Scope: the third review round over `packages/settings/` — write-path data integrity in `dsh-settings-local` (operation chain, read-modify-write, cross-process writer lock, diff-shaped YAML edits) and observer lifecycle in `dsh-settings` (watch disposal, async listener containment, the JSON-shape write boundary). This note reverses one deferral recorded in the [user-settings seam note](2026-07-28-user-settings-seam.md): the cross-process lockfile now ships.
|
||||
|
||||
## Problem
|
||||
|
||||
Review found the provider's write path could destroy state it never observed, and the seam's observer lifecycle leaked past disposal. Concretely: watcher reloads and document writes ran on two independent promise chains while every write rendered the whole next document from the cached text, so an external edit still inside the debounce window was overwritten — and the follow-up reload no-oped because the post-rename content matched the cache, erasing the edit without a trace. The initial `load()` raced the watcher's own setup, leaving a startup window whose changes never fire an event. Two processes sharing a harness home rendered from independent caches, last writer winning whole namespaces. On the seam side, a `watch()` disposer only removed the observer from its set — an invocation already chained onto the watcher tail still ran after disposal, and nothing drained started invocations at service dispose; the `settings/updated` manual fan-out caught only synchronous throws, so an async listener's rejection escaped as an unhandled rejection; and `structuredClone` admitted Dates, Maps, BigInts, and cycles that YAML/JSON storage silently distorts on the reload round-trip (a Date lands as a timestamp string, a BigInt as a plain number). YAML writes replaced the whole namespace node, deleting every comment inside the section a comment-preserving provider had promised to keep.
|
||||
|
||||
## Decision
|
||||
|
||||
**One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active.
|
||||
|
||||
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste.
|
||||
|
||||
**Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is.
|
||||
|
||||
**The write boundary admits JSON data only.** The call-time snapshot is a single `cloneJsonShaped` walk that detaches the patch and rejects any non-JSON value — Date, Map, BigInt, non-finite number, function, symbol, class instance, `undefined` array entry, circular reference — with its `$`-rooted path before anything persists. Object entries that are explicitly `undefined` still skip (the sparse-patch contract), now enforced at the boundary instead of inside `mergeLayers`.
|
||||
|
||||
**YAML edits are leaf-level diffs.** `renderYaml` diffs the stored section against the next one and applies only `setIn` for changed values and `deleteIn` for removed keys, recursing through maps. Comments, anchors, and formatting survive on every untouched node and on the key node of every changed pair; arrays and other non-map values replace wholesale when unequal (`deepEqualJson` is the shared predicate), taking comments inside them along.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer.
|
||||
- **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free.
|
||||
- **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded.
|
||||
- **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime.
|
||||
- **Keeping `structuredClone` and validating in the provider** — the seam is the durable boundary's owner (every provider stores JSON-shaped documents), and rejecting at call time gives the caller the offending path; a provider-side check would reject after merge, blaming the merged section instead of the caller's value.
|
||||
|
||||
## Consequences
|
||||
|
||||
`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: settings 写路径完整性与观察者生命周期
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-settings-write-path-integrity.md) | 中文
|
||||
|
||||
> 范围:对 `packages/settings/` 的第三轮评审——`dsh-settings-local` 的写路径数据完整性(操作链、读-改-写、跨进程写锁、diff 形态的 YAML 编辑)与 `dsh-settings` 的观察者生命周期(watch 的 dispose(资源释放)、异步监听器收容、JSON 形态写入边界)。本 note 推翻了[用户设置 seam note](2026-07-28-user-settings-seam.md)所记录的一项延后决定:跨进程锁文件现已交付。
|
||||
|
||||
## 问题
|
||||
|
||||
评审发现,提供方的写路径可能销毁它从未观察到的状态,而 seam 的观察者生命周期会泄漏到 dispose 之后。具体而言:watcher 重载与文档写入跑在两条相互独立的 promise 链上,而每次写入都从缓存文本渲染出完整的下一份文档,于是仍处于防抖窗口内的外部编辑会被覆盖——随后的重载又因 rename 后的内容与缓存一致而成为空操作,这次编辑就被无痕抹去。初始 `load()` 与 watcher 自身的建立过程存在竞态,留下一个启动窗口:落在这个窗口内的变更永远不会触发事件。共享同一 harness home 的两个进程各自从独立的缓存渲染,后写者以整个 namespace 为单位胜出。
|
||||
|
||||
在 seam 一侧,`watch()` 的释放器只把观察者从集合中移除——已经接到 watcher 链尾的调用在 dispose 之后照常运行,服务 dispose 时也没有任何环节排空已启动的调用;`settings/updated` 的手动扇出只捕获同步抛错,异步监听器的 rejection 会以 unhandled rejection 的形式逃逸;`structuredClone` 则放行 Date、Map、BigInt 与循环引用,而 YAML/JSON 存储会在重载往返中悄悄扭曲这些值(Date 会变成时间戳字符串,BigInt 会变成普通数字)。
|
||||
|
||||
YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删掉——而这个保注释的提供方承诺过要保住它们。
|
||||
|
||||
## 决策
|
||||
|
||||
**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。
|
||||
|
||||
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。
|
||||
|
||||
**观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。
|
||||
|
||||
**写入边界只放行 JSON 数据。**调用时刻的快照就是一次 `cloneJsonShaped` 遍历:它把 patch 从调用方分离出来,并在任何内容持久化之前拒绝一切非 JSON 值——Date、Map、BigInt、非有限数值、函数、symbol、类实例、值为 `undefined` 的数组元素、循环引用——拒绝时附带该值以 `$` 为根的路径。显式为 `undefined` 的对象条目仍会跳过(稀疏 patch 契约),这一契约如今在边界处强制执行,而不再放在 `mergeLayers` 内部。
|
||||
|
||||
**YAML 编辑是叶子级 diff。**`renderYaml` 对比已存储分节与下一份分节,只对变化的值应用 `setIn`、对移除的键应用 `deleteIn`,并沿 map 递归。注释、锚点与格式在每个未触碰节点上、以及每个被改键值对的键节点上全部保留;数组等非 map 值在不相等时整体替换(`deepEqualJson` 是共享的判定谓词),其内部注释随之一并被带走。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。
|
||||
- **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。
|
||||
- **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。
|
||||
- **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。
|
||||
- **保留 `structuredClone`、在提供方里做校验**——seam 才是持久化边界的所有者(每个提供方存储的都是 JSON 形态文档),而且在调用时刻拒绝能把违规值的路径给到调用方;提供方侧的检查要到合并之后才拒绝,归咎的是合并后的分节,而不是调用方传入的值。
|
||||
|
||||
## 后果
|
||||
|
||||
`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。
|
||||
|
||||
[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。
|
||||
@@ -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-27-glob-sampling.md
|
||||
2026-07-27-glob-sampling.md: 67912cf290b127a96819d57f30387197af68323d
|
||||
2026-07-27-glob-sampling.zh.md: e339a5f87b483849df60feb726b061fe85074300
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Sample over-cap glob results across the tree
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-glob-sampling.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Asked what a workspace contained, an agent described one subfolder as if it were the whole project. The workspace held 22 top-level entries and 11,485 files. `glob {"pattern":"*"}` matched 10,030 paths, but all 100 inline paths sat under one recently unpacked subtree, so the model never saw the other 21 entries.
|
||||
|
||||
Three individually valid behaviors composed into the false impression. A glob without `/` matches basenames at any depth, so `*` means every file in the tree rather than the shell's current-directory expansion. Ripgrep's `--sort=modified` is ascending, so an archive's restored old timestamps put that subtree first. The inline page then took the head of that order without saying that it represented only one concentrated slice.
|
||||
|
||||
## Decision
|
||||
|
||||
A result that fits within `globMaxResults` remains complete and byte-for-byte modification-time ordered. The required `sampleOverCapGlobResults` config has no fallback: `false` retains the modification-time head for an over-cap result, while `true` samples round-robin across the complete result's top-level entries. In sampling mode, every entry receives one slot before any receives a second, exhausted groups drop out, relative order remains stable within each group, and grouping is relative to the actual search root, including an explicit `path`.
|
||||
|
||||
In sampling mode, the footer states that the page is a cross-entry sample rather than the modification-time head and reports how many top-level entries it reaches when that fact adds information. When more top-level entries exist than inline slots, it tells the model to narrow `path`. Head mode keeps the ordinary capped-result footer. When spill succeeds, both modes preserve the complete sorted list in the artifact.
|
||||
|
||||
The prompt and schema state the configured over-cap ordering, that a pattern without `/` matches at any depth, and that glob returns files, never directory entries. The shipped CLI composition explicitly selects head mode; deployments that want representative capped pages select sampling mode. Directory orientation remains ordinary shell work in deployments that expose the model-facing bash tool: use `ls` for one directory, and glob for a named file-path pattern across the tree. `ctx.fs.listDir` remains an internal provider primitive used by skill discovery; this decision adds no model-facing `list` tool.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the modification-time head as the only behavior.** Rejected after measuring the failure shape. Some deployments need the stable ordering, but a deployment that values workspace orientation can explicitly select representative data instead of asking the model to distrust the only paths it received.
|
||||
|
||||
**Give the sampling choice a default.** Rejected. No product-wide evidence establishes either ordering as the implicit contract, so every composition selects one and misconfiguration fails at load.
|
||||
|
||||
**Sample every result.** Rejected. A complete result loses nothing to truncation, so modification-time order remains useful for age-oriented questions. Sampling begins only when the head stops describing the whole.
|
||||
|
||||
**Switch to newest-first order.** Rejected. It merely changes which concentrated subtree can dominate and removes the existing oldest-first contract without making a capped page representative.
|
||||
|
||||
**Sample only past a skew threshold.** Rejected. No current evidence supports a deployment-wide threshold, and the model could not know which ordering contract applied. The existing cap is the explainable transition.
|
||||
|
||||
**Balance recursively below the top level.** Deferred. First-segment balance fixes the observed failure; deeper balancing needs an unsupported depth-versus-breadth policy.
|
||||
|
||||
**Add a model-facing `list` tool.** Rejected after implementation review. The default coding composition already exposes general bash and the model understands `ls`; a duplicate tool would add permanent schema/prompt tokens plus ordering, pagination, symlink, escaping, UI, and snapshot contracts without a distinct security or policy benefit. Thin deployments without a model-facing bash tool do not gain directory orientation from this change.
|
||||
|
||||
**Reject `*` or silently anchor separator-free patterns.** Rejected. The same basename-at-any-depth behavior makes `*.ts` useful across a tree. Documenting the rule preserves working ripgrep semantics.
|
||||
|
||||
## Consequences
|
||||
|
||||
A sampling-mode over-cap page no longer answers age-order questions from its inline paths; its footer says so, and the spill artifact retains the complete sorted view. Sampling balances only the first segment beneath the search root, so a deeper hot subtree can still dominate within one top-level entry. Head mode retains the concentration risk as an explicit deployment trade-off.
|
||||
|
||||
The tool surface does not grow. Every composition must set `sampleOverCapGlobResults`; changing it alters glob's prompt, schema description, and over-cap Native rendering. The canonical output keeps `root` so sampling mode can recover its grouping basis, while fitting results remain unchanged.
|
||||
|
||||
## Testing
|
||||
|
||||
Package tests pin the required config, both over-cap modes, their prompt and schema descriptions, concentrated and flat results, explicit roots, more groups than the JavaScript argument limit, exhausted groups, fewer slots than groups, and paths outside the workdir. The `fs-glob-sampling` ACP scenario explicitly enables sampling, boots a minimal real Loader/app/local-bash composition, and executes the real search plugin against a deterministic `rg` process fixture; its result spans four top-level entries instead of returning one subtree's head.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: 跨目录树采样超出上限的 glob 结果
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-glob-sampling.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
用户询问工作区包含什么内容时,一个 agent(智能体)把某个子文件夹描述成了整个项目。该工作区有 22 个顶层条目和 11,485 个文件。`glob {"pattern":"*"}` 匹配到 10,030 条路径,但内联显示的 100 条路径全部位于一棵近期解压的子树中,因此模型完全没有看到其余 21 个条目。
|
||||
|
||||
三个单独看都合理的行为叠加后造成了错误印象。不含 `/` 的 glob 会匹配任意深度的文件名,因此 `*` 表示目录树中的每个文件,而不是 shell 对当前目录执行的展开。Ripgrep 的 `--sort=modified` 按升序排列,因此归档包还原出的旧时间戳会让该子树排在最前。随后,内联页面直接截取这一顺序的前部,却没有说明它只代表集中于一处的切片。
|
||||
|
||||
## 决策
|
||||
|
||||
未超过 `globMaxResults` 的结果仍保持完整,且按修改时间排序的内容逐字节不变。必填的 `sampleOverCapGlobResults` 配置没有回退值:`false` 会为超过上限的结果保留按修改时间排序的前部,`true` 则会在完整结果的顶层条目之间按轮转方式采样。采样模式下,每个条目都先获得一个位置,之后才有条目获得第二个位置;已经用尽的分组会退出轮转;各组内部的相对顺序保持稳定;分组以实际搜索根为基准,显式指定 `path` 时也如此。
|
||||
|
||||
采样模式下,footer 会说明当前页面是跨条目的样本,而不是按修改时间排序的前部;当触达的顶层条目数能提供额外信息时,还会报告该数量。若顶层条目数量超过内联位置数,footer 会要求模型缩小 `path`。保留前部的模式沿用达到上限时的普通 footer。spill 成功时,两种模式都会在该产物中保留完整排序列表。
|
||||
|
||||
提示词与 schema 会说明配置所指定的超限结果排序方式、不含 `/` 的模式会匹配任意深度,以及 glob 只返回文件而绝不返回目录条目。随产品交付的 CLI(命令行界面)组合显式选择保留前部的模式;希望达到上限的页面具有代表性的部署则选择采样模式。在向模型暴露 bash 工具的部署中,目录定位仍由普通 shell 操作完成:查看一个目录使用 `ls`,跨目录树按指定文件路径模式查找则使用 glob。skill(技能)发现流程仍将 `ctx.fs.listDir` 作为内部提供方原语使用;本决策不会新增面向模型的 `list` 工具。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只保留按修改时间排序的前部。** 测量实际故障形态后否决。某些部署需要这种稳定排序;但重视工作区定位的部署可以显式选择具有代表性的数据,而不必要求模型怀疑自己拿到的唯一一批路径。
|
||||
|
||||
**为采样选项提供默认值。** 否决。没有全产品范围的证据支持把任一排序作为隐式契约,因此每个组合都必须选择一种,配置错误则在加载时失败。
|
||||
|
||||
**对所有结果采样。** 否决。完整结果没有因截断损失任何信息,因此按修改时间排序仍有助于回答关注新旧时间的问题。只有当截取前部已经无法描述整体时,才开始采样。
|
||||
|
||||
**改为最新优先排序。** 否决。这只会改变哪一棵结果集中的子树可能占据主导;既取消了现有的最旧优先契约,也没有让受限页面更具代表性。
|
||||
|
||||
**仅在偏斜超过阈值时采样。** 否决。目前没有证据支持适用于所有部署的统一阈值,模型也无法判断当前采用的是哪一种排序契约。现有上限是可以清楚解释的切换点。
|
||||
|
||||
**在顶层以下递归平衡。** 暂缓。按第一路径段做平衡已经修复观测到的故障;更深层的平衡需要一套尚无依据的深度与广度取舍策略。
|
||||
|
||||
**新增面向模型的 `list` 工具。** 实现评审后否决。默认编程组合已经提供通用 bash,模型也理解 `ls`;重复工具会永久增加 schema 与提示词所占的 token,并引入排序、分页、符号链接、转义、UI 与快照契约,却没有独立的安全或策略收益。不向模型提供 bash 工具的精简部署也不会因本次改动获得目录定位能力。
|
||||
|
||||
**拒绝 `*`,或在不含分隔符的模式前静默加上根目录锚点。** 否决。同样的「在任意深度匹配文件名」行为使 `*.ts` 可以有效地跨目录树搜索。记录这条规则能够保留正常工作的 Ripgrep 语义。
|
||||
|
||||
## 影响
|
||||
|
||||
采样模式下,超过上限的 glob 页面无法再根据内联路径回答按时间判断新旧的问题;footer 会明确说明这一点,spill 产物仍保留完整的排序视图。采样只平衡搜索根下的第一路径段,因此某个顶层条目内部较深处、结果密集的子树仍可能占据主导。保留前部的模式则把集中风险作为显式部署取舍保留下来。
|
||||
|
||||
工具接口不会扩大。每个组合都必须设置 `sampleOverCapGlobResults`;更改该值会改变 glob 的提示词、schema 描述以及超过上限时的 Native 渲染。规范输出保留 `root`,以便采样模式恢复其分组基准;未超过上限的结果保持不变。
|
||||
|
||||
## 测试
|
||||
|
||||
包测试锁定了必填配置、两种超过上限模式及其提示词和 schema 描述、结果集中与扁平两种情况、显式根目录、分组数超过 JavaScript 参数个数上限、分组耗尽、位置数少于分组数,以及工作目录以外的路径。`fs-glob-sampling` ACP(Agent Client Protocol)场景会显式启用采样,启动最小化的真实 Loader/app/local-bash 组合,并让真实搜索插件对接确定性的 `rg` 进程 fixture(测试前置数据);其结果覆盖 4 个顶层条目,而不是只返回某棵子树的前部。
|
||||
@@ -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-28-themed-scrollbars-and-reserved-gutter.md
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.md: 38228c868bb00210118e8110feb722fb81d0d56c
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 9fafe1faa9303b5e2e23a1b3064904f71494026d
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.md: b45f70b126d083916c756afb88a8b646a4e9bb85
|
||||
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 8afa36429ce7e6e061b014d63dcb20e5a642a84c
|
||||
|
||||
@@ -20,13 +20,13 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a
|
||||
|
||||
The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading.
|
||||
|
||||
Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. Eight surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, the question composer card, and the todo panel. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls.
|
||||
Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls.
|
||||
|
||||
The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind.
|
||||
Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were missed in the first implementation and found in review, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection.
|
||||
|
||||
The elevated set is resolved from the palette's own dark elevation ladder — the surface tokens whose dark value lands on `bg-layer-2` or `bg-layer-3`, which is the step the l1/l2 split encodes. Deriving it instead from the sheets that already rebind was the first attempt and is unsound: such a set can only confirm what someone already remembered, and a surface nobody has rebound yet — exactly the case the check exists for — defines itself as unelevated. `--dsw-specific-tip` proved it, resolving to the menu surface's rung while the todo panel scrolled on it unrebound and the derived check stayed green.
|
||||
|
||||
Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which.
|
||||
Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules. That approximation cannot detect a scrolling component embedded in an elevated card painted by another package's stylesheet, as `DirectoryBrowser` inside `Modal` demonstrated; cross-sheet composition remains a review and assembled-UI responsibility.
|
||||
|
||||
The track and the corner stay transparent, so the thumb reads against whatever surface scrolls under it; only the thumb and its hover state carry a token color.
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ Status: implemented
|
||||
|
||||
两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。
|
||||
|
||||
两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。目前有八处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板、设置面板、`ui-primitives` 共用菜单卡片、输入条卡片、提问组件卡片与待办面板。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。
|
||||
两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。
|
||||
|
||||
后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。
|
||||
`Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面在最初的实现里被漏掉、由评审发现,因此逐样式表的重新绑定契约由机械检查而非人工审阅把关。
|
||||
|
||||
抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 `bg-layer-2` 或 `bg-layer-3` 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。`--dsw-specific-tip` 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。
|
||||
|
||||
判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。
|
||||
判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则。这种近似检查无法检测嵌在由另一个包的样式表绘制的抬升卡片中的滚动组件,`Modal` 内的 `DirectoryBrowser` 就证明了这一点;跨样式表的组合仍需在评审和组装后 UI 层面把关。
|
||||
|
||||
轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。
|
||||
|
||||
|
||||
@@ -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-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
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: The human transcript projects append-origin events
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-human-transcript-append-origin.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message`, `assistant/message`, and `steering/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only provenance and the replacement that cites it.
|
||||
|
||||
Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection.
|
||||
|
||||
## Decision
|
||||
|
||||
Model and human projections are separate, and the event's own marker decides which one an event belongs to. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the durable source for a transcript; replacement copies stay model-only. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`.
|
||||
|
||||
The terminal replays the transcript from append-origin surface events and keeps a shadowed step's tool cards paired through `transcriptToolCallIds`, which reads the append-origin `assistant/message` rather than surface membership. A landed compaction contributes one dim `… earlier context was compacted …` row at its own log position: the marker reports where the model stopped seeing that history instead of erasing it. The framed checkpoint payload never renders, and both paths classify a surface event by the same marker, so a compaction that arrives live and the same log replayed after resume produce the same transcript. Only replay re-derives `tool/call` pairing: a call event carries no marker of its own and inherits membership from the `assistant/message` that advertised it, which the live listener has necessarily just rendered.
|
||||
|
||||
A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactService` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Other replacements are silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation.
|
||||
|
||||
`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compact/summary` provenance stays on the page of the replacement that cites it.
|
||||
|
||||
No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
|
||||
|
||||
## 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`.
|
||||
|
||||
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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Recognize a checkpoint by shape (a replacement `user/message`).** Rejected: it reads a coincidence of today's producers instead of a declared contract, and any future producer that replaces a range with a user message would silently inherit the compaction marker. The seam already publishes `COMPACT_CHECKPOINT_SOURCE` precisely so consumers can recognize a checkpoint independently of the backend.
|
||||
|
||||
**Keep rendering the checkpoint as an injected-context card.** Rejected: the framed checkpoint is an instruction envelope written for the model, not human conversation content. Showing it while hiding the history it replaced inverts what the reader needs.
|
||||
|
||||
**Persist a second display transcript.** Rejected: the append-only log already contains the authoritative source material, so a parallel record buys nothing and adds migration and consistency work.
|
||||
|
||||
**Derive the marker from the `compact/*` bracket instead of the checkpoint.** Rejected for the transcript: the bracket is a pair of time-point markers around an operation, while the transcript needs the position where the surface actually changed. The bracket is the right source for progress and duration, which this change does not render.
|
||||
|
||||
**Classify events by re-folding the log, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: a fold answers a whole-log question, while a projection asks a per-event one that the event's own marker already answers in constant time.
|
||||
|
||||
## Consequences
|
||||
|
||||
Compaction no longer erases terminal history; a session compacted several times shows one marker per landed compaction, in log order. Pagination pages can carry more raw events than before, because quota is spent only on messages a human or model actually produced.
|
||||
|
||||
`rebuildTranscript` now materializes a component per append-origin event in the whole log, and it runs on mount, on a terminal color-scheme change, and on every reasoning toggle. Compaction used to bound that work for exactly the long sessions compaction serves, so the cost now grows with session length instead of with the surface. That is the trade the fix exists to make — preserved history is the point — but a windowing or reuse strategy belongs to whoever first measures a slow rebuild, not to a later profiler wondering why the work grew.
|
||||
|
||||
`dsh-tui` gains a dependency on the `dsh-compact` seam for one pure predicate, mirroring `dsh-session-reference`'s existing use. The terminal still needs no compaction backend at runtime.
|
||||
|
||||
Two behaviors changed with their tests. The surface-replacement terminal test previously pinned erasure ("hides shadowed tool calls") and now pins preservation plus exactly one marker, including a pruned result copy, a regenerated assistant message, and a foreign plugin's replacement all rendering nothing. The compaction snapshot scenario wrote a `workspace-context` source while claiming to pin compaction; it now writes a real checkpoint source, and its three fixtures are re-recorded to show the preserved prompt, the full tool card, and the marker.
|
||||
|
||||
The live/replay equivalence above is fixture-pinned, not only asserted here: `surface-replayed-compaction` mounts with the replacement already stored and records byte-identical to the live path's `surface-after-compaction-wide`. Changing either path breaks that equality, which is the point — the resume projection is what regressed for users, and the two fixtures must move together.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: 人类可读记录投影追加来源的事件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-human-transcript-append-origin.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
终端与宿主历史网关都把模型可见的 surface 当作人类可读记录(transcript)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message`、`assistant/message` 和 `steering/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志溯源信息与引用它的替换之间。
|
||||
|
||||
日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。
|
||||
|
||||
## Decision
|
||||
|
||||
模型投影与人类投影是分开的,而事件属于哪一种由事件自身的标记决定。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源的事件是记录的持久来源,替换副本仅供模型使用。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。
|
||||
|
||||
终端从追加来源的 surface 事件回放记录,并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对:该函数读取追加来源的 `assistant/message`,而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`:这行标记报告模型从何处起不再看到那段历史,而不是把它抹掉。带框的检查点载荷从不渲染,且两条路径都按同一个标记对 surface 事件分类,因此实时到达的压缩与恢复后回放同一份日志会产生相同的记录。只有回放会重新推导 `tool/call` 的配对关系:调用事件自身不携带标记,其归属继承自公布它的 `assistant/message`,而实时监听器必然刚刚渲染过后者。
|
||||
|
||||
检查点通过压缩接缝自身的契约来识别——`isCompactCheckpointSource`,即 `CompactService` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。其他替换保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。
|
||||
|
||||
`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compact/summary` 溯源信息会与引用它的替换留在同一页。
|
||||
|
||||
持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化,也不需要迁移。
|
||||
|
||||
## Deferred
|
||||
|
||||
浏览器客户端仍通过 `FoldAdapter` 从模型 surface 构建会话,因此压缩在 Web 端仍会把历史折叠成一行上下文。那里的修复用的是同一个谓词,另需按追加顺序的记录投影与一个标记组件;该工作是针对 `packages/client/runtime` 与 `packages/client/ui-conversation` 的独立变更。
|
||||
|
||||
该工作必须处理这样一页:其检查点引用的 `surfaceOp.start` 落在窗口之外。分页不再为检查点消耗额度,因此永远不会按检查点的溯源分组切分;而 `FoldAdapter` 会用一个非 surface 的哨兵事件填补缺失事件——于是 `SurfaceManager` 拒绝该范围,`nodes()` 退化为 `degradedSeqs()` 并记录一条错误。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。`degradedSeqs()`——按追加顺序的每个 surface 可入事件——已经很接近 A2 所需的记录投影,因此那正是应当刻意构建的形态,而不是作为退化路径被动落到的结果。渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,同样不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**按形态识别检查点(一个替换型 `user/message`)。** 被否决:那读取的是当前生产者的巧合而非已声明的契约,而未来任何用用户消息替换一段范围的生产者都会静默地继承压缩标记。接缝已经发布 `COMPACT_CHECKPOINT_SOURCE`,正是为了让消费方与后端无关地识别检查点。
|
||||
|
||||
**继续把检查点渲染为注入上下文卡片。** 被否决:带框的检查点是为模型撰写的指令信封,不是人类对话内容。展示它却隐藏它替换掉的历史,正好颠倒了读者的需要。
|
||||
|
||||
**持久化第二份展示用记录。** 被否决:仅追加的日志已经包含权威源材料,平行记录换不来任何东西,反而增加迁移与一致性工作。
|
||||
|
||||
**用 `compact/*` 括号而不是检查点来推导标记。** 就记录而言被否决:括号是围绕一次操作的一对时间点标记,而记录需要的是 surface 真正发生变化的位置。括号适合作为进度与耗时的来源,而本次变更并不渲染这些。
|
||||
|
||||
**像 `session-query` 为搜索所做的那样重新折叠日志来分类事件(`current`/`shadowed`/`log-only`)。** 被否决:折叠回答的是整份日志的问题,而投影问的是逐事件的问题,事件自身的标记已能以常数时间给出答案。
|
||||
|
||||
## Consequences
|
||||
|
||||
压缩不再抹掉终端历史;被压缩多次的会话会按日志顺序显示每次落地压缩对应的一行标记。分页的每一页可以携带比以前更多的原始事件,因为额度只花在人类或模型真正产生的消息上。
|
||||
|
||||
`rebuildTranscript` 现在会为整份日志中的每个追加来源事件物化一个组件,并在挂载时、终端配色方案变化时以及每次切换 reasoning 时运行。压缩此前正好为压缩所服务的那些长会话限制了这项工作量,因此这份开销现在随会话长度增长,而不再随 surface 增长。这正是本次修复要做的取舍——保留历史才是目的——但窗口化或复用策略属于第一个真正测到重建变慢的人,而不属于日后某个疑惑工作量为何增长的性能分析者。
|
||||
|
||||
`dsh-tui` 为一个纯谓词新增了对 `dsh-compact` 接缝的依赖,与 `dsh-session-reference` 现有用法一致。终端在运行时仍然不需要任何压缩后端。
|
||||
|
||||
两项行为随其测试一起改变。表层替换的终端测试此前钉住的是抹除(“隐藏被遮蔽的工具调用”),现在钉住的是保留加恰好一行标记,其中被裁剪的结果副本、重新生成的 assistant 消息以及来自其他插件的替换都不渲染任何内容。压缩快照场景此前声称钉住压缩,却写入了 `workspace-context` 来源;现在它写入真实的检查点来源,并重新录制三份 fixture,以显示被保留的提示、完整的工具卡片和那行标记。
|
||||
|
||||
上文的实时/回放等价性由 fixture 钉住,而不只是在此断言:`surface-replayed-compaction` 在挂载时替换已经存在,其录制结果与实时路径的 `surface-after-compaction-wide` 逐字节一致。改动任一路径都会破坏这项相等——这正是要点:回放投影才是当初对用户造成回归的部分,两份 fixture 必须一起变动。
|
||||
@@ -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-approval-panel-command-cap.md
|
||||
2026-07-30-approval-panel-command-cap.md: 941f7eda187f263f2d8af6aa643d493c92a3669b
|
||||
2026-07-30-approval-panel-command-cap.zh.md: 939a700934f6467947028d988da9a694169e203e
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: The approval takeover shares the composer's text cap
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-approval-panel-command-cap.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The approval panel is a composer takeover: while a sandbox escalation waits, it replaces the InputBar in the composer seat with the model's justification, the paired command, and a refuse/allow row. Both texts are unbounded model output, and the card had no height cap. A long command — the realistic shape, since escalation happens on the command the sandbox just denied, and a denied command is often a long inline write — grew the card until the action row left the viewport. The user could read the request and not answer it: the buttons existed, off screen, in a sticky footer that had already used the whole column.
|
||||
|
||||
The InputBar the panel replaces has always been capped (14 lines, then the textarea scrolls), so the takeover was also the one composer state that could grow without limit — the seat's height jumped on election and jumped back on answer.
|
||||
|
||||
## Decision
|
||||
|
||||
The panel's justification and command move into one scroll region (`data-approval-scroll`) capped at the same height as the composer's draft area; the amber strip and the action row sit outside it, so both buttons are in the card at every content length.
|
||||
|
||||
The cap is one value with two consumers, declared as `--dsh-composer-text-max-height: 336px` on `ConversationRoot`'s `.composerSeat` — the composer chain's only shared ancestor, since the fallback InputBar and an elected takeover render as siblings. `InputBar`'s mirror and the panel's scroll region both read it, so the seat cannot cap its two states differently: what the designer asked for ("unify it with the input box's max height") is now a fact of the stylesheet rather than a number repeated in two files. The region is `box-sizing: border-box` so the cap is its outer height, the same box the composer's draft area occupies.
|
||||
|
||||
The region is a tab stop (`tabIndex={0}`, named `role="group"`). Unlike the question composer's scroll body, whose option rows are focusable and pull the container along, this one holds nothing but text: without its own tab stop a keyboard-only user could reach the buttons and never the command's tail, and approve what they could not finish reading.
|
||||
|
||||
The panel's card rebinds `--dsh-scrollbar-thumb{,-hover}` to the l2 pair, as every scrolling surface on an elevated background must ([scrollbar contract](../../../../packages/client/ui-theme/src/styles/scrollbar.css)).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Cap the whole card instead of the text region.** One declaration, no restructuring, and it reads as the literal "same max height as the input box". Rejected because the card holds the strip and the action row: at 336px total the justification and command would get ~250px, less room than the draft they replace, and the numbers would only agree by coincidence of the strip's height. Capping the text region makes both seats top out at the same text height, which is the property that keeps the footer from jumping.
|
||||
|
||||
**Cap against the viewport like the question composer (`min(60vh, 520px)`).** The sibling takeover already does this, so it is the local precedent. Rejected because the designer's request was parity with the InputBar, and the two takeovers are not the same shape: the question composer's scroll content is a list of options the user must compare, which wants as much viewport as it can get, while the approval panel's is one command the user skims before deciding. A viewport-relative cap would also make the seat's height jump on election again, in the other direction.
|
||||
|
||||
**Ellipsize or truncate the command.** No scroll region, no cap, and the buttons stay put. Rejected because the command is the thing being approved: hiding its tail asks the user to consent to text they cannot read. Truncation is also unrecoverable here — the panel is the whole approval UI, so there is no "show more" surface to fall back to.
|
||||
|
||||
**Leave the action row inside the scroll region and cap the region.** Fewer moving parts than pinning the row. Rejected because it reproduces the defect inside the card: the buttons scroll out of the region, and the user has to discover a scrollbar to reach them.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A long command scrolls inside the card and the refuse/allow buttons stay on screen. Measured on the built client at 900x1000 and 900x700: the region reports `scrollHeight` past `clientHeight`, and both buttons stay inside the card and inside the viewport.
|
||||
- Electing the takeover no longer changes how tall the composer seat can get, so the transcript above it does not reflow by hundreds of pixels when an approval arrives or resolves.
|
||||
- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`. Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root.
|
||||
- The scenario's recorded command is a 200-token blob, far longer than a round trip needs. That cost is deliberate: the cap is unfalsifiable without content that passes it, and the model compresses any regular payload (the first recording turned "alpha 400 times" into `printf 'alpha %.0s' {1..400}`, a one-line command that proves nothing).
|
||||
|
||||
## Verification
|
||||
|
||||
`apps/web/tests/approval-composer.e2e.ts` drives the real composition: a read-only session, a denied write, the model's escalation retry, and the answer clicked through the panel. The geometry assertion runs on the live panel at two viewport heights and is guarded against holding vacuously — the region must actually be scrolling, and the measured cap must equal the composer's own, which the test reads off the live textarea before sending rather than hardcoding the px value.
|
||||
|
||||
Confirmed both directions against the built client. With the cap reverted, the region reports `scrolls: false` and grows to the command's full height (1798px for the recorded blob at 900x1000, against 336px capped); at 900x700 the card is 680px tall against a 700px viewport and the action row's bottom lands at y=749 — below the fold, the designer's report exactly. With the cap restored the scenario passes in replay.
|
||||
|
||||
Reproducing the off-screen buttons needs a card taller than the scrollport, not merely a tall card. The composer seat is `position: sticky; bottom: 0`, so while the card still fits it stays pinned to the viewport bottom and the buttons remain visible — at 900x1000 the uncapped card ate the whole transcript yet kept its action row on screen. Only once the card outgrows the scrollport does sticky stop being able to hold the bottom edge, and the row goes under.
|
||||
|
||||
The geometry block and the golden are replay-only, so record mode reaches the fixture write instead of aborting on layout.
|
||||
|
||||
The scenario keeps exactly one golden — the waiting panel — and asserts the answered state on the world instead (the decided outcome, the file the escalated command wrote, `DONE`, the panel gone, the composer re-enabled). An answered-transcript golden was recorded first and failed on Linux CI: the denied first attempt renders the OS's own refusal, and that text is platform-specific (`bash: notes.txt: Operation not permitted` on macOS against `bash: line 1: notes.txt: Read-only file system` on Linux). Any scenario whose transcript contains a sandbox-denied command inherits that, so the denial belongs in assertions, never in a golden.
|
||||
|
||||
The panel ships as a client-module bundle: `pnpm run build:web` alone does not pick up a change to `ApprovalPanel.module.css` or a new `data-` hook in `ApprovalPanel.tsx` — the package build must run first, or the browser lane asserts against an older client than the tree.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: 审批接管面板与输入框共用同一文本高度上限
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-approval-panel-command-cap.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
审批面板是一次 composer 接管:当一次沙箱越权申请处于等待状态时,它在 composer 容器中取代 InputBar,展示模型给出的理由、与之配对的命令,以及一行拒绝/允许按钮。这两段文本都是长度不受限的模型输出,而卡片当时没有任何高度上限。命令一长——而这正是现实中的常见形态,因为越权申请针对的就是沙箱刚刚拒绝的那条命令,而被拒绝的命令往往是一次很长的内联写入——卡片就会一直变高,直到操作按钮行离开视口。用户能读到这次申请,却无法回应它:按钮存在,只是在屏幕之外,位于一个已经占满整列的吸底容器里。
|
||||
|
||||
被它取代的 InputBar 一直是有上限的(14 行,之后由 textarea 自行滚动),因此这次接管也是 composer 唯一一个可以无限增高的状态——被选中时容器高度骤增,回应之后又骤降。
|
||||
|
||||
## 决策
|
||||
|
||||
面板的理由与命令移入同一个滚动区域(`data-approval-scroll`),其高度上限与 composer 的草稿区完全相同;琥珀色状态条与操作按钮行位于该区域之外,因此无论内容多长,两个按钮都留在卡片内。
|
||||
|
||||
这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的 mirror 与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的"可以跟输入框最大高度统一",如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。
|
||||
|
||||
该区域自身是一个 Tab 停靠点(`tabIndex={0}`,带名称的 `role="group"`)。提问 composer 的滚动体不需要这样做——它的选项行本身可聚焦,会把容器一起带过去;而这里除文本之外别无内容:没有自己的停靠点,仅用键盘的用户能走到按钮却走不到命令尾部,于是可能批准了自己没读完的东西。
|
||||
|
||||
面板卡片把 `--dsh-scrollbar-thumb{,-hover}` 重新绑定到 l2 那一对,这是每一个位于高层表面上的滚动区域都必须做的([滚动条约定](../../../../packages/client/ui-theme/src/styles/scrollbar.css))。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**给整张卡片设上限,而不是给文本区域设。** 一条声明,不需要重构结构,而且它读起来就是字面意义上的"与输入框相同的最大高度"。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更矮,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。
|
||||
|
||||
**像提问 composer 那样按视口设上限(`min(60vh, 520px)`)。** 同为接管面板的兄弟组件已经这么做了,因此这是本地既有先例。之所以否决:设计同学的要求是与 InputBar 对齐,而两个接管面板形态并不相同——提问 composer 的滚动内容是一组需要用户互相比较的选项,能占多少视口就该占多少;审批面板的滚动内容则是一条命令,用户在决定之前扫读即可。按视口设上限还会让容器高度在被选中时再次跳动,只是方向相反。
|
||||
|
||||
**对命令做省略号或截断处理。** 不需要滚动区域,不需要上限,按钮也不会移位。之所以否决:命令正是被审批的对象,隐去它的尾部等于要求用户为自己读不到的文本背书。在这里截断还是不可恢复的——面板就是审批的全部界面,没有"展开更多"的落脚处。
|
||||
|
||||
**把操作按钮行留在滚动区域内,只给该区域设上限。** 比把按钮行固定住少动几处。之所以否决:这会把缺陷搬进卡片内部——按钮滚出该区域,用户得先发现有滚动条才能碰到它们。
|
||||
|
||||
## 后果
|
||||
|
||||
- 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 `scrollHeight` 超过 `clientHeight`,两个按钮都留在卡片内、也都留在视口内。
|
||||
- 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的会话流不会有数百像素的重排。
|
||||
- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。
|
||||
- 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把"alpha 重复 400 次"写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。
|
||||
|
||||
## 验证
|
||||
|
||||
`apps/web/tests/approval-composer.e2e.ts` 驱动的是真实组合:一个只读会话、一次被拒绝的写入、模型的越权重试,以及在面板上点击完成的回应。几何断言在两个视口高度上针对活动面板执行,并有守卫防止它空洞地成立——该区域必须确实处在滚动状态,且实测上限必须等于 composer 自身的上限,后者由测试在发送之前从活动 textarea 上读出,而不是把该像素值写死。
|
||||
|
||||
在构建产物客户端上双向确认过。撤销上限后,该区域报告 `scrolls: false`,并长到命令的完整高度(900x1000 下,录制的字符块为 1798px,而设上限后为 336px);在 900x700 下卡片高 680px、视口高 700px,操作按钮行底边落在 y=749——正在折叠之下,与设计同学的反馈完全一致。恢复上限后,该场景在回放模式下通过。
|
||||
|
||||
要复现按钮跑到屏幕外,需要的是比滚动视口更高的卡片,而不只是一张很高的卡片。composer 容器为 `position: sticky; bottom: 0`,因此在卡片尚能容纳时它会一直吸附在视口底部,按钮仍然可见——在 900x1000 下,未设上限的卡片吃掉了整个会话流,却仍把操作按钮行留在屏幕内。只有当卡片长过滚动视口,sticky 才再也无法守住底边,按钮行随之沉入折叠之下。
|
||||
|
||||
几何断言块与 golden 仅在回放模式下执行,这样录制模式才能走到写入 fixture 那一步,而不是在布局检查处中断。
|
||||
|
||||
该场景只保留一份 golden —— 等待中的面板;回应之后的状态改为对世界作断言(决策结果、越权命令写出的那个文件、`DONE`、面板消失、输入框重新可用)。最初还录了一份"已回应会话流"的 golden,它在 Linux CI 上失败了:第一次被拒绝的尝试渲染的是操作系统自己的拒绝文本,而这段文本因平台而异(macOS 为 `bash: notes.txt: Operation not permitted`,Linux 为 `bash: line 1: notes.txt: Read-only file system`)。任何会话流中含有被沙箱拒绝命令的场景都会继承这一点,因此这类拒绝只能进断言,绝不能进 golden。
|
||||
|
||||
该面板以客户端模组包的形式发布:单跑 `pnpm run build:web` 不会带上对 `ApprovalPanel.module.css` 的改动,也不会带上 `ApprovalPanel.tsx` 中新增的 `data-` 钩子——必须先执行包构建,否则浏览器测试通道会对着一个比工作树更旧的客户端做断言。
|
||||
@@ -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-composer-context-stack-order.md
|
||||
2026-07-30-composer-context-stack-order.md: 9c269bcaf7fa360b6a5d0e16fd8cda48aa285d66
|
||||
2026-07-30-composer-context-stack-order.zh.md: 47288141ea4591b29adde0f85e810fc797740488
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Composer context stack order
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-composer-context-stack-order.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Goal, Todo, and Queue contribute independently to the same `conversation.input.dock` list, but their registration order and spacing rules did not encode the composition matrix. The renderer therefore placed Todo before Queue and Goal, while both Queue and Goal carried negative margins intended for the composer boundary. When all three were present, Queue joined to Goal and Goal joined to the composer, reversing the design's hierarchy.
|
||||
|
||||
## 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.
|
||||
|
||||
`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.
|
||||
|
||||
The order and overlap are separate contracts. Registration order establishes semantic hierarchy; CSS variables on the stack establish shared geometry. Queue does not infer that it may overlap merely from being the last visible entry, because Goal or Todo can be the last visible context card when no queue exists and must remain separated from the composer.
|
||||
|
||||
## 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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep independent negative margins on Goal and Queue.** Rejected because the affected neighbor changes with slot order; a local margin cannot express which relationship is allowed unless the semantic order is also fixed.
|
||||
|
||||
**Render each known dock id separately in `ConversationRoot`.** Rejected because it turns an extensible list slot into a hardcoded component inventory and forces the owner to change for every new registrant.
|
||||
|
||||
**Tuck whichever dock entry is last.** Rejected because Goal and Todo are standalone cards. Their absence matrix must not change the surface semantics of whichever card remains.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Composer 上下文堆栈顺序
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-composer-context-stack-order.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表,但各自的注册顺序与间距规则没有编码组合矩阵。因此,渲染器将 Todo 放在 Queue 和 Goal 之前,而 Queue 与 Goal 都带有用于 composer 边界的负外边距。三者同时出现时,Queue 与 Goal 相接,Goal 与 composer 相接,颠倒了设计层级。
|
||||
|
||||
## 决策
|
||||
|
||||
composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo 为 `10`,Queue 为 `20`,随后是位于列表外的 composer bar。顺序值之间的空档使未来条目可以声明预期位置,不必依赖插件激活顺序。
|
||||
|
||||
`ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null,不占用间距。
|
||||
|
||||
顺序与重叠是两项独立契约。注册顺序定义语义层级,stack 上的 CSS 变量定义共享几何。系统不能仅因 Queue 是最后一个可见条目,就推断它可以与 composer 重叠,因为没有 Queue 时,Goal 或 Todo 可能成为最后一个可见上下文卡片,而它们必须与 composer 保持间隔。
|
||||
|
||||
## 验证
|
||||
|
||||
注册测试固定了三个顺序值。浏览器截图覆盖完整的 Goal/Todo/Queue 组合矩阵、没有 Queue 的 Goal+Todo,以及仅有 Queue 的情况;这些场景共同覆盖全部相邻关系:Goal–Todo、Todo–Queue 与 Queue–Composer。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**Goal 和 Queue 分别保留独立的负外边距。** 不予采纳,因为受影响的相邻项会随 slot 顺序变化;除非语义顺序也固定,否则局部外边距无法表达允许哪一种关系。
|
||||
|
||||
**在 `ConversationRoot` 中分别渲染每个已知 dock id。** 不予采纳,因为这会把可扩展的列表 slot 变成硬编码的组件清单,并迫使 owner 在每新增一个注册方时随之修改。
|
||||
|
||||
**让最后一个 dock 条目贴卡。** 不予采纳,因为 Goal 和 Todo 是独立卡片;Goal 或 Todo 缺席时的组合不得改变剩余卡片的界面语义。
|
||||
|
||||
## 后果
|
||||
|
||||
所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Goal `0`、Todo `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md
|
||||
2026-06-30-session-store-fork-api.md: 5342deba8ca879026d32ee1420cb3c0fdf67c500
|
||||
2026-06-30-session-store-fork-api.zh.md: 51a3e0ce50aff10a9812c91d24dc6e78a56c43ba
|
||||
2026-06-30-session-store-fork-api.md: 69ff85e1f137f4f263bf951af0a3f655411c606a
|
||||
2026-06-30-session-store-fork-api.zh.md: 3304a6f384c9004b3572c95881f832a4aa21b77c
|
||||
|
||||
@@ -28,6 +28,12 @@ class SessionStore extends Service {
|
||||
|
||||
An empty prefix is forkable; any non-empty boundary must be a safe existing sequence outside an open turn. Typed errors distinguish missing sources, stale objects, duplicate child ids, invalid boundaries, and prefixes ending during execution. Broader log validation and crash repair remain with their existing owners.
|
||||
|
||||
### Host and browser adaptation
|
||||
|
||||
The Host `session.fork` RPC accepts `atSeq` as an anchor within the desired turn rather than as the store's inclusive safe boundary. It selects the first `turn/end` at or after that anchor; an omitted or past-end anchor selects the last completed turn. An anchor already in the log but not followed by a matching `turn/end` returns `fork-unavailable` and never falls back to an earlier turn, so a message action cannot silently omit the clicked message.
|
||||
|
||||
The Host creates the child through the agent registry with the selected seed and lineage, and pre-publication setup installs the latest logged provider, model, and reasoning target before the child can run. It then attaches the child to the source Workspace. An attachment failure returns `workspace-attach-failed` with the already-published child id; the client reconciles that child into its summary list before surfacing the error. The Session-row action uses the last completed turn, while a message action supplies its event seq; both open the child after success, and lineage expansion makes it visible beneath the source.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive.
|
||||
@@ -40,4 +46,4 @@ An empty prefix is forkable; any non-empty boundary must be a safe existing sequ
|
||||
|
||||
The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header.
|
||||
|
||||
The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md), while this API gets focused `dsh-session` unit tests plus JSONL persistence coverage.
|
||||
The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md); focused store, Host, carrier, and client tests pin the boundary and reconciliation contracts, while the real Chromium scenario pins the assembled message action and lineage tree.
|
||||
|
||||
@@ -28,6 +28,12 @@ class SessionStore extends Service {
|
||||
|
||||
空前缀可以被 fork;任何非空边界都必须是位于开放轮次之外且安全、已存在的序号。类型化的错误区分源缺失、对象陈旧、子 id 重复、边界无效和前缀结束于执行过程中等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。
|
||||
|
||||
### Host 与浏览器适配
|
||||
|
||||
Host 的 `session.fork` RPC 接受 `atSeq`,并将其视为所需轮次内的锚点,而非 store 中包含该序号的安全边界。它选择该锚点处或其后的首个 `turn/end`;锚点省略或超过末尾时,选择最后一个已完成轮次。若锚点已在日志中,但从该锚点起找不到匹配的 `turn/end`,则返回 `fork-unavailable`,绝不回退到更早的轮次,因此消息操作不会静默遗漏所点击的消息。
|
||||
|
||||
Host 通过 agent(智能体)注册表,以选定的种子和谱系创建子会话;发布前 setup 会先安装日志中最新的提供方、模型和推理(reasoning)目标,子会话才能运行。随后,Host 将子会话附加到源 Workspace。若附加失败,则返回 `workspace-attach-failed` 及已发布的子会话 id;客户端先将该子会话对账到摘要列表,再向调用方报告错误。Session 行操作使用最后一个已完成轮次,消息操作则提供其事件 seq;两者都会在成功后打开子会话,展开谱系后可在源会话下看到它。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在会话存储原语之上执行一层策略而去发现并安装第二个服务。
|
||||
@@ -40,4 +46,4 @@ class SessionStore extends Service {
|
||||
|
||||
公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。
|
||||
|
||||
v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。
|
||||
v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖;store、Host、载体与客户端的专项测试固定边界和对账契约,真实 Chromium 场景则固定组装后的消息操作与谱系树。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md
|
||||
2026-07-08-self-referential-cordis-toolset.md: 40934fe0e2975c4e068df6ef8f31ed7921df3230
|
||||
2026-07-08-self-referential-cordis-toolset.zh.md: 13662b9359aa85895ce85391ebd5a5902cc451cc
|
||||
2026-07-08-self-referential-cordis-toolset.md: 335d5e808016ebc37c8457c5dcf4d7da9d8b8c93
|
||||
2026-07-08-self-referential-cordis-toolset.zh.md: 8d2a105a2189aa23915f718c6440069d1a2aa9ed
|
||||
|
||||
@@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme
|
||||
|
||||
## Decision
|
||||
|
||||
The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence.
|
||||
The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by `examples/web-cordis`. It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence.
|
||||
|
||||
The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.bash` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。
|
||||
该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 `examples/web-cordis` 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。
|
||||
|
||||
vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时 Plugin 可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH runtime 中,可能影响同一进程的其他 session。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.md: c011a0284ea0efe59693785c038f814a866068ac
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5aea4ac0c5a3b29c098c297e514fab49caf643ff
|
||||
|
||||
@@ -14,17 +14,17 @@ The interactive channel must remain a Cordis plugin over the same agent, session
|
||||
|
||||
DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior.
|
||||
|
||||
The app layer has one terminal front door. `@deepseek-ai/dsh-tui-demo` mounts the TUI before the configured agent, and `examples/tui-agent` owns the interactive coding composition and Code Mode overlay directly. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate automation protocol.
|
||||
There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/config/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition. Non-interactive tasks use the official headless surface; ACP remains a separate automation protocol and owns the supported Code Mode demo.
|
||||
|
||||
The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1.
|
||||
|
||||
### Session projection and interaction
|
||||
|
||||
The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle.
|
||||
The TUI rebuilds the transcript from the append-origin session events, so resumed history keeps every message the reader already saw; a compacted range stays readable behind one marker instead of matching the model-visible conversation ([append-origin transcript](../bug-fix/2026-07-29-human-transcript-append-origin.md)). It renders Markdown text and reasoning, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle.
|
||||
|
||||
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services.
|
||||
|
||||
The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
|
||||
The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. The selector carries a filter box above the list: typing narrows the rows to a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the selection on the previously highlighted row when it survives the filter; Escape clears a non-empty filter before a second Escape cancels the selector. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
|
||||
|
||||
### Terminal ownership
|
||||
|
||||
@@ -47,6 +47,6 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t
|
||||
|
||||
- Interactive terminal work has a stateful Markdown, card, plan, and question interface with no second terminal protocol to keep aligned.
|
||||
- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol.
|
||||
- Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor.
|
||||
- Session projection makes resume consistent with the durable conversation, but one configured session owns the transcript and editor.
|
||||
- Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI.
|
||||
- Model and reasoning-effort selection use adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state.
|
||||
|
||||
@@ -14,17 +14,17 @@ Status: implemented
|
||||
|
||||
DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。
|
||||
|
||||
应用组合层只有一个终端入口。`@deepseek-ai/dsh-tui-demo` 在已配置 agent 之前挂载 TUI,`examples/tui-agent` 直接拥有交互式 coding 组装及其 Code Mode overlay。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的自动化协议。
|
||||
只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/config/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装。非交互任务使用官方 headless 界面;ACP 仍是独立的自动化协议,并拥有受支持的 Code Mode demo。
|
||||
|
||||
所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。
|
||||
|
||||
### 会话投影与交互
|
||||
|
||||
TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。
|
||||
TUI 从追加来源的会话事件重建 transcript(文本记录),因此恢复后的历史会保留读者已经看到的每条消息;被压缩的范围不再与模型可见会话保持一致,而是留在一行标记之后仍可阅读([追加来源的 transcript](../bug-fix/2026-07-29-human-transcript-append-origin.md))。TUI 渲染 Markdown 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。
|
||||
|
||||
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。
|
||||
|
||||
`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
|
||||
`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。选择器在列表上方设有一个过滤框:输入内容会按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在原先高亮行仍通过过滤时保持其选中状态;Escape 会先清空非空的过滤内容,再次按 Escape 才取消选择器。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
|
||||
|
||||
### 终端所有权
|
||||
|
||||
@@ -47,6 +47,6 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调
|
||||
|
||||
- 交互式终端拥有带状态的 Markdown、卡片、计划和提问界面,无需再对齐第二套终端协议。
|
||||
- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。
|
||||
- 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。
|
||||
- 会话投影使恢复与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。
|
||||
- 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。
|
||||
- 模型和推理强度选择使用适配器公布的元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
|
||||
2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7
|
||||
2026-07-20-dsh-cli-personal-config.md: 3331e36c86002d91fb272868268707fed014d01a
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 172d84b075ed7ecc127c317b47e30581db67f89a
|
||||
|
||||
@@ -14,10 +14,10 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh
|
||||
|
||||
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
|
||||
|
||||
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim:
|
||||
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The official dsh surfaces consume its two optional files; the demo bins boot their committed trees verbatim:
|
||||
|
||||
- `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`.
|
||||
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics are exactly the committed overlay semantics (the Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, an unmatched id warns and is skipped.
|
||||
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op.
|
||||
- A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip).
|
||||
|
||||
The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
|
||||
|
||||
@@ -14,10 +14,10 @@ Status: implemented
|
||||
|
||||
**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
|
||||
|
||||
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
|
||||
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的官方界面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
|
||||
|
||||
- `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。
|
||||
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与已提交 overlay 完全一致(Code Mode overlay 是模板):按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 记录警告并跳过。
|
||||
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。
|
||||
- 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。
|
||||
|
||||
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
|
||||
|
||||
@@ -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-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b
|
||||
2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md
|
||||
2026-07-21-tui-resume-command.md: c8cb855378d793a168e1f87d6b41f9a48db7dc14
|
||||
2026-07-21-tui-resume-command.zh.md: 2a7e74d1106499cb7d7232dc13954ae126246550
|
||||
|
||||
@@ -10,17 +10,17 @@ The original `/resume` printed shell commands. It did not let a keyboard user in
|
||||
|
||||
## Decision
|
||||
|
||||
`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled.
|
||||
`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace scope line, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It orders candidates by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. The picker opens on the current workspace and reaches every other one through the scope toggle the [cross-workspace resume](2026-07-28-cross-workspace-resume.md) note owns.
|
||||
|
||||
`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process.
|
||||
`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, workspace, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process.
|
||||
|
||||
After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
|
||||
After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume` with the validated id and the target workspace. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable.
|
||||
The exit line is a launcher-owned context slot rather than a config template, and a host without in-place handoff reports that the session stays resumable instead of naming a command it cannot construct; the [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) note owns that ownership move and supersedes the `resumeCommand` config key this note originally shipped. The TUI still never executes shell text.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`.
|
||||
**Have the TUI spawn the resume command.** Rejected: the text is display copy, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`.
|
||||
|
||||
**Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path.
|
||||
|
||||
@@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re
|
||||
|
||||
## Testing
|
||||
|
||||
TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff.
|
||||
TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, the no-host warning, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff.
|
||||
|
||||
@@ -10,17 +10,17 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。
|
||||
`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace 作用域行、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间排列候选项,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。选择页打开时位于当前 workspace,并通过[跨 workspace 恢复](2026-07-28-cross-workspace-resume.md)记录所拥有的作用域切换到达其他每一个 workspace。
|
||||
|
||||
`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。
|
||||
`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、workspace、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。
|
||||
|
||||
预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
|
||||
预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并以经过验证的 id 和目标 workspace 调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
|
||||
|
||||
`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。
|
||||
退出时打印的行是启动器拥有的上下文插槽,而非配置模板;不支持原地交接的宿主会说明会话仍可恢复,而不再给出它无法构造的命令。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md)记录了这次所有权迁移,并取代本记录最初交付的 `resumeCommand` 配置键。TUI 仍然绝不执行 shell 文本。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。
|
||||
**让 TUI 创建恢复命令进程。** 否决:该文本是展示用文案,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。
|
||||
|
||||
**在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。
|
||||
|
||||
@@ -36,4 +36,4 @@ Status: implemented
|
||||
|
||||
## Testing
|
||||
|
||||
TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。
|
||||
TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、无宿主时的告警,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md
|
||||
2026-07-22-docked-web-goal-bar.md: 110aea299a260896b0098f10b337734e0c0aebcf
|
||||
2026-07-22-docked-web-goal-bar.zh.md: cc0a5eda6815e6c02e97fd02197659764d4f2d69
|
||||
2026-07-22-docked-web-goal-bar.md: f014da61d2fa0bf25121c040dae99354ab15de9d
|
||||
2026-07-22-docked-web-goal-bar.zh.md: f62c6efbb2d330fb7d5ab74138eb781f1a1bc06c
|
||||
|
||||
@@ -6,11 +6,11 @@ English | [中文](2026-07-22-docked-web-goal-bar.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator docks directly above the message composer as a rounded-top strip tucked under the composer card's top edge. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal.
|
||||
The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator belongs in the composer-context stack; the [composer context stack decision](../bug-fix/2026-07-30-composer-context-stack-order.md) owns its position among Goal, Todo, Queue, and the composer. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal.
|
||||
|
||||
## Decision
|
||||
|
||||
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
|
||||
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
|
||||
|
||||
Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it.
|
||||
|
||||
@@ -26,14 +26,14 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card.
|
||||
- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip separates it from Todo, Queue, and the prompt it qualifies.
|
||||
- **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state.
|
||||
- **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface.
|
||||
- **Carry the full verb set (`onComplete` included) in `GoalBarActions`** — rejected as speculative generality: the interface carries only the rendered verbs (`onPause` joined it when the active strip gained its pause action).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface.
|
||||
- Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface.
|
||||
- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads).
|
||||
- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools).
|
||||
- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job.
|
||||
|
||||
@@ -6,11 +6,11 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器停靠在消息输入框正上方,呈现为一条圆角顶部的横条,收进输入框卡片顶边之下。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。
|
||||
Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器属于 composer 上下文堆栈;[composer 上下文堆栈决策](../bug-fix/2026-07-30-composer-context-stack-order.md) 规定它在 Goal、Todo、Queue 与 composer 之间的位置。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。
|
||||
|
||||
## 决策
|
||||
|
||||
`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
|
||||
`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
|
||||
|
||||
可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。
|
||||
|
||||
@@ -26,14 +26,14 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。
|
||||
- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;头部横条会使目标与 Todo、Queue 及其限定的提示词彼此分离。
|
||||
- **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。
|
||||
- **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。
|
||||
- **在 `GoalBarActions` 中携带完整动词集合(含 `onComplete`)**:作为投机性泛化不予采纳,接口只携带实际渲染的动词(active 横条获得暂停操作后,`onPause` 随之加入)。
|
||||
|
||||
## 后果
|
||||
|
||||
- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。
|
||||
- Web UI 中目标的存在形式是独立的 composer 上下文横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。
|
||||
- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。
|
||||
- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。
|
||||
- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。
|
||||
|
||||
@@ -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-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541
|
||||
2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md
|
||||
2026-07-24-configurable-tui-prompt-theme.md: f8815c6c1904c47ebb899c3da7ac62a50f7f88f0
|
||||
2026-07-24-configurable-tui-prompt-theme.zh.md: 831471860a9dcd8bb10408c492e1fc6299af6262
|
||||
|
||||
@@ -16,7 +16,7 @@ The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPr
|
||||
|
||||
Registered fragments are trusted ANSI-capable presentation output. Template literals and ordinary external content remain sanitized, but a prompt-value plugin may emit terminal controls. Composite values own coordinated background transitions and separators, so one `${powerline}` value can render a complete Powerline segment without coupling adjacent atomic providers.
|
||||
|
||||
The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `timing`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `timing` and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix.
|
||||
The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `queued`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `queued` — the steering-queue badge, unavailable unless a running turn has queued messages — and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPromp
|
||||
|
||||
注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。
|
||||
|
||||
内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。
|
||||
内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`queued`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `queued`——转向队列徽标,仅在运行中的一轮有排队消息时才可用——与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md
|
||||
2026-07-24-web-session-model-selector.md: 4bd52bba8d3ba16c01fc59d2b561516f6fd0bb87
|
||||
2026-07-24-web-session-model-selector.zh.md: 35f3525b80e7d27fb573e854aefc317ed354df53
|
||||
2026-07-24-web-session-model-selector.md: 003c0b5ac1c6963701e1d93e4c3ff8615fc45dd5
|
||||
2026-07-24-web-session-model-selector.zh.md: ba76b87b9bd43fff97bf9ea76624f5f75b10e135
|
||||
|
||||
@@ -18,7 +18,7 @@ The browser `ModelService` owns one `ModelDirectory` per live session. Its snaps
|
||||
|
||||
`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default.
|
||||
|
||||
The production browser roster is the flat config tree in `apps/cli/cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant.
|
||||
The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm
|
||||
|
||||
`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。
|
||||
|
||||
生产环境的浏览器名册是 `apps/cli/cordis.yml` 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。
|
||||
生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3
|
||||
2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md
|
||||
2026-07-25-session-list-browsing-and-manual-order.md: 831aa53e532a75392690c330837482bb0f9c32b1
|
||||
2026-07-25-session-list-browsing-and-manual-order.zh.md: 9ad074d59c13585aa4fca46ae4d40e2deb15cde6
|
||||
|
||||
@@ -12,14 +12,14 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a
|
||||
|
||||
## Decision
|
||||
|
||||
### Flat view and viewing state
|
||||
### Flat rows and viewing state
|
||||
|
||||
The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads.
|
||||
The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads.
|
||||
|
||||
### Row interactions
|
||||
|
||||
- Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight.
|
||||
- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them.
|
||||
- Session-row … menu: Rename / Fork session / Delete session; Rename and Fork are wired, while Delete remains visual-only. The workspace-header … menu's Rename / Delete workspace actions are both wired. Menus close when the pointer leaves them.
|
||||
- Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard).
|
||||
|
||||
### workspace.rename
|
||||
@@ -30,7 +30,7 @@ The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders e
|
||||
|
||||
The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal.
|
||||
|
||||
The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame.
|
||||
The UI is HTML5 drag on session rows inside a group (workspace grouping only, outside search; fork children and their source sessions are ordered independently). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame.
|
||||
|
||||
### Shell/region split
|
||||
|
||||
@@ -46,15 +46,15 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine,
|
||||
|
||||
**Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only.
|
||||
|
||||
**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent.
|
||||
**Nest sessions by fork lineage in WorkSpace mode** — nesting makes the current child visible only while its ancestors are expanded and limits in-group manual ordering to root nodes; `parentId` is lineage data, not a list-navigation structure. Flattening all sessions into peer rows lets each row be opened, searched, and ordered independently; In one list still disables drag because it has no workspace persistence carrier.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics.
|
||||
- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features.
|
||||
- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction.
|
||||
- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations.
|
||||
- Wiring session Delete and growing the wire status enum remain future iterations.
|
||||
|
||||
## Testing
|
||||
|
||||
Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`.
|
||||
Package-level suites cover the derivations (deriveGroups/deriveFlat), peer session rows, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application and pin that a fork does not introduce session expansion controls.
|
||||
|
||||
@@ -12,14 +12,14 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
### 平铺视图与浏览态
|
||||
### 平铺行与浏览态
|
||||
|
||||
group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。
|
||||
group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。
|
||||
|
||||
### 行交互
|
||||
|
||||
- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。
|
||||
- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。
|
||||
- session 行 … 菜单:Rename / Fork session / Delete session,其中 Rename 与 Fork 已接线,Delete 仍为纯视觉;workspace 组头 … 菜单的 Rename / Delete workspace 均已接线。菜单鼠标移出即关。
|
||||
- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。
|
||||
|
||||
### workspace.rename
|
||||
@@ -30,7 +30,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所
|
||||
|
||||
`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。
|
||||
|
||||
UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。
|
||||
UI 为组内 session 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子与源会话一样独立排序)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。
|
||||
|
||||
### 壳/区域切分
|
||||
|
||||
@@ -46,15 +46,15 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin
|
||||
|
||||
**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。
|
||||
|
||||
**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。
|
||||
**WorkSpace 模式按 fork 谱系嵌套 session** —— 嵌套会让当前子会话依赖祖先展开态才能可见,也让组内手动序只能移动根节点;`parentId` 是 lineage 数据,不是列表导航结构。所有 session 拍平成同级行后,每行都可独立打开、搜索与排序;In one list 仍因没有 workspace 持久化载体而禁用拖拽。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。
|
||||
- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。
|
||||
- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。
|
||||
- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。
|
||||
- session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。
|
||||
|
||||
## Testing
|
||||
|
||||
包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。
|
||||
包级用例覆盖派生(deriveGroups/deriveFlat)、同级 session 行、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用,并钉住 fork 后没有 session 展开控件。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md
|
||||
2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94
|
||||
2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7
|
||||
2026-07-27-tui-tool-card-header.md: 13f5e7fec1a82d02d5bf8cae4784505c02ab6f38
|
||||
2026-07-27-tui-tool-card-header.zh.md: 85f9f1244a7da568f5dcc892729021ac0fa270c9
|
||||
|
||||
@@ -24,7 +24,7 @@ The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src
|
||||
|
||||
**Keep the presenter title in the header** (e.g. `Tool / read / Read src/index.ts`). Rejected: the verb duplicates the tool name, and non-bash tools have no genuinely distinct one-line description — the target belongs in the body, so only bash contributes a header desc.
|
||||
|
||||
**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission.
|
||||
**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. The body's flat default-foreground styling was later revisited: the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md) recesses the whole body into one dim tone under this note's colored status header.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 pre
|
||||
|
||||
**把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。
|
||||
|
||||
**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。
|
||||
**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。正文原本以默认前景色平铺,这种样式后来也经过调整:[整合后的 TUI 呈现](../architecture/2026-07-28-consolidated-tui-presentation.md)把整个正文收进本文所述彩色状态标题之下的同一种暗色调。
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md
|
||||
2026-07-27-web-session-fork-actions.md: b5dc7e820de069a68b38ed87c7d29ffbdb4867bc
|
||||
2026-07-27-web-session-fork-actions.zh.md: 774cd74d69eb02d43ca01c8ec7ba1cf94c24b1dc
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Web session fork actions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-web-session-fork-actions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Session store already provides a fork primitive that creates a child session from a completed-turn prefix, but the Web client has no unified interaction contract. The Session-row menu can express only “branch from the latest completed turn,” while message IconActions need to express “branch from the turn containing this message”; if the two entry points independently interpret the boundary, switching, and failure behavior, the same user action acquires two sets of semantics. Nesting a fork child beneath its source session also makes the newly selected child visible only while its ancestors are expanded and weakens the workspace manual-order model.
|
||||
|
||||
## Decision
|
||||
|
||||
The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list.
|
||||
|
||||
`forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation.
|
||||
|
||||
Session lineage is not projected into a list hierarchy. WorkSpace mode displays source sessions and all fork children as peer rows in the manual order from `WorkspaceView.sessionIds`; every row can be opened, searched, and dragged independently. In one list mode continues to sort strictly by `updatedAt`; the Ungrouped group also sorts by recency when no workspace ledger is available. `parentId` remains available for lineage, tool presentation, and later queries, but does not control session-list visibility.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Wire only the Session-row menu.** Rejected: at a message, the user has already selected more precise context; forcing them back to the list can only degrade the boundary to the latest completed turn, while the visible message branch icon would remain non-responsive.
|
||||
|
||||
**Allow branching only from user messages.** Rejected: settled assistant content also has a stable event `seq`, and the host places it in its containing completed turn; making only one of two visually identical branch buttons work would create an invisible behavioral difference.
|
||||
|
||||
**Nest fork children beneath their source by `parentId`.** Rejected: lineage is not navigation ownership; nesting requires automatic ancestor expansion to reveal the current item and prevents children from participating in the workspace's peer manual order.
|
||||
|
||||
**Call the session service directly from message components.** Rejected: client components must not touch `ctx` or business services; injected callbacks keep mutation in the apply world and leave components driven purely by props.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls.
|
||||
|
||||
Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Web session fork 操作
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-web-session-fork-actions.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 Web 端没有一份统一的交互契约。Session 行菜单只能表达「从最新完成轮分支」,消息 IconActions 还需要表达「从这条消息所在轮分支」;如果两处各自解释边界、切换与失败行为,同一个用户动作会形成两套语义。把 fork 子会话嵌套在源会话下还会让新选中的子会话依赖祖先展开态才能看见,并削弱 workspace 的手动排序模型。
|
||||
|
||||
## Decision
|
||||
|
||||
Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。
|
||||
|
||||
`forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。
|
||||
|
||||
Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行,每行都可独立打开、搜索和拖拽;In one list 模式继续按 `updatedAt` 严格排序;Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage、工具呈现和后续查询,但不控制 session 列表可见性。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只接 session 行菜单。** 否决:用户在消息处已经选择了更精确的上下文,强迫其回到列表只能退化为最新完成轮,且已展示的消息分支图标会成为无响应控件。
|
||||
|
||||
**只允许用户消息分支。** 否决:已定稿 assistant 内容同样有稳定事件 `seq`,host 会把它归入所属完成轮;让两个外观相同的分支按钮只有一个可用会制造不可见的行为差异。
|
||||
|
||||
**按 `parentId` 把 fork 子会话嵌套在源会话下。** 否决:lineage 不是导航所有权;嵌套要求自动展开祖先才能看见当前项,并让子会话无法参与 workspace 的同级手动排序。
|
||||
|
||||
**由消息组件直接调用 session 服务。** 否决:client 组件不得接触 `ctx` 或业务服务;注入回调让 mutation 留在 apply 世界,组件保持纯 props。
|
||||
|
||||
## Consequences
|
||||
|
||||
用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。
|
||||
|
||||
Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
|
||||
2026-07-28-cross-workspace-resume.md: 1ef6195b6f269a6dc8cf869902b7772ad48f00c2
|
||||
2026-07-28-cross-workspace-resume.zh.md: d31dc5f0470e20f86062edb4ad75c37a6160fe23
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: Cross-workspace session resume
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-cross-workspace-resume.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing.
|
||||
|
||||
Storage was the binding one. The shipped TUI composition defaulted its persistence root to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it.
|
||||
|
||||
The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused.
|
||||
|
||||
Finally, resume never changed directory. The host re-execs `dsh --resume=<id>` through `process.execve`, which inherits the cwd. Session *header* cwd is restored from the log, but process cwd is what `dsh-fs-local`, the bash executor, and glob/grep resolve against, so resuming a foreign session would have replayed its transcript while acting on the wrong project.
|
||||
|
||||
## Decision
|
||||
|
||||
The shared CLI configuration supplies one session root under the Harness home, the picker gains a workspace scope, and the handoff carries the target directory.
|
||||
|
||||
**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row resolves `sessions` beneath `DSH_HOME`, with the standard `~/.dsh` fallback when the environment has not been initialized. TUI, Web, and headless therefore consume one default without launcher patches or a boot slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice.
|
||||
|
||||
**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for.
|
||||
|
||||
`summarizeResumeCandidate` therefore drops `'different workspace'` and gains `'session has no recorded workspace'`. That is a real new refusal rather than a rename: a header without `cwd` names no directory for the host to enter, so it cannot be handed off even though its log is intact.
|
||||
|
||||
**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. Resume always uses the default `dsh --resume` surface because `meta` rejects parent options; the handoff already enters the persisted target directory.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Patch `persistenceRoot` from the `dsh` launcher instead of changing the bundle default.** Rejected after finding that a loader patch assigns `config` wholesale. The personal `~/.dsh/config.yaml` overlay already patches the `tui-agent` row with a partial config, which is exactly why `persistenceRoot` was falling back to the bundle default in the first place; a launcher patch would either be erased by that overlay or have to win over it and make the overlay unable to set the field. Owning the default in the bundle survives any partial patch and keeps one home for the fact.
|
||||
|
||||
**Keep `./.sessions` and additionally scan the Harness-home root.** Rejected: two roots means two SQLite indexes and a merged listing whose rows have different liveness and revision authorities, to preserve visibility of logs that the no-migration decision already gives up.
|
||||
|
||||
**Migrate existing project-local logs into the shared root.** Rejected by the requester. Sessions under a project's `./.sessions` stay on disk and stay resumable by explicit `dsh --resume <id>` from that directory, but no longer appear in `/resume`.
|
||||
|
||||
**One flat list of every workspace.** Rejected: it loses the "this project" default that the overwhelmingly common case wants, and in a busy home directory the current project's sessions would compete with unrelated ones.
|
||||
|
||||
**Let the host infer the directory from the restored session header.** Rejected: the header is model- and prompt-facing state restored *after* boot, while the directory must be entered *before* `execve`. Passing it explicitly keeps the ordering visible at the seam.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration.
|
||||
- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it.
|
||||
- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here.
|
||||
|
||||
## Testing
|
||||
|
||||
TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. Built CLI PTY tests exercise the shared config default and the per-process derived query index. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: 跨 workspace 会话恢复
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-cross-workspace-resume.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。
|
||||
|
||||
存储是那个决定性的原因。已交付的 TUI 组合把持久化根默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。
|
||||
|
||||
接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。
|
||||
|
||||
最后,恢复流程从不切换目录。宿主通过 `process.execve` 重新执行 `dsh --resume=<id>`,而它会继承 cwd。会话*头部*的 cwd 会从日志中还原,但 `dsh-fs-local`、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。
|
||||
|
||||
## Decision
|
||||
|
||||
共享 CLI 配置提供 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
|
||||
|
||||
**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项把 `sessions` 解析到 `DSH_HOME` 下;环境尚未初始化时则使用标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需启动器补丁或启动槽位。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。
|
||||
|
||||
**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。
|
||||
|
||||
因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。
|
||||
|
||||
**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。恢复始终使用默认的 `dsh --resume` 界面,因为 `meta` 会拒绝父级选项;交接过程已经进入持久化保存的目标目录。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**从 `dsh` 启动器给 `persistenceRoot` 打补丁,而不是改动组合包默认值。** 在发现 loader 补丁会整体赋值 `config` 之后否决。个人的 `~/.dsh/config.yaml` 覆盖层已经用一份局部配置给 `tui-agent` 那一项打了补丁,这恰恰就是 `persistenceRoot` 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。
|
||||
|
||||
**保留 `./.sessions`,并额外扫描 Harness home 根目录。** 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。
|
||||
|
||||
**把现有的项目本地日志迁移到共享根目录。** 被需求方否决。项目 `./.sessions` 下的会话仍留在磁盘上,从该目录显式执行 `dsh --resume <id>` 仍可恢复,只是不再出现在 `/resume` 中。
|
||||
|
||||
**把所有 workspace 铺成一个扁平列表。** 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。
|
||||
|
||||
**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在边界处保持可见。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。
|
||||
- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
|
||||
- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。
|
||||
|
||||
## Testing
|
||||
|
||||
TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。构建后的 CLI PTY 测试会运行共享配置默认值与每进程派生的查询索引。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md
|
||||
2026-07-28-dsh-guided-skill-session-commands.md: a11807dca9ad1640857cd95a8b528df691b8a27e
|
||||
2026-07-28-dsh-guided-skill-session-commands.zh.md: e48420ed77496d8c336031473364f9008fccced7
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-dsh-guided-skill-session-commands.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two recurring flows begin with the user manually invoking one skill and answering its questions: migrating from another coding agent, and upgrading this checkout. Both require the user to know the skill exists and to type `/skill:dsh-migrate` or `/skill:dsh-upgrade` as the session's first turn. A dedicated entry command that drops the user straight into that guided session removes the discovery step.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:<name>` and pressed Enter.
|
||||
|
||||
The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:<name>` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set.
|
||||
|
||||
**Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume <id>` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup.
|
||||
|
||||
**`migrate`/`upgrade` take no options.** Unlike `meta`, they carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`.
|
||||
|
||||
The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent.
|
||||
|
||||
## Testing
|
||||
|
||||
`apps/cli/tests/args.spec.ts` gains routing for `migrate`/`upgrade` (bare discriminant) and exit-1 for every leaked option on either side of each subcommand.
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`.
|
||||
|
||||
No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Prefill the input and let the user press Enter.** Rejected: needs a new editor-prefill seam and still requires a keystroke. Auto-submit reuses `invokeSkill` and delivers the intended one-command entry.
|
||||
|
||||
**Seed a natural-language instruction ("use the dsh-migrate skill…") instead of `/skill:<name>`.** Rejected here: the literal skill-invocation path renders the skill body into the first turn deterministically, identical to the manual command, rather than depending on the model choosing to load the skill.
|
||||
|
||||
**Support `--resume` on `migrate`/`upgrade`.** Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's `dsh --resume <id>`; re-injecting the skill on resume would duplicate the first turn.
|
||||
|
||||
**Read `INITIAL_SKILL_KEY` outside the TUI (as `CONFIGURED_AGENT_IDENTITIES_KEY` is read by `agent-loop`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and touches no other plugin.
|
||||
|
||||
## Consequences
|
||||
|
||||
Migrating or upgrading is one command from anywhere, with the guiding skill already invoked. The launcher→TUI initial-skill slot is reusable by any future guided-session command; the TUI's contract is "invoke this named skill once at startup," and freshness/resume policy stays with the launcher that owns session identity. The [TUI skill slash command](2026-07-21-tui-skill-slash-command.md) remains the mechanism; this note adds a launcher-driven auto-invocation of it and does not supersede it.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-dsh-guided-skill-session-commands.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
有两个反复出现的流程都以用户手动调用某个 skill 并回答其问题开始:从其他编码 agent 迁移,以及升级本 checkout。二者都要求用户知道该 skill 存在,并把 `/skill:dsh-migrate` 或 `/skill:dsh-upgrade` 作为会话首轮键入。一个专用入口命令若能让用户直接进入该引导式会话,便可省去这一发现步骤。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:<name>` 并回车。
|
||||
|
||||
播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:<name>` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给 TUI,与 `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。
|
||||
|
||||
**新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。
|
||||
|
||||
**`migrate`/`upgrade` 不接受任何选项。** 与 `meta` 不同,它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。
|
||||
|
||||
`dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。
|
||||
|
||||
## 测试
|
||||
|
||||
`apps/cli/tests/args.spec.ts` 新增 `migrate`/`upgrade` 的路由(裸判别式),以及每个子命令两侧任一泄漏选项的退出码 1。
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。
|
||||
|
||||
无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**预填输入框并让用户按回车。** 已否决:需要新增编辑器预填 seam,且仍需一次按键。自动提交复用 `invokeSkill`,实现预期的一命令入口。
|
||||
|
||||
**播种自然语言指令(“使用 dsh-migrate skill……”)而非 `/skill:<name>`。** 在此否决:字面 skill 调用路径会确定性地把 skill 正文渲染进首轮,与手动命令完全一致,而不依赖模型自行选择加载该 skill。
|
||||
|
||||
**在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume <id>` 到达的普通 TUI 会话;恢复时重新注入 skill 会重复首轮。
|
||||
|
||||
**在 TUI 之外读取 `INITIAL_SKILL_KEY`(如同 `agent-loop` 读取 `CONFIGURED_AGENT_IDENTITIES_KEY` 那样),而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽位折叠进配置,可以让它与其他由启动器持有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且不触及任何其他插件。
|
||||
|
||||
## 后果
|
||||
|
||||
迁移或升级从任何位置都只需一条命令,且引导 skill 已被调用。启动器→TUI 的初始 skill 槽可被未来任何引导式会话命令复用;TUI 的契约是“在启动时调用一次这个具名 skill”,而新鲜性/恢复策略留在拥有会话身份的启动器一侧。[TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)仍是该机制;本 note 在其之上新增了一个由启动器驱动的自动调用,并未取代它。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md
|
||||
2026-07-28-dsh-meta-source-workspace.md: d65e0e6ff092b63931dd58c52fa76fb76a071dff
|
||||
2026-07-28-dsh-meta-source-workspace.zh.md: 72a0c65e6eeda7d63dccc2306a3d7625cdb54362
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: `dsh meta` boots the TUI over the harness checkout
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh` treats the invoking directory as the workspace, which is what makes it useful on arbitrary projects. Working on dsh itself therefore means `cd`-ing to the checkout first — and the checkout is not a memorable path: the source install keeps it under a container directory as a timestamped staging worktree (`~/.dsh/source/staging-<timestamp>`) behind a `current` symlink, so the target moves on every upgrade. The agent is already *told* where its source lives by the `harness:source` prompt section, and the `cordis` toolset can modify that runtime, but the human still had to locate the directory by hand to start a session there.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory.
|
||||
|
||||
The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active.
|
||||
|
||||
The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by a new optional third parameter that only `runMeta` passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent.
|
||||
|
||||
`meta` accepts only `--resume <id>`. `--config` would boot a foreign tree against the harness workspace, which is the `--config` case rather than this one; `-p` is not interactive. Both fail loud, as does an empty `--resume=` — matching the default surface, where a swallowed empty id would silently start a fresh session.
|
||||
|
||||
**`meta` does not redeclare `--resume`.** Commander parses an option a subcommand shares with its parent into `program.opts()` and leaves the subcommand's own options object empty, so redeclaring it silently dropped the id (found by probing the adapter, not by review). The action reads `program.opts()`, which also accepts the flag on either side of the subcommand; `--help` still lists it among the parent's options.
|
||||
|
||||
## Testing
|
||||
|
||||
`apps/cli/tests/args.spec.ts` extends its two existing cases rather than adding a file: routing for `meta`, `meta --resume <id>`, and `--resume <id> meta` (pinning the shared-option behavior above), and exit-1 for `meta --resume=`, `meta --config`, and `meta -p`. `runMeta` itself is composition inside the module's existing `v8 ignore` block, like `runTui`.
|
||||
|
||||
There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir.
|
||||
|
||||
The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one.
|
||||
|
||||
`dsh meta --resume <valid-id>` once started a *fresh* session instead of resuming — a pre-existing defect on the default surface, not one this mode introduced. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) found the cause and fixed it: a personal overlay had replaced the whole `tui-agent` config block, overwriting the shipped `resumeSessionId` intake with a read of an unset environment variable, so a valid id was silently ignored. Session identity is now a launcher-owned context slot that no config key can displace, and `meta` routes through it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it.
|
||||
|
||||
**A `--meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape.
|
||||
|
||||
**Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another.
|
||||
|
||||
**Make the printed resume hint mode-aware.** Deferred here as a known cost, then delivered by [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md): the exit line became a launcher-provided context slot, so meta mode prints `dsh meta --resume <id>` and a copied hint works from any directory. It previously came from static config as `dsh --resume {session}` and only worked when re-run from the checkout.
|
||||
|
||||
## Consequences
|
||||
|
||||
Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. Meta sessions are isolated in the checkout's `.sessions/`, so `dsh meta --resume` sees only other meta sessions — intended, since a session's logged cwd belongs to its workspace.
|
||||
|
||||
The resume hint was this mode's original cost and is now resolved. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) made both the printed line and the in-place `/resume` handoff reproduce the mode as `dsh meta --resume <id>` from one shared argv helper, so a copied hint works from any directory and the handoff no longer depends implicitly on `execve` preserving the process cwd.
|
||||
|
||||
`runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-dsh-meta-source-workspace.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh` 把调用目录视为 workspace,这正是它能作用于任意项目的原因。但因此,开发 dsh 自身就得先 `cd` 到检出目录——而该目录并不是一个好记的路径:源码安装会把它放在一个容器目录下、作为带时间戳的 staging 工作树(`~/.dsh/source/staging-<timestamp>`),并由 `current` 符号链接指向,因此每次升级后目标都会变化。`harness:source` 提示词段已经*告知* agent 其源码位置,`cordis` 工具集也能修改该运行时,但人类仍需手工定位该目录才能在其中开始会话。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。
|
||||
|
||||
目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。
|
||||
|
||||
机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个新的可选第三参数把守,只有 `runMeta` 会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。
|
||||
|
||||
`meta` 只接受 `--resume <id>`。`--config` 会以 harness workspace 启动其他配置树,那属于 `--config` 的场景而非本场景;`-p` 并非交互式。两者都会明确报错,空的 `--resume=` 亦然——与默认界面一致,在那里被吞掉的空 id 会静默开启一个新会话。
|
||||
|
||||
**`meta` 不重新声明 `--resume`。** 对于子命令与父命令共享的选项,Commander 会将其解析进 `program.opts()`,而把子命令自身的 options 对象留空;因此重新声明会静默丢弃该 id(这是通过实测适配器发现的,而非评审发现)。action 读取 `program.opts()`,这同时也允许该标志出现在子命令的任意一侧;`--help` 仍会在父命令的选项中列出它。
|
||||
|
||||
## Testing
|
||||
|
||||
`apps/cli/tests/args.spec.ts` 扩展其已有的两个用例而非新增文件:`meta`、`meta --resume <id>` 与 `--resume <id> meta` 的路由(钉住上述共享选项行为),以及 `meta --resume=`、`meta --config`、`meta -p` 的退出码 1。`runMeta` 自身与 `runTui` 一样,属于该模块既有 `v8 ignore` 块内的组合代码。
|
||||
|
||||
该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。
|
||||
|
||||
取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。
|
||||
|
||||
`dsh meta --resume <有效 id>` 曾经开启一个*新*会话而非恢复——这是默认界面上既已存在的缺陷,并非本 mode 引入。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 查明了原因并将其修复:一个个人 overlay 替换了整个 `tui-agent` 配置块,用对一个未设置的环境变量的读取覆盖了已交付的 `resumeSessionId` 入口,因此有效的 id 会被静默忽略。会话标识如今是一个启动器拥有的上下文槽位,没有任何配置键能取代它,而 `meta` 经由它进行路由。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。
|
||||
|
||||
**在默认界面上加一个 `--meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。
|
||||
|
||||
**解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。
|
||||
|
||||
**让打印的恢复提示随 mode 变化。** 在此作为已知代价推迟,随后由 [由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 交付:退出行变成了一个启动器提供的上下文槽位,因此 meta 模式打印 `dsh meta --resume <id>`,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `dsh --resume {session}`,且只有在检出目录中重新运行才有效。
|
||||
|
||||
## Consequences
|
||||
|
||||
在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。meta 会话被隔离在检出目录的 `.sessions/` 内,因此 `dsh meta --resume` 只能看到其他 meta 会话——这是预期行为,因为会话记录的 cwd 属于它的 workspace。
|
||||
|
||||
恢复提示曾是本 mode 的原初代价,如今已解决。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 让打印的行与原地 `/resume` 移交都从同一个共享的 argv 辅助函数将该 mode 复现为 `dsh meta --resume <id>`,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。
|
||||
|
||||
`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md
|
||||
2026-07-29-addressable-queue-operations.md: 78a7d346163bb7e5e76c989c6e93576b4a6cee64
|
||||
2026-07-29-addressable-queue-operations.zh.md: 050b9755ad4ebe70e2bdcafb711ef279331e27af
|
||||
2026-07-29-addressable-queue-operations.md: 7a08b889c958e583dc430d33a1855fe3725f3d48
|
||||
2026-07-29-addressable-queue-operations.zh.md: 701b028c7494fd7cb608d05a5d170c9075b155d7
|
||||
|
||||
@@ -18,7 +18,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M
|
||||
|
||||
**Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`.
|
||||
|
||||
**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence.
|
||||
**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `"<n> 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -34,7 +34,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M
|
||||
|
||||
## Verification
|
||||
|
||||
AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire.
|
||||
AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios capture the default collapsed header before expanding the queue and driving its exposed edit and delete actions through the built Web composition and real HTTP/SSE wire.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行
|
||||
|
||||
**Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。
|
||||
|
||||
**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。
|
||||
**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `"<n> 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -34,7 +34,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行
|
||||
|
||||
## 验证
|
||||
|
||||
AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。
|
||||
AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会先捕获默认收起的表头,再展开队列,并通过构建后的 Web 组合和真实 HTTP/SSE 协议操作其公开的编辑和删除。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md
|
||||
2026-07-29-directory-picker-adaptive-default.md: 7ff6529bb8e445f63343b1019ac520f56b19d5e4
|
||||
2026-07-29-directory-picker-adaptive-default.zh.md: a2a2d8ec4c91a347eedfc3aa3413b091ee847934
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Adaptive default for the directory-picker interaction
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-directory-picker-adaptive-default.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [directory-picker seam](../architecture/2026-07-28-directory-picker-capability-seam.md) made the interaction a `cordis.yml` swap point, but the shipped composition still had to pin one backend: `-browse` everywhere meant a local operator never got the OS chooser, `-native` everywhere breaks every remote deployment. The right default depends on facts only the running host knows — where the server binds, whether the process was launched over SSH, whether a display session exists — so no static row is correct for all deployments.
|
||||
|
||||
## Decision
|
||||
|
||||
A third sibling package, **`dsh-host-directory-picker-auto`**: a node-half-only *chooser* that owns no picking code and no UI. Its `apply` samples the host facts exactly once at boot — bind host from the injected `httpServer` (a new `host` getter mirrors the existing `port`), `SSH_CONNECTION`/`SSH_TTY`, platform, `DISPLAY`/`WAYLAND_DISPLAY`, and a `PATH` probe for a Linux chooser binary (zenity/kdialog) — resolves them through one exported pure function, and mounts the chosen dual-face backend with `ctx.loader.create({name})` into the Loader's **in-memory root tree**; the effect's disposer removes the entry and joins the backend fiber's teardown (`remove()` alone only starts it), so unloading the chooser settles only after the backend quiesced. `native` requires every attended-and-servable signal: loopback bind ∧ no SSH markers ∧ a display session the native backend can drive — assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a chooser binary on linux, and never true elsewhere (the native backend supports exactly darwin/win32/linux). Anything ambiguous resolves to `browse`, which works everywhere. `apps/cli` now mounts `-auto` as its `directory-picker` row; composing `-native` or `-browse` directly remains the pin.
|
||||
|
||||
Why entry-level mounting is the load-bearing mechanism: the client module table (`dsh-client-modules`) reconciles **Loader entries** reactively over `internal/plugin`, so a backend mounted as a real entry gets its browser half discovered exactly as a config-row's would be — the seam's one-row-swaps-both-faces invariant survives adaptivity with zero duplicated client code. The dev HMR row (`AppCLIEntry`) is the mechanism precedent. Root-tree targeting matters: the root tree's `write()` is a no-op, so the resolved row can never be persisted back into `cordis.yml` (the Include subtree *does* write).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Boot-glue resolution in `AppCLIEntry`** (ship both rows with static `disabled`, patch `disabled` from a `--directory-picker=auto|native|browse` flag). Works — `PatchOptions` patches metadata, and the modules scan skips disabled rows — but leaves the decision app-private where every future composition re-implements it; the chooser plugin gives any `cordis.yml` the same one-row adaptivity. Reintroduce the flag only when a deployment needs to *force* a backend without editing its yml.
|
||||
- **One merged plugin branching per call** (client tries `pick`, falls back to the browse dialog on `directory-picker-unavailable`). Rejected: the client would need both flows in one bundle — the bundle-purity gate forbids cross-plugin value imports and jscpd forbids copying the dialog — and per-call probing pays a doomed RPC on every open of a browse host.
|
||||
- **Resurrecting the wire advertisement** so both client flows mount and branch on the host's kind. Rejected: reverses the seam note's deletion for no consumer the chooser doesn't already serve, and collides with the `single` directory-flow holes.
|
||||
- **Per-connection adaptivity** (native for a loopback browser, browse for a remote one, same server). Deferred: needs a per-client capability, the advertisement above, and both flows mounted; no deployment serves both operator shapes at once today.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, headless host, unsupported platform, or Linux without a chooser binary → in-app browser. Detection infers operator location from launch context, which no launch-side signal can prove: a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, arriving from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation — per-connection adaptivity could not fix that last case either. A wrong `native` choice degrades to the backend's existing retryable failure dialog; deployments in these shapes compose `-browse` directly.
|
||||
- The chooser mounts backends by runtime string (`BACKEND_PACKAGES`, exported), which yml-row scanning cannot see; `verify-cordis-config` therefore requires every composition mounting `-auto` to declare both backends as dependencies, so keyless Linux CI (which only ever resolves `browse`) cannot hide a dropped `-native` dependency. The shipped-tree web e2e/snapshot lane (`apps/web/tests/scaffold.ts`) pins `-browse` by disable+insert patch — its goldens are interaction-specific and must not depend on the host running the suite.
|
||||
- One resolution per boot keeps the seam's capability-stability contract; per-connection shapes remain out of scope until a deployment demands them.
|
||||
- Mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service; duplicate flow in the `single` holes).
|
||||
- The host typecheck aggregate now references the two backend projects (declarations only, node entries carry no client merge) so the chooser's REAL-composition test can mount them — the mirror of the client aggregate's `webserver` reference.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note:目录选择交互的自适应默认值
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-07-29-directory-picker-adaptive-default.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[目录选择 seam](../architecture/2026-07-28-directory-picker-capability-seam.md)把交互形态做成了 `cordis.yml` 的切换点,但随附的组合仍必须固定一个后端:处处用 `-browse` 意味着本地操作者永远得不到 OS 选择器,处处用 `-native` 则弄坏所有远程部署。正确的默认值取决于只有运行中的宿主才知道的事实——服务器绑定在哪里、进程是否经 SSH 启动、是否存在显示会话——因此没有哪一静态行对所有部署都正确。
|
||||
|
||||
## 决策
|
||||
|
||||
第三个同级包 **`dsh-host-directory-picker-auto`**:一个只有 node 半侧的*选择器*,不持有任何选取代码,也没有 UI。它的 `apply` 在启动时恰好采样一次宿主事实——从注入的 `httpServer` 读绑定宿主(新增的 `host` getter 与既有的 `port` 对称)、`SSH_CONNECTION`/`SSH_TTY`、平台、`DISPLAY`/`WAYLAND_DISPLAY`、以及对 Linux 选择器二进制(zenity/kdialog)的一次 `PATH` 探查——经由一个导出的纯函数判定,再用 `ctx.loader.create({name})` 把选中的双面后端挂进 Loader 的**内存根树**;该 effect 的 disposer 会移除该条目并汇入后端 fiber 的拆卸(单靠 `remove()` 只是启动拆卸),因此卸载选择器要到后端静止之后才落定。`native` 要求全部“有人值守且可服务”信号:回环绑定 ∧ 无 SSH 标记 ∧ native 后端能驱动的显示会话——darwin/win32 上视为存在,linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY` 外加一个选择器二进制,其余平台一律不成立(native 后端恰好支持 darwin/win32/linux)。任何含糊情形都判定为处处可用的 `browse`。`apps/cli` 现在把 `-auto` 挂为它的 `directory-picker` 行;直接组合 `-native` 或 `-browse` 仍是固定交互的方式。
|
||||
|
||||
条目级挂载之所以是承重机制:client 模块表(`dsh-client-modules`)基于 `internal/plugin` 对 **Loader 条目**做响应式协调,因此以真实条目挂载的后端,其 browser half 被发现的方式与配置行完全相同——seam 的“一行同时换两面”不变式在自适应下依然成立,且没有一行重复的 client 代码。开发环境的 HMR 行(`AppCLIEntry`)是该机制的先例。瞄准根树很关键:根树的 `write()` 是 no-op,因此判定出的行绝不会被持久化回 `cordis.yml`(Include 子树*会*写回)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在 `AppCLIEntry` 里做启动胶水判定**(随附两行并带静态 `disabled`,由 `--directory-picker=auto|native|browse` 标志修补 `disabled`)。可行——`PatchOptions` 能修补元数据,模块扫描也会跳过禁用行——但把决策留成应用私有,此后每个组合都要重新实现;选择器插件让任何 `cordis.yml` 都获得同样的一行自适应。只有当某个部署需要不改自己的 yml 就*强制*指定后端时,才重新引入该标志。
|
||||
- **合并成一个按调用分支的插件**(client 先试 `pick`,收到 `directory-picker-unavailable` 再回退到浏览对话框)。否决:client 得把两套流程装进同一个 bundle——bundle 纯净门禁禁止跨插件的值导入,jscpd 禁止复制对话框——而且按调用探测让 browse 宿主每次打开都付出一次注定失败的 RPC。
|
||||
- **复活 wire 广播**,让两套 client 流程都挂载并按宿主的 kind 分支。否决:推翻 seam Agent Note 的那次删除,却服务不了任何选择器尚未服务的消费方,还与 `single` 目录流洞相冲突。
|
||||
- **按连接自适应**(同一台服务器,回环浏览器用 native、远程浏览器用 browse)。延期:需要按客户端的能力对象、上述广播,以及同时挂载两套流程;今天没有部署同时服务两种操作者形态。
|
||||
|
||||
## 后果
|
||||
|
||||
- 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定、无头宿主、不支持的平台,或没有选择器二进制的 Linux → 应用内浏览器。探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点:脱离的 tmux 会话会丢失 `SSH_*`;非 Aqua 的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上——即便按连接自适应也修不了最后这一情形。错误的 `native` 选择会退化为后端既有的可重试失败对话框;处于这些形态的部署直接组合 `-browse`。
|
||||
- 选择器按运行时字符串(已导出的 `BACKEND_PACKAGES`)挂载后端,yml 行扫描看不到这一点;因此 `verify-cordis-config` 要求每个挂载 `-auto` 的组合把两个后端都声明为依赖,使无密钥的 Linux CI(它永远只会判定出 `browse`)无法掩盖被丢掉的 `-native` 依赖。随附树的 web e2e/快照通道(`apps/web/tests/scaffold.ts`)以 disable+insert 补丁固定 `-browse`——其 golden 是交互特定的,绝不能依赖运行该套件的宿主。
|
||||
- 每次启动只判定一次,维持 seam 的能力稳定性契约;按连接的形态在有部署提出需求前仍不在范围内。
|
||||
- 同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务;`single` 洞中的重复流程)。
|
||||
- host 类型检查聚合现在引用两个后端项目(仅声明,node 入口不携带 client 合并),使选择器的 REAL-composition 测试能挂载它们——与 client 聚合对 `webserver` 的引用互为镜像。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: e79662056792c3ab413468ad038dec40455be767
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: ccc9b127d2fcf276fa415ec0ede8b062e45f3df5
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: fb4b82a22478b2de38755368ec48a5dca420e79d
|
||||
|
||||
@@ -12,7 +12,7 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo
|
||||
|
||||
**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant *content* nodes (non-empty text blocks) append a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.**
|
||||
|
||||
Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
|
||||
Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Both message rows pass their event's `seq` to the same fork callback; [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the real mutation contract. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -22,10 +22,10 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day
|
||||
|
||||
**Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate.
|
||||
|
||||
**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
|
||||
**Let the IconActions decision also define session fork semantics.** Rejected: this note owns only message chrome, clocks, and mount gating; boundary selection, failure behavior, and switching semantics belong to the separate [Web session fork actions](2026-07-27-web-session-fork-actions.md), keeping presentation components from becoming a second home for session mutation.
|
||||
|
||||
**Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source.
|
||||
|
||||
## Consequences
|
||||
|
||||
Settled assistant content answers expose copy and the event clock as soon as the row mounts; Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome.
|
||||
Settled assistant content answers expose copy, branch, and the event clock as soon as the row mounts; Think-only nodes stay chrome-free. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only assistant gate, and the respective event `seq` values passed by the user and assistant branch buttons; the web e2e scenario pins the assembled IconActions chrome.
|
||||
|
||||
@@ -12,7 +12,7 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
|
||||
|
||||
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。**
|
||||
|
||||
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
|
||||
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
|
||||
|
||||
## 曾考虑的方案
|
||||
|
||||
@@ -22,10 +22,10 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
|
||||
|
||||
**在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。
|
||||
|
||||
**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
|
||||
**由 IconActions 决策同时定义 session fork 语义。** 否决:本笔记只拥有消息 chrome、时钟与挂载门控;边界选择、失败行为和切换语义属于独立的 [Web session fork 操作](2026-07-27-web-session-fork-actions.md),避免展示组件成为 session mutation 的第二正家。
|
||||
|
||||
**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。
|
||||
|
||||
## 后果
|
||||
|
||||
已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。
|
||||
已定稿的 assistant 内容回答在行挂载后立刻暴露复制、分支与事件时钟;纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控,以及 user/assistant 分支按钮各自传递的事件 `seq`;Web e2e 场景钉住组装后的 IconActions chrome。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md
|
||||
2026-07-30-plan-review-presentation-intent.md: aeab12aac308c24aa5c4b5953a60ae0f2cf6c5b5
|
||||
2026-07-30-plan-review-presentation-intent.zh.md: 4096018e374212c821675ce6ed3a2355df20edc9
|
||||
@@ -0,0 +1,55 @@
|
||||
# Agent Note: Plan review as a decision, not a question
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-plan-review-presentation-intent.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`exit_plan_mode` presents a finished plan for review through `ctx.userInteraction.ask()`, the same seam `ask_user_question` uses. On the Web GUI that made a plan review render as the generic question flow of [the ask-question Web presentation](2026-07-29-ask-question-web-presentation.md): a `1 / 1` pager, the plan as a question's supporting detail, the two verdicts as numbered radio rows with descriptions, an "Other — enter a custom answer" row, and `Skip this question` / `Submit` in the footer.
|
||||
|
||||
Every one of those affordances is wrong for the surface. Reviewing a plan is one decision over one document, and the quiz chrome told the user they were being examined rather than asked to approve work — reported as "让人很困惑以为在做题". The paging controls page a set of one. Skipping is not an outcome the tool accepts (it folds into keep-planning). Worst, the surface gave no hint that this was the plan gate at all, while the adjacent waiting-approval takeover already had exactly the right shape for a decision: a tinted strip naming what is being decided, the subject as the body, and a right-aligned action row.
|
||||
|
||||
## Decision
|
||||
|
||||
A question may declare a **presentation intent**, and the Web composer renders a declared intent as its own surface. `AskUserQuestionItem` gains `intent?: AskUserQuestionIntent`, a tagged shape whose one member is `{ kind: 'plan-review', approve: string }`; `plan-mode` sets it on the review question, naming `Approve` as the label that approves.
|
||||
|
||||
An intent shapes presentation only. The answer protocol is untouched: a UI honouring the intent answers with the same option labels a generic UI would send, so `exit_plan_mode` reads one answer shape regardless of which surface collected it, and a UI that does not know a tag renders the generic flow with nothing lost but the layout.
|
||||
|
||||
`approve` names the affirmative option instead of relying on option order, so no UI infers a verdict from a position. Two assertions an intent makes are beyond the types, and `UserInteractionService.ask()` rejects both as `BAD_INTENT` at the asker: an `approve` naming none of that question's own options — before any UI can answer a choice never offered — and an intent on a question with no `detail`, the thing it declares itself a review of, which would ask the user to approve something invisible. On the wire the intent is a discriminated union, so an unrecognised tag is a rejected frame rather than a silently generic render.
|
||||
|
||||
`ui-question` renders the intent as `PlanReviewPanel`, in the waiting-approval card language: the amber strip carries `Plan review`, the plan is the scrolling markdown body, and the decision row holds three actions — `Chat about it`, `Refuse`, `Approve`. The question text becomes the card's accessible name rather than a headline, because the buttons already say what the decision is. Approve and Refuse answer with the asker's own option labels and keep the asker's descriptions as tooltips; `Chat about it` cancels the request, which returns the composer so the user can simply say what they want. All copy is bilingual under the existing `question` namespace.
|
||||
|
||||
Routing lives inside the single composer entry (`QuestionComposer` chooses the shape) rather than in a second chain registration, and `planReviewOf` claims a request only when the card can send every answer that request allows: one question declaring the intent, the plan as its `detail`, the named approve label offered, and a binary single choice — at most one option besides approve, and not multi-select. A third option or a multi-select batch has answers two buttons cannot express, so the generic flow keeps it, and keeps anything else the card cannot render. "Presentation only" is therefore literal: an intent never costs the user a reachable answer, and the client — downstream of a wire boundary — leaves every request answerable.
|
||||
|
||||
Dismissal became its own model-facing outcome. `ASK_CANCELLED` previously reached the model as "the user cancelled ask_user_question", naming a tool it never called; `exit_plan_mode` now reports that the user dismissed the review to speak instead and to stay in plan mode and wait. Every other ask failure — an abort from turn cancel or provider teardown, where no user is coming — keeps its own message.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Make plan review its own pending kind (`plan-review/requested`).** Rejected as the wrong size for a presentation problem. It buys an honest response shape (approve / decline / discuss instead of an answer batch) at the cost of a third `PendingKind`, new requested/resolved frames and schemas, an api-proxy registry and respond branch, client session and baseline-replay handling, and a new three-package capability seam for a decision the question protocol already expresses. Worth revisiting only if plan review grows outcomes the answer shape cannot carry.
|
||||
|
||||
**Route the card on the question's `id` or `header` (`plan-review` / `Plan review`).** Rejected: string-sniffing a foreign package's copy across a wire boundary, which any wording change silently breaks. The intent is the declaration that makes the routing legible.
|
||||
|
||||
**Order the options and let the card read position 0 as approve.** Rejected: a positional contract at a package seam, invisible in both the type and the wire frame, and unenforceable — a producer that reorders its options would invert a user's verdict. Naming the label costs one string.
|
||||
|
||||
**Register a second composer-chain entry for the plan card.** Rejected: two entries would select over the same pending question carrier, making the surface depend on chain priority and on whether the plan package's client half is composed at all. One entry that picks its own shape cannot race itself, and the generic flow is the built-in fallback.
|
||||
|
||||
**Put the panel in `ui-plan` beside the plan chip.** Rejected: the panel's whole behavior is the question carrier's answer encoding (`PendingQuestion`), which `ui-question` owns; the intent is a question-protocol field, not plan-mode's private channel. Rendering declared intents belongs to the package that owns question rendering, as tool render intents belong to the tool renderer.
|
||||
|
||||
**Extract a shared takeover card with `ui-conversation`'s `ApprovalPanel`.** Not done: the two takeovers agree on tokens and geometry but not on content — this body is scrolling markdown, that one a headline plus a command line — and the shared shell would be two elements wide. They are kept in step by token, not by component.
|
||||
|
||||
**Give `Chat about it` its own protocol outcome.** Rejected: dismissing a request is a verb the generic flow already has (the `×` that cancels the batch). Promoting it to a labelled button is presentation; inventing a fourth wire outcome for it is not.
|
||||
|
||||
## Consequences
|
||||
|
||||
The question protocol now carries a presentation axis. Adding a second intent is a tag on the union, a producer that sets it, a schema member, and a panel — no new frame, service, or answer shape. The cost is that the question seam knows presentation exists at all, and that `ui-question` knows the word "plan"; both are the price of one entry owning every question surface.
|
||||
|
||||
The plan gate reads as a plan gate: the plan is the card's content, the verdict is two labelled buttons, and taking the turn back is a third. The generic flow is untouched for every other question, and its committed goldens did not move.
|
||||
|
||||
A deployment whose client half predates this change still shows the quiz layout — correct, answerable, and merely unstyled — because the intent is additive and the fallback is the generic flow.
|
||||
|
||||
## Testing
|
||||
|
||||
`ui-question` tests pin the narrowing (single-question batch, intent present, plan as detail, named approve label offered, binary single choice, decline absent when only approve is offered) and the panel (strip, markdown plan, accessible name, absence of pager/radio/skip/custom, approve and decline answering with the asker's labels, dismissal cancelling, one-shot latch with re-arm and message on a rejected receipt, tooltips present and absent, both locales). `user-interaction` tests pin both `BAD_INTENT` rejections and intent pass-through; `plan-mode` tests pin the declared intent against its own option list and both failure messages; the apiproxy schema test pins wire acceptance and an unknown tag's rejection.
|
||||
|
||||
The `plan-review` Web e2e lane records `/plan` entering plan mode for real, the model calling `exit_plan_mode`, the decision card taking the composer (asserting the generic flow did **not** claim the request), and the card's own Approve completing the turn — two keyless goldens, the waiting card and the approved transcript.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Agent Note:计划审阅是一次决定,不是一道题
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-plan-review-presentation-intent.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`exit_plan_mode` 通过 `ctx.userInteraction.ask()` 把写好的计划交给用户审阅,而这正是 `ask_user_question` 使用的同一个 seam。在 Web GUI 上,这导致计划审阅渲染为[ask-question Web 呈现](2026-07-29-ask-question-web-presentation.md)里的通用问题流程:一个 `1 / 1` 分页器、计划作为问题的补充说明、两个裁决作为带描述的编号单选行、一行"其他,请填写自定义答案",以及底部的 `跳过本题` / `提交`。
|
||||
|
||||
这些可交互元素对这个界面而言无一正确。审阅一份计划是对一份文档做一次决定,而做题式的界面告诉用户他正在被考试,而不是被请求批准一份工作 —— 实际反馈是"让人很困惑以为在做题"。分页控件在给只有一项的集合分页。跳过并不是该工具接受的结果(它会折叠成继续规划)。最糟的是,这个界面完全没有暗示这就是计划关口,而旁边的等待审批接管早就具备了一次决定该有的形状:一条带色条带说明正在决定什么、主体是决定的对象、右对齐的操作行。
|
||||
|
||||
## 决定
|
||||
|
||||
一个问题可以声明**呈现意图(presentation intent)**,Web 输入区把已声明的意图渲染为它自己的界面。`AskUserQuestionItem` 新增 `intent?: AskUserQuestionIntent`,一个带标签的形状,目前唯一成员是 `{ kind: 'plan-review', approve: string }`;`plan-mode` 在审阅问题上设置它,并指明 `Approve` 是表示批准的标签。
|
||||
|
||||
意图只塑造呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,`exit_plan_mode` 读到的都是同一种回答形状;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。
|
||||
|
||||
`approve` 指名肯定选项,而不依赖选项顺序,因此没有任何 UI 会从位置推断裁决。意图作出的两项断言超出类型的表达能力,`UserInteractionService.ask()` 都以 `BAD_INTENT` 在提问方一侧拒绝:`approve` 未命中该问题自身的任一选项 —— 早于任何 UI 回答一个从未被提供过的选择;以及意图落在没有 `detail` 的问题上,而 `detail` 正是它自称在审阅的东西,那会让用户去批准一件看不见的事。在协议格式(wire format)上意图是可辨识联合,因此无法识别的标签是被拒绝的帧,而不是静默退回通用渲染。
|
||||
|
||||
`ui-question` 把该意图渲染为 `PlanReviewPanel`,沿用等待审批卡片的语言:琥珀色条带写着 `Plan review`,计划是可滚动的 markdown 主体,决定行放三个操作 —— `Chat about it`、`Refuse`、`Approve`。问题文本成为卡片的无障碍名称而非标题,因为按钮已经说明了这次决定是什么。Approve 与 Refuse 用提问方自己的选项标签回答,并把提问方的描述保留为 tooltip;`Chat about it` 取消该请求,从而让输入区归位,用户直接说他想说的话即可。所有文案在既有 `question` 命名空间下双语。
|
||||
|
||||
路由住在单一输入区条目内部(由 `QuestionComposer` 选择形状),而不是第二个链式注册;`planReviewOf` 仅在卡片能够发出该请求允许的每一个答案时才接管:只有一个问题且声明了意图、以 `detail` 承载计划、提供了被指名的批准标签,且是二元单选 —— 除批准外最多一个选项,且非多选。出现第三个选项或多选批次时,其答案是两个按钮无法表达的,通用流程保留它,也保留其他任何卡片渲染不了的请求。因此"只塑造呈现"是字面意义上的:意图绝不让用户失去一个可达的答案,而位于协议边界下游的客户端让每个请求都保持可回答。
|
||||
|
||||
放弃审阅成为面向模型的独立结果。`ASK_CANCELLED` 以前传到模型的是"the user cancelled ask_user_question",指名了一个它从未调用的工具;现在 `exit_plan_mode` 报告用户放弃审阅是为了改用说话,并要求留在 plan mode 中等待。其余每一种 ask 失败 —— 轮次取消或提供方拆卸导致的中止,那里并没有用户会来 —— 保留它们自己的消息。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**让计划审阅成为自己的待处理种类(`plan-review/requested`)。** 否决:对一个呈现问题来说尺寸不对。它换来的是诚实的响应形状(approve / decline / discuss 而非一批回答),代价是第三个 `PendingKind`、新的 requested/resolved 帧与 schema、一个 api-proxy 注册表与响应分支、客户端会话与基线重放处理,以及为一个问题协议已能表达的决定新增一个三包能力 seam。只有当计划审阅长出回答形状承载不了的结果时才值得重新考虑。
|
||||
|
||||
**按问题的 `id` 或 `header`(`plan-review` / `Plan review`)路由卡片。** 否决:这是跨协议边界嗅探另一个包的文案字符串,任何措辞改动都会静默破坏它。意图才是让路由可读的那个声明。
|
||||
|
||||
**约定选项顺序,让卡片把第 0 个位置读作批准。** 否决:这是包边界上的位置约定,在类型和协议帧里都看不见,也无法强制 —— 生产方一旦重排选项,就会颠倒用户的裁决。指名标签只花一个字符串。
|
||||
|
||||
**为计划卡片注册第二个输入区链条目。** 否决:两个条目会对同一个待回答问题载体做选择,使界面取决于链优先级、以及计划包的客户端半边是否被组合。一个自己挑形状的条目不会和自己抢,而通用流程正是内建的回退。
|
||||
|
||||
**把面板放在 `ui-plan` 里、紧挨计划状态标签。** 否决:面板的全部行为就是问题载体的回答编码(`PendingQuestion`),那是 `ui-question` 拥有的;意图是问题协议的字段,不是 plan-mode 的私有通道。渲染已声明的意图属于拥有问题渲染的那个包,正如工具渲染意图属于工具渲染方。
|
||||
|
||||
**与 `ui-conversation` 的 `ApprovalPanel` 抽出共享的接管卡片。** 未做:两个接管在 token 和几何上一致,但内容不一致 —— 这边的主体是可滚动 markdown,那边是一行标题加一行命令 —— 共享外壳只会剩两个元素宽。它们靠 token 保持一致,而不是靠组件。
|
||||
|
||||
**给 `Chat about it` 自己的协议结果。** 否决:放弃一个请求是通用流程已有的动词(取消整批的 `×`)。把它提升为带标签的按钮属于呈现;为它发明第四种协议结果不属于。
|
||||
|
||||
## 结果
|
||||
|
||||
问题协议从此带有一个呈现轴。新增第二个意图 = 联合上的一个标签、一个设置它的生产方、一个 schema 成员、一个面板 —— 不需要新的帧、服务或回答形状。代价是问题 seam 从此知道"呈现"这件事存在,且 `ui-question` 知道"plan"这个词;两者都是由单一条目拥有全部问题界面所要付的价钱。
|
||||
|
||||
计划关口读起来就像计划关口:计划是卡片的内容,裁决是两个带标签的按钮,把轮次拿回来是第三个。通用流程对其他每个问题都未受影响,其已提交的 golden 也没有变动。
|
||||
|
||||
客户端半边早于本次改动的部署仍然显示做题式布局 —— 正确、可回答、只是没有专门样式 —— 因为意图是增量的,而回退就是通用流程。
|
||||
|
||||
## 测试
|
||||
|
||||
`ui-question` 测试钉住收窄(单问题批、意图存在、计划作为 detail、被指名的批准标签确实被提供、二元单选、只提供批准时 decline 缺席)与面板(条带、markdown 计划、无障碍名称、无分页/单选/跳过/自定义、批准与拒绝用提问方的标签回答、放弃触发取消、一次性闭锁在回执被拒时重新武装并给出消息、tooltip 有与无、两种语言)。`user-interaction` 测试钉住两种 `BAD_INTENT` 拒绝与意图透传;`plan-mode` 测试钉住已声明的意图与其自身选项列表的一致、以及两条失败消息;apiproxy schema 测试钉住协议接受与未知标签的拒绝。
|
||||
|
||||
`plan-review` Web e2e 通道录制了 `/plan` 真实进入 plan mode、模型调用 `exit_plan_mode`、决定卡片接管输入区(并断言通用流程**没有**接管该请求)、以及卡片自身的 Approve 完成该轮 —— 两份无密钥 golden:等待中的卡片与批准后的会话记录。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md
|
||||
2026-07-30-web-composer-stats-and-input-polish.md: 0d90b8c1d2e283f2bcca7d9e82ac461d9fa4eb7e
|
||||
2026-07-30-web-composer-stats-and-input-polish.zh.md: db47250852724e62337948aa516effb42f19066c
|
||||
2026-07-30-web-composer-stats-and-input-polish.md: 78f286cb0edf58d0212492024b8706ffd432ee70
|
||||
2026-07-30-web-composer-stats-and-input-polish.zh.md: eeba56d9f3c2ef10222e32b0809e099f60181ac8
|
||||
|
||||
@@ -10,19 +10,19 @@ The web composer footer showed a single joined stats string (cache/tokens/turns/
|
||||
|
||||
## Decision
|
||||
|
||||
**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 8px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal and todo share one 752px tip-fill column.**
|
||||
**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 6px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal, todo, and queue share one 752px tip-fill column.**
|
||||
|
||||
- `'conversation.composer.dock'` entries reach the page as the `ComposerBarOwnerProps.footer` slot, rendered under the card inside the bar's `.root`, so the stats line and the card share one width constraint. `StatsLine` derives everything client-side from the snapshot: turns/steps, LLM wall time from assistant `timing` (`completedTime - stepStartTime`), tool wall time from tool-result `time - callTime` pairs, prompt/output token split with cache-read folded into input, and cache-hit percentage. Groups render pipe-separated and drop out whole when empty; `formatTokens` (517 / 12.2K / 1.2M) and `formatDuration` (45.2s / 2m42s) are exported for tests. Durations cover only in-window nodes — the README owns that limitation.
|
||||
- `.composerStack` carries `gap: 8px` and entries carry no outer margins (QueueDock's margin removed), so a dock entry that renders null costs nothing. GoalBar is the one deliberate exception: `margin: 0 auto -10px` cancels the gap and tucks its square bottom edge 2px under the card.
|
||||
- `.composerStack` carries the Figma composition matrix's 6px gap. Goal and Todo remain standalone cards; the terminal Queue entry subtracts that gap plus the named 5px layout overlap so the later composer card paints over only the queue edge. The [composer context stack decision](../bug-fix/2026-07-30-composer-context-stack-order.md) owns the order and overlap contract.
|
||||
- The sticky seat's background is a `linear-gradient` from `color-mix(bg-base 0%, transparent)` at 0px to solid `bg-base` at 36px — pixel stops, not the figma export's percentage, so a growing draft widens only the solid region; `color-mix` keeps both themes fading from their own base.
|
||||
- A `useCallback` ref on the seat attaches a ResizeObserver that publishes `--dsh-composer-height` on the scroll body; ChatView's back-to-bottom slot computes `bottom` from it (152px first-paint fallback) instead of the prior hardcoded 168px.
|
||||
- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal and todo strips both use the 44px-gutter / 752px-cap column with the todo `tip` fill and l1 border; the todo header is compacted (13/20 type, 8+8 padding) so its collapsed height equals the goal strip's 38px.
|
||||
- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal, Todo, and Queue panels use the 44px-gutter / 752px-cap column with `tip` fill and l1 border; the standalone Goal and collapsed Todo cards are 36px and 44px tall.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Percentage gradient stops (the figma export's 24%).** Rejected: the stop scales with seat height, so a tall draft stretches the fade band over most of the transcript; the fixed 36px band equals the design's 24% at the resting ~150px composer and stays constant as the composer grows.
|
||||
|
||||
**A skeleton-owned dock column with a generic "bottommost entry tucks" contract.** Built and backed out in review: a `.inputDock` wrapper owning width/rhythm plus `--dsh-dock-tuck-*` vars on `:last-child` would retarget the tuck automatically on reorder, but it rewrote every entry and the GoalBar DOM ahead of a pending merge. Per-entry CSS with GoalBar owning its own tuck was chosen; the generic column remains available if dock entries multiply.
|
||||
**A generic "bottommost entry tucks" contract.** Rejected because Goal and Todo are independent cards even when either is the last visible dock entry. Queue owns the one intentional composer overlap, while the stack owns its shared gap and overlap values.
|
||||
|
||||
**Backend-supplied duration fields for the stats line.** Unnecessary: assistant `timing` and tool call/result pairs already reach the snapshot, so wall times fold client-side with no new session event or host projection.
|
||||
|
||||
@@ -30,4 +30,4 @@ The web composer footer showed a single joined stats string (cache/tokens/turns/
|
||||
|
||||
## Consequences
|
||||
|
||||
The stats row now reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The one-gap stack rhythm makes dock spacing composition-independent, but GoalBar's tuck is positional: it must stay the bottommost dock entry (`order: 1`) or its negative margin tucks it under the wrong neighbor. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance.
|
||||
The stats row reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The stack's fixed order keeps standalone context cards independent and makes Queue the only panel joined to the composer; a future dock entry must choose its order relative to those roles. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance.
|
||||
|
||||
@@ -10,19 +10,19 @@ Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串
|
||||
|
||||
## Decision
|
||||
|
||||
**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 8px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal 与 todo 共用一条 752px 的 tip 填充列。**
|
||||
**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 6px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal、todo 与 queue 共用一条 752px 的 tip 填充列。**
|
||||
|
||||
- `'conversation.composer.dock'` 条目以 `ComposerBarOwnerProps.footer` 席位到达页面,渲染在卡片下方、bar 的 `.root` 之内,统计行与卡片因此共享同一宽度约束。`StatsLine` 全部在客户端从快照推导:turns/steps、由 assistant `timing`(`completedTime - stepStartTime`)折算的 LLM 墙钟时间、由 tool-result 的 `time - callTime` 配对折算的工具墙钟时间、把 cache-read 并入输入侧的提示/输出 token 拆分,以及缓存命中率。各组以竖线分隔、无数据时整组消失;`formatTokens`(517 / 12.2K / 1.2M)与 `formatDuration`(45.2s / 2m42s)导出供测试。耗时只覆盖窗口内节点——该限制由 README 记录。
|
||||
- `.composerStack` 携带 `gap: 8px`,条目不带外边距(QueueDock 的 margin 已删除),渲染为 null 的 dock 条目零成本。GoalBar 是唯一的刻意例外:`margin: 0 auto -10px` 抵消 gap,把方形下缘塞进卡片下方 2px。
|
||||
- `.composerStack` 采用 Figma 组合矩阵中的 6px 间距。Goal 与 Todo 保持为独立卡片;末端的 Queue 条目减去这段间距及具名的 5px 布局重叠量,使后渲染的 composer 卡片只覆盖 Queue 边缘。[composer 上下文堆栈决策](../bug-fix/2026-07-30-composer-context-stack-order.md) 规定顺序与重叠契约。
|
||||
- sticky 座位的背景是从 0px 处的 `color-mix(bg-base 0%, transparent)` 到 36px 处纯色 `bg-base` 的 `linear-gradient`——像素节点而非 figma 导出的百分比,草稿长高只扩大纯色区域;`color-mix` 让两个主题都从各自的底色淡出。
|
||||
- 座位上的 `useCallback` ref 挂 ResizeObserver,把 `--dsh-composer-height` 发布到滚动体上;ChatView 的回到底部席位据此计算 `bottom`(首帧回退 152px),替换先前硬编码的 168px。
|
||||
- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。goal 与 todo 条统一使用 44px 边距/752px 上限的列、todo 的 `tip` 填充与 l1 边框;todo 表头紧凑化(13/20 字号、8+8 内边距),折叠高度与 goal 条的 38px 对齐。
|
||||
- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。Goal、Todo 与 Queue 面板统一使用 44px 边距/752px 上限的列,并采用 `tip` 填充和 l1 边框;独立的 Goal 卡片与折叠后的 Todo 卡片高度分别为 36px 和 44px。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**百分比渐变节点(figma 导出的 24%)。** 否决:节点随座位高度缩放,长草稿会把过渡带拉伸到消息流的大半;固定 36px 过渡带等于设计稿在静息 ~150px 编辑器下的 24%,且随编辑器长高保持恒定。
|
||||
|
||||
**骨架拥有的 dock 列加通用「最底条目贴卡」契约。** 实现后在评审中撤回:由 `.inputDock` 包装层拥有宽度/节奏、在 `:last-child` 上发布 `--dsh-dock-tuck-*` 变量,重排时贴卡会自动换人,但它在一次待合并前重写了每个条目和 GoalBar 的 DOM。最终选择逐条目 CSS、GoalBar 自持贴卡;dock 条目增多时通用列方案仍然可用。
|
||||
**通用「最底条目贴卡」契约。** 不予采纳,因为 Goal 和 Todo 即使成为最后一个可见 dock 条目,也仍是独立卡片。Queue 拥有唯一一处有意的 composer 重叠,而 stack 拥有共享的间距和重叠量。
|
||||
|
||||
**由后端为统计行提供耗时字段。** 不必要:assistant `timing` 与工具 call/result 配对已经到达快照,墙钟时间可在客户端折算,无需新的会话事件或 host 投影。
|
||||
|
||||
@@ -30,4 +30,4 @@ Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串
|
||||
|
||||
## Consequences
|
||||
|
||||
统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。单 gap 的 stack 节奏使 dock 间距与组合无关,但 GoalBar 的贴卡是位置性的:它必须保持为最底的 dock 条目(`order: 1`),否则其负边距会塞到错误的邻居下面。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。
|
||||
统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。stack 的固定顺序使独立的上下文卡片彼此分离,并让 Queue 成为唯一与 composer 相接的面板;未来新增 dock 条目时,必须选择自己相对于这些角色的顺序。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md
|
||||
2026-07-30-web-context-injection-disclosure.md: 84c3259f3f226e501a671cc55cacf7d7d96f61fb
|
||||
2026-07-30-web-context-injection-disclosure.zh.md: 4d77e06e27badb02fb73ca2ea2a739b33c5804de
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Web context injection disclosure
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-web-context-injection-disclosure.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web conversation rendered every logged non-user message through the generic `JsonBlock`. That presentation used a textual triangle, compact label typography, a bordered JSON panel, and unrelated spacing, so context injection did not match the Tool calls disclosure shown in the product design. Restyling the generic primitive would also change unknown events and attachment fallbacks.
|
||||
|
||||
## Decision
|
||||
|
||||
`MessageItem` routes context nodes to `ContextInjectionRow`. The row starts collapsed, names the presentation `上下文注入`, uses the existing browse glyph, and exposes the whole 24px header as one pointer and keyboard disclosure target. Its expanded body begins 4px below the header at the shared 22px content indent and renders the design's 141px scrollport with 8px radius, code-block background, 11/16 code text, and no border.
|
||||
|
||||
`ContextInjectionRow` serializes both logged `content` and `source` into one inline JSON value, preserving provenance alongside model-visible material. The display remains bounded by the existing 20,000-character truncation policy. It changes no session event, runtime fold, or context-producing plugin.
|
||||
|
||||
The package-internal `DisclosureRow` owns the header geometry, icon-to-chevron transition, controlled open state, and Enter/Space behavior shared by context and `ToolRow`. `ToolRow` remains the semantic owner of tool state, summaries, file links, and expanded tool bodies. Context does not enter the keyed toolview slot and gains no context-specific slot while all context sources share one presentation.
|
||||
|
||||
## Verification
|
||||
|
||||
Conversation component tests pin the collapsed default, browse glyph, whole-row pointer and keyboard toggles, inline JSON shape, truncation, and unchanged generic unknown-event rendering. The keyless assembled-Web history scenario injects context through the real Agent API, records the collapsed row in its ARIA golden, and measures the design's icon, header, indent, gap, scrollport, padding, radius, typography, color, and overflow in Chromium.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Restyle `JsonBlock` globally.** Unknown surface events and miscellaneous content blocks use that primitive for a separate generic fallback, so a global visual change would couple unrelated presentations.
|
||||
|
||||
**Render context as a read tool.** Reusing `ToolRow` directly would add false tool semantics, state and keyed dispatch to a logged non-user message.
|
||||
|
||||
**Add a keyed context-view slot.** Every current context source uses the same title and provenance body. A registration seam has no present consumer and can be added without changing the row if distinct source-owned presentations emerge.
|
||||
|
||||
## Consequences
|
||||
|
||||
Context injection matches the Tool calls visual language without changing its durable meaning. The shared disclosure header prevents the two rows from drifting, while the dedicated context body and generic `JsonBlock` remain independently evolvable. The fixed-height body trades automatic expansion for a stable transcript rhythm and requires scrolling to inspect long injected instructions.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Web 上下文注入展开项
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-web-context-injection-disclosure.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 会话原本通过通用 `JsonBlock` 渲染每条已记录的非用户消息。这种呈现使用文本三角符号、紧凑的标签字体、有边框的 JSON 面板和另一套间距,因此上下文注入与产品设计中的 Tool calls 展开项不一致。修改通用原语的样式还会影响未知事件和附件的兜底呈现。
|
||||
|
||||
## 决策
|
||||
|
||||
`MessageItem` 将上下文节点路由至 `ContextInjectionRow`。该行初始折叠,标题为 `上下文注入`,使用现有的浏览图标,并使整个 24px 标题栏成为可通过指针和键盘操作的展开目标。其展开主体从标题栏下方 4px 处开始,与共用的 22px 内容缩进对齐,并渲染设计规定的 141px 滚动区;滚动区采用 8px 圆角、代码块背景、11/16 代码文本且无边框。
|
||||
|
||||
`ContextInjectionRow` 将已记录的 `content` 和 `source` 序列化为一个内联 JSON 值,在模型可见内容旁保留来源信息。显示内容继续受现有的 20,000 字符截断策略约束。该变更不修改任何会话事件、运行时折叠逻辑或上下文生成插件。
|
||||
|
||||
包内部的 `DisclosureRow` 负责上下文行和 `ToolRow` 共用的标题栏几何、图标至折叠箭头的过渡、受控打开状态,以及 Enter/Space 操作。`ToolRow` 仍是工具状态、摘要、文件链接和展开后工具主体的语义 owner。所有上下文来源共用同一套呈现;上下文不会进入键控 toolview slot,也不会获得上下文专用 slot。
|
||||
|
||||
## 验证
|
||||
|
||||
会话组件测试固定验证初始折叠状态、浏览图标、整行的指针与键盘切换、内联 JSON 形状、截断,以及通用未知事件渲染保持不变。无密钥的组装后 Web 历史场景通过真实 Agent API 注入上下文,在 ARIA 预期输出中记录折叠行,并在 Chromium 中测量设计规定的图标、标题栏、缩进、间隙、滚动区、内边距、圆角、字体排版、颜色和溢出行为。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**全局重新设置 `JsonBlock` 样式。** 之所以否决:未知 surface 事件和其他内容块使用该原语作为独立的通用兜底呈现,全局视觉变更会把无关的呈现耦合起来。
|
||||
|
||||
**将上下文渲染为 read 工具。** 之所以否决:直接复用 `ToolRow` 会为已记录的非用户消息添加错误的工具语义、状态和键控分发。
|
||||
|
||||
**新增键控 context-view slot。** 之所以否决:当前所有上下文来源都使用相同的标题和来源信息主体,注册 seam 暂无消费方。如果将来出现由不同来源拥有的呈现,仍可在不更改该行的情况下添加此 seam。
|
||||
|
||||
## 后果
|
||||
|
||||
上下文注入与 Tool calls 采用一致的视觉语言,同时不改变其持久保存的语义。共用的展开项标题栏可以防止这两种行逐渐偏离,而专用的上下文主体与通用 `JsonBlock` 仍可独立演进。固定高度的主体以无法随内容自动增高为代价,为 transcript(文本记录)维持稳定的排版节奏;查看较长的注入指令时必须滚动。
|
||||
@@ -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-14-typescript-program-backed-semantic-gates.md: 43a7b9b5369feb199721f5f1348c03cde66ee411
|
||||
2026-07-14-typescript-program-backed-semantic-gates.zh.md: 1ab027d723e30007e6675ae1f3589fb594d10afc
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md
|
||||
2026-07-14-typescript-program-backed-semantic-gates.md: 91639d53b660c68ae52c7ddcef6b3f594e82273e
|
||||
2026-07-14-typescript-program-backed-semantic-gates.zh.md: 2270408564f0fc90241255dfb86a65c30362d651
|
||||
|
||||
@@ -30,6 +30,8 @@ The wrapper owns config diagnostics, semantic compiler options, repository-relat
|
||||
|
||||
Context and agent-dispatch calls contribute only finite string-literal event sets. Direct `EventsService.dispatch()` calls recover the event slot through array literals, constant aliases, conditional branches, and resolved call sites of non-exported local helpers. Generic forwarding parameters are not concrete producers: attribution stays with the call sites that supply a closed event value.
|
||||
|
||||
Semantic queries run only where a branch can consume them: calls are prefiltered by the closed event-API method-name set before receiver classification, and helper call sites are indexed on demand instead of eagerly resolving every call in every package source. The demand-driven index proves locality per helper — a helper that is non-exported, sits in a real ES module, and whose every same-file reference is a direct callee has all of its calls in that file by module scoping, so only that file is indexed. Any unproven premise (an export modifier, a global script file, an aliasing or otherwise unclassifiable reference) falls back to the original full package-source index, which is the unchanged original semantics; the proof affects cost, never results. A lazy single global index was rejected because the helper-parameter path is reached on the current tree, so it would still pay nearly the whole `getResolvedSignature` sweep.
|
||||
|
||||
Every declared harness event must have a discovered producer. A missing producer fails generation as dead vocabulary or an unsupported semantic dispatch shape; listener-free extension points remain valid. `internal/dispatch` instrumentation is not treated as a subscription to every event it observes, so the matrix contains direct product listeners rather than manually asserted indirect relationships.
|
||||
|
||||
### B. Scoped-event routing generates one typed resolver map
|
||||
|
||||
@@ -30,6 +30,8 @@ Status: implemented
|
||||
|
||||
Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。
|
||||
|
||||
语义查询只在存在消费分支的位置运行:调用先经过封闭的事件 API 方法名集合预过滤,再做接收者分类;辅助函数调用点索引按需构建,而不是预先对全部包源码的每个调用求解签名。需求式索引对每个辅助函数逐一证明局部性——未导出、位于真正的 ES 模块文件中、且同文件所有引用都是直接调用位的辅助函数,按模块作用域规则其全部调用必在本文件内,此时只索引该文件。任一前提无法证明(带导出修饰符、位于全局 script 文件、存在别名化或无法归类的引用)即回退到原全部包源码索引,回退路径就是原语义本身:证明只影响开销,不影响结果。惰性单一全局索引方案被否决,因为当前源码树确实会走到辅助函数参数路径,该方案仍需支付几乎全额的 `getResolvedSignature` 扫描成本。
|
||||
|
||||
每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。
|
||||
|
||||
### B. 带作用域的事件路由生成一份强类型解析函数表
|
||||
|
||||
@@ -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/process/2026-07-22-evidence-based-larger-hosted-runners.md
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: 983d5520bd73fc3cf82c37bf0d4a9ff1c6e6f51c
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: a86dcf2c60d7b950e7557e84ef6993e712a2ce09
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: d46b8291ec05e997728da76354354f9e36bd2fb4
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: e05ad30a713258ed7bc3d8099f8d6fab3d7c0c5d
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user