diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 6c1dd7368d..f140786632 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -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 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 4028f50bf5..b0873b7aa7 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 7dc96b47c9..f3472dbfc5 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index b8d0f27e13..aa927a2e7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -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 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 99e5d0d95f..a2080024d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 89c1a578f0..1c430bf293 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -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。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bb9425fa64..900e1d01b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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: ad2aa904beddb2fe941883c3c1827702dbec9964 +2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7c8f8cb676..ad2aa904be 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -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, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: 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. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. - **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,6 +32,9 @@ 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 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 05545fc3cd..30e719ad9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -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 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 - **符号链接:为可进入性而跟随。** 用 `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,6 +32,9 @@ 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,直到下次安排线上变更。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index f3a3226c11..fd0e926ace 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md index 1ba1dd2663..773f831ec2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 02f84f3482..0602aa1ba0 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -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 的启动行为。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml new file mode 100644 index 0000000000..8fc7c8966f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md new file mode 100644 index 0000000000..e114317163 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -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 ` 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 ` 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. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md new file mode 100644 index 0000000000..e4078023cb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -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 ` 会开启一个*全新*会话且什么都不说,并被直接复现: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 `——从而收口了 `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 现在会明确报错。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml new file mode 100644 index 0000000000..a6db678efa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md new file mode 100644 index 0000000000..9c269bcaf7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md new file mode 100644 index 0000000000..47288141ea --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md @@ -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 边界。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 421933e57a..56c5d930b4 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 40934fe0e2..335d5e8080 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 13662b9359..8d2a105a21 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -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 相当,不是安全边界,也不是产品默认配置。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 14e1c4f1e3..688fe6864a 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: 7cc09652a7033c216e3835d33dc9e4a623666259 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: c5bb6e54e89ec3d1e0e7d2229a120ad158d1f9bc +2026-07-17-dedicated-full-screen-tui-front-door.md: c011a0284ea0efe59693785c038f814a866068ac +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5aea4ac0c5a3b29c098c297e514fab49caf643ff diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 7cc09652a7..c011a0284e 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -14,7 +14,7 @@ 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. @@ -24,7 +24,7 @@ The TUI rebuilds the transcript from the append-origin session events, so resume 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 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index c5bb6e54e8..5aea4ac0c5 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -14,7 +14,7 @@ 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 退出。 @@ -24,7 +24,7 @@ TUI 从追加来源的会话事件重建 transcript(文本记录),因此 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}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 401d02aeac..11674d7747 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index cc96543821..3331e36c86 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 02f9c57806..172d84b075 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -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 读取个人配置,因此其他测试启动器无需改动。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index d470b61414..ea5ef87849 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 86f62e16f5..c8cb855378 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index 06e58f8144..2a7e74d110 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -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 则覆盖搜索与交接。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml index 187c4dfe94..176211d25b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md index 110aea299a..f014da61d2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md index cc0a5eda68..f62c6efbb2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -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` 命令的职责。 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml index 4532ab2148..d8ac4d0dca 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -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 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md index 4008f23a3f..f8815c6c19 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md index daf15b54d7..831471860a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -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 > ` 前缀。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index b65218274c..b1e6e8b7d1 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 4bd52bba8d..003c0b5ac1 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 35f3525b80..ba76b87b9b 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -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 占用方。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml index 820645dc95..ed6406245e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md index 0868d8dcaf..13f5e7fec1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md index 71db5fc6fc..85f9f1244a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml new file mode 100644 index 0000000000..7123353c03 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md new file mode 100644 index 0000000000..1ef6195b6f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -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=` 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 ` 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. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md new file mode 100644 index 0000000000..d31dc5f047 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -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=`,而它会继承 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 ` 仍可恢复,只是不再出现在 `/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。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml new file mode 100644 index 0000000000..c60a7f0127 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md new file mode 100644 index 0000000000..a11807dca9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -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:` 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:` 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 ` 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:`.** 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 `; 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. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md new file mode 100644 index 0000000000..e48420ed77 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -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:` 并回车。 + +播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 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 ` 恢复该会话时是普通 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:`。** 在此否决:字面 skill 调用路径会确定性地把 skill 正文渲染进首轮,与手动命令完全一致,而不依赖模型自行选择加载该 skill。 + +**在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume ` 到达的普通 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 在其之上新增了一个由启动器驱动的自动调用,并未取代它。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml new file mode 100644 index 0000000000..1a7c941216 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md new file mode 100644 index 0000000000..d65e0e6ff0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -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-`) 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 `. `--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 `, and `--resume 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 ` 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 ` 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 ` 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. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md new file mode 100644 index 0000000000..72a0c65e6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -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-`),并由 `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 `。`--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 ` 与 `--resume 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 `,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `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 `,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。 + +`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml index c82bd65908..653b7c0554 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-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 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md index 0d90b8c1d2..78f286cb0e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md index db47250852..eeba56d9f3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md @@ -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 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml new file mode 100644 index 0000000000..b3e0c18cdd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md new file mode 100644 index 0000000000..84c3259f3f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md new file mode 100644 index 0000000000..4d77e06e27 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md @@ -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(文本记录)维持稳定的排版节奏;查看较长的注入指令时必须滚动。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 89b7165650..c059ba010c 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-20-remove-stdio-and-echo-agents.md: d804f8e3c3de886fc48c87d6378c9f06d0e3c15a -2026-07-20-remove-stdio-and-echo-agents.zh.md: d42e6d527231e1703153c02c66d9286fa1e73c95 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +2026-07-20-remove-stdio-and-echo-agents.md: b578e49f004cabf0e37385ea06a769ced5388217 +2026-07-20-remove-stdio-and-echo-agents.zh.md: 3d8eecf73f17f3b62dc4b35b0f3c1af03f6f3a86 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index d804f8e3c3..b578e49f00 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -18,7 +18,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The TUI PTY smoke includes the Code Mode overlay composition, while `examples/cordis-agent/tests/keyless-smoke.e2e.ts` provides a minimal PTY boot over the real Cordis-agent Loader tree. The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index d42e6d5272..3d8eecf73f 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -18,7 +18,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 @@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -TUI PTY 冒烟测试包含 Code Mode 覆盖层组装,而 `examples/cordis-agent/tests/keyless-smoke.e2e.ts` 会基于真实 Cordis-agent Loader 目录树执行最小 PTY 启动。构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml new file mode 100644 index 0000000000..b100535da6 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +2026-07-29-shared-base-config-overlays.md: ee642cbc786bef708791fb58e655c5a3f0e9c4e7 +2026-07-29-shared-base-config-overlays.zh.md: b7fc9c6b121b8d0eb94d734af6bda6df45e25b1d diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md new file mode 100644 index 0000000000..ee642cbc78 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -0,0 +1,53 @@ +# Agent Note: One shared base config with per-surface overlays + +Status: implemented + +English | [中文](2026-07-29-shared-base-config-overlays.zh.md) + +## Problem + +`dsh` shipped two full config trees that were 43 rows the same. `apps/cli/cordis.yml` composed the web surface as 74 flat rows, while the TUI booted `examples/tui-agent/cordis.yml`, whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins and re-declared their configuration as its own twenty-key pass-through `Config`. + +Neither file was what its location claimed. `examples/tui-agent` was not an example: `apps/cli/src/tui.ts` hardcoded it as the product's default config, and it owned the TUI PTY smoke, the eight terminal snapshot scenarios, and the PTY harness the `cordis-agent` leaf imported. `dsh-tui-demo` was not a demo either — it was the application, mounted by the shipped binary from `packages/examples/`. + +The duplication was the load-bearing problem. Of the 43 shared rows, 38 were byte-identical and 5 differed for a defensible per-surface reason, so every capability change had to be made twice and could silently drift. The bundle also inverted a default: `composeTuiApp` read `config.goals ?? {}`, so the shipped TUI mounted goals, `tool-goal`, `goal-session`, and `/goal` although no config key requested them. + +## Decision + +One shared base, one overlay per surface, composed as sibling patch lists. + +`apps/cli/config/base.cordis.yml` holds the 43 rows both surfaces mount. `apps/cli/config/tui.cordis.yml` and `apps/cli/config/web.cordis.yml` are **patch lists**, not trees: each states the handful of rows whose value is surface-specific and inserts its own rows. The launcher includes the base once and applies every overlay as a sibling patch list at **one** include level, because include patches never cross an include boundary — stacking overlays as nested includes would silently stop reaching base rows. + +Precedence is list order, last write winning per row: base, then the surface overlay, then either a `--config` overlay or the personal `~/.dsh/config.yaml`, then the launcher's own flag and profile patches. + +`--config ` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. + +A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records. + +`examples/tui-agent`, `examples/cordis-agent`, `examples/code-mode`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and the supported Code Mode demo remains the ACP overlay at `examples/acp-agent/code-mode.cordis.yml`. + +## Alternatives considered + +**Leave both trees flat and duplicated.** Rejected: 43 rows maintained twice is the defect, and a gate asserting they stay identical would freeze the duplication rather than remove it. + +**Nest the overlays as includes (`code-mode` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op. + +**Put the union of all rows in the base and have each overlay disable what it does not want.** Rejected: the base stops meaning "shared", and each surface carries rows it exists only to switch off. + +**Keep the per-surface rows in the base and let overlays patch them.** Adopted only for the five rows that must exist in both trees, because a patch cannot create a row. Their base entries carry the plugin name and the config both surfaces share; each overlay states the rest. + +## Consequences + +An overlay or `--config` tree that named `@deepseek-ai/dsh-tui-demo`, or patched the `tui-agent` row, no longer resolves. Overlays now patch the row that owns each key: the model route on `agent-loop`, the persona on `system-prompt`, presentation on `tui`. + +A patch whose `id` matches no row stays a no-op rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot. + +`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. Web keeps sandboxed Bash and filesystem providers plus approval, permission presets, directory picking, and browser permission UI; the overlay disables the shared local providers because patches can disable rows but cannot delete them. The TUI query index uses a unique process-local temporary database because the SQLite backend requires one writer owner. It is a disposable derived index rebuilt by each process; `/resume` lists the underlying corpus directly and does not depend on index reuse. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row. + +## Verification + +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. Code Mode remains covered by the ACP overlay and programmatic TUI snapshots rather than a separate shipped TUI application. + +All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/ui/app-boot/tests/config-reload.spec.ts`). + +Flattening surfaced three latent defects, each fixed here: the TUI captured the optional `sessionQuery` service once at construction and so could permanently disable `/resume` when it won the mount race; the shipped session-store root silently reverted to a project-local `./.sessions`; and `--config-replace` was dropped by the resume handoff. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md new file mode 100644 index 0000000000..b7fc9c6b12 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 一份共享 base 配置加各 surface 的 overlay + +Status: implemented + +[English](2026-07-29-shared-base-config-overlays.md) | 中文 + +## 问题 + +`dsh` 交付了两棵完整的配置树,其中 43 个配置项完全相同。`apps/cli/cordis.yml` 以 74 个平铺配置项组合 web surface,而 TUI 启动的是 `examples/tui-agent/cordis.yml`——其中单独一行 `@deepseek-ai/dsh-tui-demo` 挂载了十二个插件,并把它们的配置重新声明为自己那份二十个键、仅作透传的 `Config`。 + +这两份文件都名不副实。`examples/tui-agent` 并不是示例:`apps/cli/src/tui.ts` 把它硬编码为产品的默认配置;它还拥有 TUI 的 PTY 冒烟测试、八个终端快照场景,以及被 `cordis-agent` 叶节点 import 的 PTY harness。`dsh-tui-demo` 也不是 demo——它就是应用本身,由交付的二进制从 `packages/examples/` 中挂载。 + +真正决定性的问题是重复。43 个共享配置项中,38 个逐字节相同,5 个因各 surface 的正当理由而不同;因此每次能力改动都必须改两处,而且可能无声漂移。该组合包还反转了一个默认值:`composeTuiApp` 读取 `config.goals ?? {}`,于是交付的 TUI 挂载了 goals、`tool-goal`、`goal-session` 和 `/goal`——尽管没有任何配置键要求它们。 + +## 决策 + +一份共享 base,每个 surface 一份 overlay,以平级 patch 列表的形式组合。 + +`apps/cli/config/base.cordis.yml` 持有两个 surface 都会挂载的 43 个配置项。`apps/cli/config/tui.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 是 **patch 列表**,不是配置树:各自声明少数取值因 surface 而异的配置项,并 insert 自己的配置项。启动器只 include base 一次,并把每个 overlay 作为**同一** include 层级上的平级 patch 列表应用——因为 include patch 不会跨越 include 边界,把 overlay 堆叠成嵌套 include 会使其静默地无法触达 base 配置项。 + +优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay 或个人 `~/.dsh/config.yaml`,最后是启动器自身的 flag 与 profile patch。 + +`--config ` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 + +patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。 + +`examples/tui-agent`、`examples/cordis-agent`、`examples/code-mode` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/`,受支持的 Code Mode demo 则保留为 `examples/acp-agent/code-mode.cordis.yml` 中的 ACP overlay。 + +## 备选方案 + +**保留两棵平铺且重复的树。** 拒绝:43 个配置项维护两份正是缺陷本身,而用一个门禁断言二者保持一致只会固化重复,而非消除它。 + +**把 overlay 嵌套成 include(`code-mode` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。 + +**把所有配置项的并集放进 base,由各 overlay 禁用自己不需要的部分。** 拒绝:base 将不再意味着「共享」,而每个 surface 都要携带仅为将其关闭而存在的配置项。 + +**把因 surface 而异的配置项留在 base 中,由 overlay 去 patch。** 仅对必须同时存在于两棵树中的那五个配置项采用,因为 patch 无法创建配置项。它们在 base 中的条目携带插件名与两个 surface 共享的配置,其余部分由各 overlay 声明。 + +## 影响 + +指名 `@deepseek-ai/dsh-tui-demo`、或 patch `tui-agent` 配置项的 overlay 与 `--config` 树将不再可解析。overlay 现在要 patch 拥有对应键的那一行:模型路由在 `agent-loop`,人设在 `system-prompt`,呈现设置在 `tui`。 + +若某个 patch 的 `id` 不匹配任何配置项,Loader 仍只告警而不报错。这是有意为之:同一份个人 overlay 会跨 surface 共用,而 `insert` 配置项按设计本就不匹配任何目标,因此仅在 `web` 下存在的配置项不能让 TUI 启动失败。 + +`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry`。Web 保留沙箱化 Bash 与文件系统提供方,以及审批、权限预设、目录选择和浏览器权限界面;覆盖层会禁用共享的本地提供方,因为补丁可以禁用条目但不能删除条目。TUI 查询索引使用进程唯一的临时数据库,因为 SQLite 后端要求单写入者所有权。该索引是每个进程重新构建的可丢弃派生数据;`/resume` 直接列出底层语料,不依赖索引复用。`AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。 + +## 验证 + +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能稳定完成且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。Code Mode 继续由 ACP overlay 与程序化 TUI 快照覆盖,而不再维护独立交付的 TUI 应用。 + +全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。 + +平铺过程暴露出三处潜伏缺陷,均在此一并修复:TUI 曾在构造时一次性捕获可选的 `sessionQuery` 服务,因此在挂载竞争中胜出时会永久禁用 `/resume`;交付的会话存储根目录曾静默退回项目本地的 `./.sessions`;`--config-replace` 曾在 resume 交接中被丢弃。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index d491394db7..9345449920 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md -2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1 -2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b +2026-07-18-tui-terminal-state-snapshots.md: b8e6f77d96fbd4d077d55fefa3652813c737a37b +2026-07-18-tui-terminal-state-snapshots.zh.md: 1b2f6b58e53d9ccd5f0935dab9e9812d2bfe3eb4 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 18c79bc2d0..b8e6f77d96 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -21,7 +21,7 @@ TUI coverage has four complementary layers: 3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI has its own `examples/tui-agent` leaf beside the Headless and ACP leaves. It owns the interactive coding backends and tools directly and loads `@deepseek-ai/dsh-tui-demo`; TUI snapshots and PTY tests live with that leaf. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. +The runnable TUI is the shipped `apps/cli` composition: the shared `base.cordis.yml` plus the `tui.cordis.yml` overlay, which owns the interactive coding backends, tools, and front door. TUI snapshots and PTY tests live in `apps/cli/tests/`. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. ### Recorded-session replay diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index d1d4a6ca85..1b2f6b58e5 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -21,7 +21,7 @@ TUI 覆盖分为四个互补层次: 3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 Headless 和 ACP 叶节点并列。它直接拥有交互式 coding 后端与工具,并加载 `@deepseek-ai/dsh-tui-demo`;TUI 快照和 PTY 测试也归属这个叶节点。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 +可运行 TUI 就是交付的 `apps/cli` 组合:共享的 `base.cordis.yml` 加 `tui.cordis.yml` overlay,后者拥有交互式 coding 后端、工具与前端入口。TUI 快照和 PTY 测试位于 `apps/cli/tests/`。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 ### 已录制会话回放 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 530296f0ff..14990e32c8 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 97be6d5d70d12f783e2c80b1bcd546cfa4582ca8 -2026-07-24-web-gui-browser-e2e-lane.zh.md: f55b4f8011cc42aa4e0f8c11d9900525a565e1a7 +2026-07-24-web-gui-browser-e2e-lane.md: fb28b7013550a853b92e50810f5bc34f2c02d2e4 +2026-07-24-web-gui-browser-e2e-lane.zh.md: b9a7d050031c3d08269a3b971cd1a84f082efba7 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 97be6d5d70..fb28b70135 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -16,7 +16,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. @@ -66,7 +66,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. -**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. **Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index f55b4f8011..b9a7d05003 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -16,7 +16,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 @@ -66,7 +66,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 -**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 **为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml index af41b392bd..9ce321434f 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md -2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847 +2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md index 06fa98b10d..3937b17502 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -8,7 +8,7 @@ English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md) The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack. -**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory. +**Where the files actually live.** The shipped Web overlay gives the json backend a relative root — `root: './.storages'` (`apps/cli/config/web.cordis.yml`) — while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory. **How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md index b7bd18ffdb..ff2f196a91 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -8,7 +8,7 @@ Status: proposed 持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。 -**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`(apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。 +**文件到底存在哪。** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'`(`apps/cli/config/web.cordis.yml`)——而共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`);`storage-json` 没有对应的全局根目录。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。 **现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index bb5f370009..7e4aacef29 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 93c36d18abd06bbd7a80c918f520b92489180395 -README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd +README.md: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215 +README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c diff --git a/apps/cli/README.md b/apps/cli/README.md index 93c36d18ab..2bc36cce62 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,19 +2,23 @@ English | [中文](README.zh.md) -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: -- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; -- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; +- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; +- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. + +`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. + + +The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 85f4624a59..0e0771658c 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,19 +2,23 @@ [English](README.md) | 中文 -`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: -- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; -- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; -- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; +- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; +- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 + +`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 + + +Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/examples/tui-agent/composition.md b/apps/cli/composition.md similarity index 50% rename from examples/tui-agent/composition.md rename to apps/cli/composition.md index 86129d3fa4..7082329d6d 100644 --- a/examples/tui-agent/composition.md +++ b/apps/cli/composition.md @@ -3,36 +3,71 @@ # TUI Agent App Composition -The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package. +The TUI surface combines the shared CLI base with its surface overlay and full-screen terminal package. ```mermaid flowchart LR - cfg["examples/tui-agent
cordis.yml"] + cfg["apps/cli/config
cordis.yml"] + plugin_tui_timer["timer
@cordisjs/plugin-timer"] + cfg --> plugin_tui_timer plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] cfg --> plugin_tui_hmr - plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_tui_llm_deepseek - plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] - cfg --> plugin_tui_llm_pi_ai - plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] - cfg --> plugin_tui_subprocess - plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_tui_bash - plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] - cfg --> plugin_tui_tui_agent - plugin_tui_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_tui_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_tui_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] + cfg --> plugin_tui_llm + plugin_tui_session["session
@deepseek-ai/dsh-session"] + cfg --> plugin_tui_session + plugin_tui_session_title["session-title
@deepseek-ai/dsh-session-title"] + cfg --> plugin_tui_session_title plugin_tui_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] cfg --> plugin_tui_session_title_llm + plugin_tui_user_interaction["user-interaction
@deepseek-ai/dsh-user-interaction"] + cfg --> plugin_tui_user_interaction + plugin_tui_agent["agent
@deepseek-ai/dsh-agent"] + cfg --> plugin_tui_agent + plugin_tui_tasks["tasks
@deepseek-ai/dsh-tasks-local"] + cfg --> plugin_tui_tasks + plugin_tui_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] + cfg --> plugin_tui_llm_retry + plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_tui_llm_pi_ai + plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] + cfg --> plugin_tui_session_persistence_jsonl + plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] + cfg --> plugin_tui_session_query_sqlite + plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess + plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_tui_bash_local + plugin_tui_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] + cfg --> plugin_tui_tool_bash + plugin_tui_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] + cfg --> plugin_tui_tool_tasks + plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_tui_fs_policy + plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_tui_tool_fs + plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_tui_tool_fs_search + plugin_tui_workspace_context["workspace-context
@deepseek-ai/dsh-workspace-context"] + cfg --> plugin_tui_workspace_context + plugin_tui_skill["skill
@deepseek-ai/dsh-skill"] + cfg --> plugin_tui_skill + plugin_tui_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] + cfg --> plugin_tui_skill_local + plugin_tui_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] + cfg --> plugin_tui_tool_skill + plugin_tui_commands["commands
@deepseek-ai/dsh-commands"] + cfg --> plugin_tui_commands + plugin_tui_goal["goal
@deepseek-ai/dsh-goal"] + cfg --> plugin_tui_goal + plugin_tui_goal_session["goal-session
@deepseek-ai/dsh-goal-session"] + cfg --> plugin_tui_goal_session + plugin_tui_command_goal["command-goal
@deepseek-ai/dsh-command-goal"] + cfg --> plugin_tui_command_goal + plugin_tui_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] + cfg --> plugin_tui_plan_mode plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_tui_token_meter - plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] - cfg --> plugin_tui_tool_result_prune plugin_tui_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_tui_compact_basic plugin_tui_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -49,37 +84,56 @@ flowchart LR cfg --> plugin_tui_workflow_workerthread plugin_tui_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_tui_tool_workflow - plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] - cfg --> plugin_tui_tool_ralph - plugin_tui_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] - cfg --> plugin_tui_plan_mode - plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_tui_fs_local - plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_tui_fs_policy - plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_tui_tool_fs - plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_tui_tool_fs_search plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] cfg --> plugin_tui_timeout_policy plugin_tui_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] cfg --> plugin_tui_spill_local plugin_tui_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] cfg --> plugin_tui_spill_policy + plugin_tui_tools["tools
@deepseek-ai/dsh-tools"] + cfg --> plugin_tui_tools + plugin_tui_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] + cfg --> plugin_tui_system_prompt + plugin_tui_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] + cfg --> plugin_tui_agent_loop + plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_tui_fs_local + plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_tui_llm_deepseek ``` | Plugin id | Package / module | | --- | --- | +| `timer` | `@cordisjs/plugin-timer` | | `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | -| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | +| `llm` | `@deepseek-ai/dsh-llm` | +| `session` | `@deepseek-ai/dsh-session` | +| `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | +| `user-interaction` | `@deepseek-ai/dsh-user-interaction` | +| `agent` | `@deepseek-ai/dsh-agent` | +| `tasks` | `@deepseek-ai/dsh-tasks-local` | +| `llm-retry` | `@deepseek-ai/dsh-llm-retry` | +| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | +| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | +| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | +| `bash-local` | `@deepseek-ai/dsh-bash-local` | +| `tool-bash` | `@deepseek-ai/dsh-tool-bash` | +| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `workspace-context` | `@deepseek-ai/dsh-workspace-context` | +| `skill` | `@deepseek-ai/dsh-skill` | +| `skill-local` | `@deepseek-ai/dsh-skill-local` | +| `tool-skill` | `@deepseek-ai/dsh-tool-skill` | +| `commands` | `@deepseek-ai/dsh-commands` | +| `goal` | `@deepseek-ai/dsh-goal` | +| `goal-session` | `@deepseek-ai/dsh-goal-session` | +| `command-goal` | `@deepseek-ai/dsh-command-goal` | +| `plan-mode` | `@deepseek-ai/dsh-plan-mode` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | @@ -88,16 +142,15 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `plan-mode` | `@deepseek-ai/dsh-plan-mode` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | -| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | | `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | | `spill-local` | `@deepseek-ai/dsh-spill-local` | | `spill-policy` | `@deepseek-ai/dsh-spill-policy` | +| `tools` | `@deepseek-ai/dsh-tools` | +| `system-prompt` | `@deepseek-ai/dsh-system-prompt` | +| `agent-loop` | `@deepseek-ai/dsh-agent-loop` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -Source config: [`examples/tui-agent/cordis.yml`](cordis.yml). +Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml). Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/tui-agent/cordis.yml b/apps/cli/config/base.cordis.yml similarity index 51% rename from examples/tui-agent/cordis.yml rename to apps/cli/config/base.cordis.yml index 2b14271c96..67c2b3b551 100644 --- a/examples/tui-agent/cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -1,24 +1,60 @@ -# Full-screen TUI coding agent with swappable model and local-bash backends. -# `dsh-tui-demo` supplies the agent spine, workspace instructions, generic -# task controls, JSONL persistence, the pi-tui front door, and `main`. -# HMR remains a leaf because it depends on Loader internals. The app bin loads -# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. +# The shared `dsh` core: every row both the TUI (`tui.cordis.yml`) and the web +# surface (`web.cordis.yml`) mount identically. Neither surface includes the +# other — each is a patch list applied over THIS file at one include level, so a +# surface overlay, a `--config` overlay, and the personal `~/.dsh/config.yaml` +# all address these rows by id. Patch lists stack in that order, last write +# winning per row. +# +# A patch replaces the targeted row's whole `config` rather than merging into +# it, so a row whose value differs per surface does NOT live here: it belongs to +# each overlay, keeping any single row down to one overlay layer plus the user's. +# Rows with surface-specific values appear below only with shared plugin identity +# and neutral defaults; each overlay restates the complete surface configuration. +# +# Row order carries no load semantics (activation is service-availability +# driven); the grouping is for readers. + +- id: timer + name: '@cordisjs/plugin-timer' - id: hmr name: '@cordisjs/plugin-hmr' config: root: ['.'] -# The native DeepSeek adapter. Shipped default: full thinking at max effort on -# every request (wire-only defaults; they never enter the request header). -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' +- id: llm + name: '@deepseek-ai/dsh-llm' + +- id: session + name: '@deepseek-ai/dsh-session' + +- id: session-title + name: '@deepseek-ai/dsh-session-title' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - thinking: enabled - reasoningEffort: max + fallbackMaxWords: 5 + fallbackMaxBytes: 40 + maxTitleBytes: 80 + +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +- id: agent + name: '@deepseek-ai/dsh-agent' + +- id: tasks + name: '@deepseek-ai/dsh-tasks-local' + +- id: llm-retry + name: '@deepseek-ai/dsh-llm-retry' - id: llm-pi-ai name: '@deepseek-ai/dsh-llm-pi-ai' @@ -31,72 +67,90 @@ apiKey: !!js process.env.ANTHROPIC_API_KEY baseURL: !!js process.env.ANTHROPIC_BASE_URL -# Local executor for the app bundle's bash tool. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js >- + (() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })() + +# TUI consumes this shared session capability. Its launcher supplies a unique +# process-local path; non-TUI surfaces disable the row in their overlay. +- id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db' + - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' -- id: bash +- id: bash-local name: '@deepseek-ai/dsh-bash-local' config: timeoutMs: 60000 -# The app bundle pre-creates the TUI's `main` agent. -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' config: - provider: deepseek - model: deepseek-v4-pro - # `dsh --resume ` provides the session id on the boot context (the ids - # live under ./.sessions); with no flag the identifier is undefined and a - # fresh session starts each run. The typeof guard tolerates a launcher that - # never provides the slot, reading undefined rather than throwing. - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - persistenceRoot: './.sessions' - # Printed on exit and listed by `/resume`; `{session}` fills the live id. - # `dsh --resume ` resumes that session, so run it from this cwd. - resumeCommand: 'dsh --resume {session}' - workspaceContext: - maxBytes: 65536 - ui: - showReasoning: true - maxToolOutputLines: 6 - # Keep the persona to identity and behavior; tool plugins own tool guidance. - # The loop resolves {{model}} from this agent's configuration. - persona: | - You are a coding agent powered by the {{model}} model. + maxBytes: 65536 - Verify your work by running the code or tests. Keep answers brief and - factual. +- id: skill + name: '@deepseek-ai/dsh-skill' -# Model-made session titles on the first-message cadence: replaces the spine's -# deterministic fallback title with a short model summary. The TUI renders the -# logged `session/title` as the banner subtitle and the terminal window title. -# Omitting provider/model inherits the main request's exact route. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +- id: commands + name: '@deepseek-ai/dsh-commands' + +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' + +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' + +- id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 60000 + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. -# Replay-aware request pressure with one service-wide context window. - id: token-meter name: '@deepseek-ai/dsh-token-meter' -- id: tool-result-prune - name: '@deepseek-ai/dsh-compact-tool-result-prune' - -# Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' -# Expose fresh-child `spawn` and completed-prefix `fork` through independent -# in-process backends. Each tool instance needs a distinct `toolName`; the registry -# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -122,9 +176,6 @@ provider: fork toolName: subagent_fork - -# The worker-thread workflow engine fans a model-written JavaScript script's -# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: @@ -133,60 +184,9 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' -# A separate fixed consumer demonstrates fresh-agent Ralph iteration without -# changing the workflow tool or same-session goal behavior. -- id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - -# Plan mode gives the TUI plugin-owned /plan [message] entry and /plan off exit -# commands; the reviewed exit rides the TUI's user-interaction provider. -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# Policy loads before the model-facing filesystem tools so writes and edits require -# an observed file. This single-session app resolves relative paths from the process cwd. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the -# local bash executor above — not ctx.fs. Capped results save the complete -# formatted list through the spill backend below (ctx.spillStore, optional). -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs -# (the search tools above declare 30s) as a deadline on exec.signal. Without -# it a declared budget is advisory and only the bash executor's own timeout -# backstop applies. - id: timeout-policy name: '@deepseek-ai/dsh-timeout-policy' -# Tool-output spill stack: a local backend that saves oversized tool text under -# a private session-scoped dir, and the tools/post-execute policy that replaces -# an over-budget plain-text result with a preview + the spill locator/retrieval -# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until -# a tool returns more than maxInlineBytes of plain text. - id: spill-local name: '@deepseek-ai/dsh-spill-local' @@ -194,3 +194,37 @@ name: '@deepseek-ai/dsh-spill-policy' config: maxInlineBytes: 50000 + +# ── rows every surface mounts, whose values each overlay states ────────────── + +# The tool registry. Presentation mode is a surface choice, so each overlay +# states it; omitting it here keeps the schema default (native). +- id: tools + name: '@deepseek-ai/dsh-tools' + +# The deployment persona is a surface choice; plan-mode and tool plugins own +# their own prompt sections. +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: '' + +# Agents created at startup. The TUI pre-creates `main`; the web surface creates +# sessions on client request, so its overlay keeps this empty. +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: [] + +# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the +# TUI states it explicitly because that value is also the session workspace. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + +# The native DeepSeek adapter; reads the key/base-url the boot's layered .env +# loading left in the environment. Thinking defaults are a surface choice. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml new file mode 100644 index 0000000000..d6099a5736 --- /dev/null +++ b/apps/cli/config/tui.cordis.yml @@ -0,0 +1,113 @@ +# `dsh` (the default surface) — the full-screen TUI, as a patch list over +# `base.cordis.yml`. The launcher includes the base and applies this file, then +# any `--config` overlay, then the personal `~/.dsh/config.yaml`, as sibling +# patch lists at ONE include level: patches never cross an include boundary, so +# stacking overlays as nested includes would silently stop reaching base rows. +# +# A patch replaces the targeted row's whole `config`, so each row below restates +# every key it owns. A patch whose `id` matches no row is skipped with a Loader +# warning, which is deliberate: one personal overlay is shared across surfaces, +# so a row that exists only under `web` must not fail the TUI's boot. +# +# The launcher owns session identity and the exit line, and provides both on the +# boot context rather than through config, so no key here — and no overlay +# replacing one — can drop `--resume`. + +# ── surface-specific values the base deliberately omits ───────────────────── + +# `main` is the agent the TUI drives. `provider`/`model` are the route `dsh +# login` rewrites and a personal overlay repoints; `cwd` anchors the session to +# the invoking directory, which is also what scopes `/resume` to this workspace. +- id: agent-loop + config: + agents: + - id: main + provider: deepseek + model: deepseek-v4-pro + cwd: !!js process.cwd() + +# Keep the persona to identity and behavior; tool plugins own tool guidance. +# The loop resolves {{model}} from this agent's configuration. +- id: system-prompt + config: + persona: | + You are a coding agent powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. + +# Shipped default: full thinking at max effort on every request (wire-only +# defaults; they never enter the request header). +- id: llm-deepseek + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + thinking: enabled + reasoningEffort: max + +# This single-session app resolves relative paths from the process cwd. +- id: fs-local + config: + cwd: !!js process.cwd() + +# The shipped TUI presents the native tool registry. +- id: tools + config: + mode: native + +# ── TUI-only rows ─────────────────────────────────────────────────────────── + +- insert: + # Relational runtime checks over the authoritative event streams; each + # companion registers the assertions its own package owns. + - id: invariants + name: '@deepseek-ai/dsh-invariants' + - id: session-invariant + name: '@deepseek-ai/dsh-session/invariant' + - id: agent-invariant + name: '@deepseek-ai/dsh-agent/invariant' + - id: scope-invariant + name: '@deepseek-ai/dsh-scope/invariant' + - id: agent-loop-invariant + name: '@deepseek-ai/dsh-agent-loop/invariant' + + - id: session-checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' + + # The derived query index behind `/resume`. The launcher provides a unique + # process-local path because this SQLite backend has one writer owner; the + # project-local fallback applies when no launcher sets the typed slot. + - id: session-reference + name: '@deepseek-ai/dsh-session-reference' + + # Compacts oversized tool results before the broader conversation compactor + # runs, preserving the model-visible result within the configured budget. + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + + # Persisted same-session goals reach the model and the slash menu here; the + # domain, driver, and `/goal` command are in the base. + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + + # A separate fixed consumer demonstrates fresh-agent Ralph iteration without + # changing the workflow tool or same-session goal behavior. + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + + # The keyboard-backed provider behind ask_user_question and the plan-mode + # review, and the front door it renders inside. + - id: tui-prompt + name: '@deepseek-ai/dsh-tui/prompt' + + # The TUI renders exactly the agent the agent-loop row bound, so it reads the + # same launcher-owned identity rather than restating one. + - id: tui + name: '@deepseek-ai/dsh-tui' + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + showReasoning: true + maxToolOutputLines: 6 + + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml new file mode 100644 index 0000000000..3d7fe06baf --- /dev/null +++ b/apps/cli/config/web.cordis.yml @@ -0,0 +1,215 @@ +# `dsh web` — the browser surface, as a patch list over `base.cordis.yml`. +# The launcher includes the base and applies this file, then any `--config` +# overlay, then AppCLIEntry's profile-json and CLI-flag patches, as sibling patch +# lists at ONE include level: patches never cross an include boundary, so +# stacking overlays as nested includes would silently stop reaching base rows. +# +# A patch replaces the targeted row's whole `config`, so each row below restates +# every key it owns. `--dev` appends the dsh-client-hmr row in code +# (AppCLIEntry). + +# ── surface-specific values the base deliberately omits ───────────────────── + +# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested. +- id: hmr + disabled: true + +# Session query is a TUI capability; Web owns its own session presentation. +- id: session-query-sqlite + disabled: true + +- id: tools + config: + # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh + # process into Code Mode while per-session tool-mode selection is being + # designed; unset keeps the schema default (native). Remove the env seam + # once the web UI owns the choice per session. + mode: !!js process.env.DSH_TOOLS_MODE + +- id: llm-deepseek + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +# The web surface replaces the unrestricted local executors with the shared +# sandbox policy. Its default preserves the previous unrestricted behavior; +# DSH_PERMISSION_MODE and the browser permission picker can confine a session. +- insert: + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' + workspaceRoot: !!js process.cwd() + + - id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + + - id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" + + - id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never + + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + +- id: bash-local + disabled: true + +- id: fs-local + disabled: true + +# ── web-only host rows, the transport layer, and the browser roster ───────── + +# `dshClient` rows are the browser roster the modules node half scans into +# window.__DSH_BOOT__; the modules row is simultaneously a host row. +- insert: + - id: session-projection + name: '@deepseek-ai/dsh-session-projection' + + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + + - id: storage + name: '@deepseek-ai/dsh-storage' + + - id: storage-json + name: '@deepseek-ai/dsh-storage-json' + config: + root: './.storages' + + - id: storage-domain + name: '@deepseek-ai/dsh-storage-domain' + config: + backend: json + + - id: workspace + name: '@deepseek-ai/dsh-workspace' + + - id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 + + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + + # The API gateway: the transport-agnostic dispatch face every client shape + # shares. provider/model are the host default routing — the profile json's + # mapping target (user config overrides these engineering defaults). + - id: directory-picker + name: '@deepseek-ai/dsh-host-directory-picker-browse' + + - id: api-gateway + name: '@deepseek-ai/dsh-host-apiproxy' + config: + provider: deepseek + model: deepseek-v4-flash + + # ── layer 2: transport/service ────────────────────────────────────────────── + + # Plain route-registration carrier. distIndex is an assembly fact, not user + # config — AppCLIEntry resolves the frontend dist and patches it in; host and + # port arrive as CLI-flag patches over these defaults. + - id: webserver + name: '@deepseek-ai/dsh-host-webserver' + config: + host: 127.0.0.1 + port: 3080 + + # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + + # Dual-face: node half scans this very tree for dshClient rows, composes + # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the + # module table the shell kernel constructs before cordis exists (§4.7 — + # adopted as a plugin entry by the kernel, never fetched). + - id: modules + name: '@deepseek-ai/dsh-client-modules' + + # Owns both ends of the web transport: node half binds the gateway to the + # webserver under /api; browser half is the fetch/SSE client. + - id: connection + name: '@deepseek-ai/dsh-client-connection' + + - id: client-runtime + name: '@deepseek-ai/dsh-client-runtime' + + - id: ui-theme + name: '@deepseek-ai/dsh-client-ui-theme' + + - id: locale + name: '@deepseek-ai/dsh-client-locale' + + - id: ui-layout + name: '@deepseek-ai/dsh-client-ui-layout' + + - id: ui-sidebar + name: '@deepseek-ai/dsh-client-ui-sidebar' + + - id: ui-settings + name: '@deepseek-ai/dsh-client-ui-settings' + + - id: ui-settings-general + name: '@deepseek-ai/dsh-client-ui-settings-general' + + - id: ui-models + name: '@deepseek-ai/dsh-client-ui-models' + + - id: ui-conversation + name: '@deepseek-ai/dsh-client-ui-conversation' + + + - id: ui-workspace + name: '@deepseek-ai/dsh-client-ui-workspace' + + # Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over + # it (ui-command), and the two reference sources (ui-skill / ui-subagent). + - id: ui-slash + name: '@deepseek-ai/dsh-client-ui-slash' + + - id: ui-command + name: '@deepseek-ai/dsh-client-ui-command' + + - id: ui-skill + name: '@deepseek-ai/dsh-client-ui-skill' + + - id: ui-subagent + name: '@deepseek-ai/dsh-client-ui-subagent' + + # Goal surface: GoalBar in the input dock over the goal session projection. + - id: ui-goal + name: '@deepseek-ai/dsh-client-ui-goal' + + # Model selection: the /model popupSelect + composer seat over session.models. + - id: ui-model + name: '@deepseek-ai/dsh-client-ui-model' + + - id: ui-permission + name: '@deepseek-ai/dsh-client-ui-permission' + + # Plan control: the composer plan seat over the plan projection + /plan channel. + - id: ui-plan + name: '@deepseek-ai/dsh-client-ui-plan' + + - id: ui-question + name: '@deepseek-ai/dsh-client-ui-question' + + - id: ui-trajectory + name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml deleted file mode 100644 index ed44f75c7f..0000000000 --- a/apps/cli/cordis.yml +++ /dev/null @@ -1,420 +0,0 @@ -# dsh web — the full web-shape composition: host runtime (layer 1), the -# transport/service layer (layer 2), and the browser plugin roster (dshClient -# rows the modules node half scans into window.__DSH_BOOT__). Row order -# carries no load semantics (activation is service-availability driven); the -# grouping below is for readers. `--dev` appends the dsh-client-hmr row in -# code (AppCLIEntry) — prod and dev differ by exactly that one row. -# AppCLIEntry patches this tree before boot: profile json + CLI flags + -# distIndex land as config patches over the rows below (yaml = engineering -# defaults, json = user config, user wins per field). - -# ── layer 1: runtime ──────────────────────────────────────────────────────── - -- id: timer - name: '@cordisjs/plugin-timer' - -- id: llm - name: '@deepseek-ai/dsh-llm' - -- id: session - name: '@deepseek-ai/dsh-session' - -# Projection registry: drives every registered domain unit over committed -# session events and serves finished values (history-tail projections block + -# session/projection frames). Without this row every domain's optional unit -# injection stays silent — no block, no frames, no titles/todos on the web. -- id: session-projection - name: '@deepseek-ai/dsh-session-projection' - -- id: session-title - name: '@deepseek-ai/dsh-session-title' - config: - fallbackMaxWords: 5 - fallbackMaxBytes: 40 - maxTitleBytes: 80 - -# Model-made titles on the first-message cadence (the web sidebar renders -# session/title). Same values as the TUI composition. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 60000 - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - persona: '' - -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh - # process into Code Mode while per-session tool-mode selection is being - # designed; unset keeps the schema default (native). Remove the env seam - # once the web UI owns the choice per session. - mode: !!js process.env.DSH_TOOLS_MODE - -# Code Mode substrate for the row above. Mounted unconditionally because -# Loader metadata is static (no conditional rows): a native-mode boot only -# registers the service — a worker thread spawns per run_code execution. -- id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' - -- id: user-interaction - name: '@deepseek-ai/dsh-user-interaction' - -- id: agent - name: '@deepseek-ai/dsh-agent' - -- id: tasks - name: '@deepseek-ai/dsh-tasks-local' - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: [] - -# The native DeepSeek adapter; reads the key/base-url the boot's layered -# .env loading (cwd then $DSH_HOME) left in the environment. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - -# Common pi-ai provider routes read credentials and endpoint overrides from the -# boot's layered environment. -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY - baseURL: !!js process.env.OPENAI_BASE_URL - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY - baseURL: !!js process.env.ANTHROPIC_BASE_URL - -# Transient-failure recovery around the loop's model calls (same policy as -# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff). -- id: llm-retry - name: '@deepseek-ai/dsh-llm-retry' - -# Session store root. AppCLIEntry resolves the engineering default to a -# global dir under the Harness home ($DSH_HOME, else ~/.dsh): sessions live -# in one place across every cwd, not a project-local ./.sessions. The -# persistenceRoot profile key (user config) still overrides this per field. -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - -- id: storage - name: '@deepseek-ai/dsh-storage' - -- id: storage-json - name: '@deepseek-ai/dsh-storage-json' - config: - root: './.storages' - -- id: storage-domain - name: '@deepseek-ai/dsh-storage-domain' - config: - backend: json - -- id: workspace - name: '@deepseek-ai/dsh-workspace' - -# Persisted projection cache: durable per-session checkpoints of every -# registered projection unit (json backend → ./.storages/session_projcache.json, -# beside workspace.json), throttled between the two mandatory points -# (turn/end + detach), serving cold listings without full-log loads. -- id: session-projection-cache - name: '@deepseek-ai/dsh-session-projection-cache' - config: - writeEveryEvents: 200 - writeIntervalMs: 5000 - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -# The sandboxed product path (the acp-agent composition): per-platform -# runner provider, the shared policy home, the confined bash executor, and -# the approval seam its escalation asks through. The web deployment default -# is danger-full-access + never (same behavior as the former bash-local -# rows); DSH_PERMISSION_MODE opts a process into a confined default, and -# per-session switches ride the /permission command's knob events. -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' - workspaceRoot: !!js process.cwd() - -- id: bash-sandbox - name: '@deepseek-ai/dsh-bash-sandbox' - -- id: approval - name: '@deepseek-ai/dsh-user-approval' - config: - policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" - -# Presets over the two knobs (requires the confining executor + approval): -# the web permission chip's table, served through the permissions projection -# and switched through /permission. -- id: permission - name: '@deepseek-ai/dsh-permission' - config: - presets: - read-only: - sandbox: read-only - approval: ask - workspace-write: - sandbox: workspace-write - approval: ask - danger-full-access: - sandbox: danger-full-access - approval: never - -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -- id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' - -# fs cwd stays the package default (process.cwd()) — the same value the -# gateway injects into session.cwd, so paths and sessions agree. The -# sandboxed backend rides the SAME policy as bash: write/edit fence by the -# effective mode, so read/write/edit stay available under every mode. -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -- id: workspace-context - name: '@deepseek-ai/dsh-workspace-context' - config: - maxBytes: 65536 - -- id: skill - name: '@deepseek-ai/dsh-skill' - -- id: skill-local - name: '@deepseek-ai/dsh-skill-local' - -- id: tool-skill - name: '@deepseek-ai/dsh-tool-skill' - -# Host command registry: the single source of truth behind command.list / -# command.execute; the web '/' menu is a pure projection of this registry. -- id: commands - name: '@deepseek-ai/dsh-commands' - -# Goal service + automatic same-session continuation + the /goal command. -# The GoalService registers the 'goal' session projection unit; the web -# GoalBar reads it through useProjection. -- id: goal - name: '@deepseek-ai/dsh-goal' - -- id: goal-session - name: '@deepseek-ai/dsh-goal-session' - -- id: command-goal - name: '@deepseek-ai/dsh-command-goal' - -# Plan mode registers /plan (the first real command on the web surface). -# Section text mirrors examples/tui-agent/cordis.yml (the reference -# deployment); plan-mode throws at load on an empty section. -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# token-meter rejects unknown config keys — keep this row bare. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -# Omitting maxInlineBytes makes the whole policy a silent no-op — always -# state it explicitly. -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 - -# The API gateway: the transport-agnostic dispatch face every client shape -# shares. provider/model are the host default routing — the profile json's -# mapping target (user config overrides these engineering defaults). -# Directory-picking package, dual-face: the node half serves the gateway's -# host.* picker RPCs, the browser half fills ui-workspace's directory-flow -# slots — one row composes the whole interaction. Swap point: mount -# '-native' instead for the host-display OS chooser. -- id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-browse' - -- id: api-gateway - name: '@deepseek-ai/dsh-host-apiproxy' - config: - provider: deepseek - model: deepseek-v4-flash - -# ── layer 2: transport/service ────────────────────────────────────────────── - -# Plain route-registration carrier. distIndex is an assembly fact, not user -# config — AppCLIEntry resolves the frontend dist and patches it in; host and -# port arrive as CLI-flag patches over these defaults. -- id: webserver - name: '@deepseek-ai/dsh-host-webserver' - config: - host: 127.0.0.1 - port: 3080 - -# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── - -# Dual-face: node half scans this very tree for dshClient rows, composes -# window.__DSH_BOOT__, serves /plugins//client.js; browser half is the -# module table the shell kernel constructs before cordis exists (§4.7 — -# adopted as a plugin entry by the kernel, never fetched). -- id: modules - name: '@deepseek-ai/dsh-client-modules' - -# Owns both ends of the web transport: node half binds the gateway to the -# webserver under /api; browser half is the fetch/SSE client. -- id: connection - name: '@deepseek-ai/dsh-client-connection' - -- id: client-runtime - name: '@deepseek-ai/dsh-client-runtime' - -- id: ui-theme - name: '@deepseek-ai/dsh-client-ui-theme' - -- id: locale - name: '@deepseek-ai/dsh-client-locale' - -- id: ui-layout - name: '@deepseek-ai/dsh-client-ui-layout' - -- id: ui-sidebar - name: '@deepseek-ai/dsh-client-ui-sidebar' - -- id: ui-settings - name: '@deepseek-ai/dsh-client-ui-settings' - -- id: ui-settings-general - name: '@deepseek-ai/dsh-client-ui-settings-general' - -- id: ui-models - name: '@deepseek-ai/dsh-client-ui-models' - -- id: ui-conversation - name: '@deepseek-ai/dsh-client-ui-conversation' - - -- id: ui-workspace - name: '@deepseek-ai/dsh-client-ui-workspace' - -# Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over -# it (ui-command), and the two reference sources (ui-skill / ui-subagent). -- id: ui-slash - name: '@deepseek-ai/dsh-client-ui-slash' - -- id: ui-command - name: '@deepseek-ai/dsh-client-ui-command' - -- id: ui-skill - name: '@deepseek-ai/dsh-client-ui-skill' - -- id: ui-subagent - name: '@deepseek-ai/dsh-client-ui-subagent' - -# Goal surface: GoalBar in the input dock over the goal session projection. -- id: ui-goal - name: '@deepseek-ai/dsh-client-ui-goal' - -# Model selection: the /model popupSelect + composer seat over session.models. -- id: ui-model - name: '@deepseek-ai/dsh-client-ui-model' - -# The /permission popup picker (hostBacked over the host /permission command). -- id: ui-permission - name: '@deepseek-ai/dsh-client-ui-permission' - -# Plan control: the composer plan seat over the plan projection + /plan channel. -- id: ui-plan - name: '@deepseek-ai/dsh-client-ui-plan' - -- id: ui-question - name: '@deepseek-ai/dsh-client-ui-question' - -- id: ui-trajectory - name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0914aaf5aa..e3d1976c52 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,17 +9,19 @@ }, "files": [ "lib/bin.js", - "cordis.yml", + "config", "src" ], "license": "BSD-3-Clause", "dependencies": { + "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", @@ -48,15 +50,17 @@ "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", @@ -66,10 +70,15 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", @@ -87,9 +96,12 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", @@ -107,6 +119,7 @@ "js-yaml": "^4.2.0" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "node-pty": "1.1.0" } } diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 668d8f7f02..1ba1c0fc1e 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,8 +1,8 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share - * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). + * for the Web/headless surface. * Everything here is what must exist before the Loader runs: layered env, - * the patch composition over the shipped cordis.yml (profile json + CLI + * the patch composition over the shipped base and surface overlay (profile json + CLI * flags + the resolved frontend dist), and the fail-loud triple after the * tree settles. */ @@ -11,13 +11,10 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' import { Context } from 'cordis' -import type { FiberState } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -90,18 +87,23 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) -/** - * Value mirror of cordis's `FiberState` const enum members the sweep needs - * (a const enum has no runtime object to import; same rationale as the - * client-side mirror in dsh-client-web). - */ -const FIBER_ACTIVE = 2 as FiberState.ACTIVE -const FIBER_PENDING = 0 as FiberState.PENDING - /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { - /** Absolute path of the shipped cordis.yml. */ + /** Absolute path of the shared base config the Loader includes. */ configPath: string + /** + * Absolute path of this surface's overlay: a patch list applied over + * {@link configPath} before this entry's own profile/flag patches. Its rows + * are also merge inputs, so a flag override preserves the overlay's other + * fields on the same row. + */ + overlayPath: string + /** + * Optional explicit overlay applied after {@link overlayPath} and before + * this entry's own profile/flag patches. When absent, the personal + * `$DSH_HOME/config.yaml` overlay is applied instead. + */ + extraOverlayPath?: string /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ dev: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ @@ -163,10 +165,8 @@ export class AppCLIEntry { } /** - * Compose the patch set from the non-yml config sources: computed - * engineering defaults (the global session root), profile json (user - * config, overriding those defaults), CLI flags, and the resolved frontend - * dist. Patches replace a row's config wholesale, so each patched row's yml + * Compose the patch set from profile json, CLI flags, and the resolved + * frontend dist. Patches replace a row's config wholesale, so each patched row's yml * static values are re-read here (bypass parse) and merged under the overrides. */ private composePatches(): void { @@ -178,12 +178,6 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 0: computed engineering defaults. The session store defaults to - // a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history - // is shared across every cwd, not a project-local ./.sessions. The profile - // (Source 1) overwrites this same field via last-write-wins in put(). - put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions')) - // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) @@ -216,60 +210,60 @@ export class AppCLIEntry { }) } - /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */ + /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */ private async bootTree(): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.loader.create({ - name: 'cordis:include', - config: { - path: pathToFileURL(resolve(this.options.configPath)).href, - ...this.patches.length > 0 ? { patches: this.patches } : {}, - }, + // One include of the shared base with every overlay as a sibling patch + // list: patches never cross an include boundary, so nesting them would + // silently stop reaching base rows. The surface overlay applies first, then + // this entry's profile-json and CLI-flag patches, which therefore win. + const patches = [ + ...loadOverlayPatches('dsh', this.options.overlayPath), + ...this.options.extraOverlayPath === undefined + ? loadPersonalPatches('dsh') ?? [] + : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ...this.patches, + ] + this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { + if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) - if (this.options.dev) { - await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) - } - this.ctx = ctx - await ctx.loader.await() + } + + /** Install the diagnostic for plugin rejections that happen after settled boot. */ + private assertBoot(): void { + installFailLoud('dsh') } /** - * Fail-loud triple: assertEntriesLoaded catches import failures, - * installFailLoud catches late apply rejections, and the all-ACTIVE sweep - * below catches PENDING fibers (cordis inject waiting has no timeout). + * Bypass parse of the base and this surface's overlay (id → row) for + * patch-merge inputs; the Loader still reads both files itself. The overlay + * wins per row, matching the order its patches are applied in, and its + * `insert` rows are indexed too because a flag may target one of them. */ - private assertBoot(): void { - installFailLoud('dsh') - assertEntriesLoaded(this.ctx, 'dsh') - const failures: string[] = [] - for (const entry of this.ctx.loader.entries()) { - if (entry.fiber === undefined || entry.disabled) continue - const state = entry.fiber.state - if (state === FIBER_ACTIVE) continue - if (state === FIBER_PENDING) { - const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined) - failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) - } else { - failures.push(`${entry.options.name}: fiber state ${String(state)}`) + private parseYmlRows(): Map { + const rows = new Map() + const files = [this.options.configPath, this.options.overlayPath] + if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath) + for (const file of files) { + for (const row of this.parseRowList(file)) { + if (typeof row.id === 'string') rows.set(row.id, row) + for (const inserted of row.insert ?? []) { + if (typeof inserted.id === 'string') rows.set(inserted.id, inserted) + } } } - if (failures.length > 0) { - throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) - } + return rows } - /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ - private parseYmlRows(): Map { - const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) - const rows = new Map() - for (const row of doc as { id?: string; config?: unknown }[]) { - if (typeof row.id === 'string') rows.set(row.id, row) - } - return rows + /** + * Parse one entry or patch list, rejecting anything that is not a top-level + * array so a malformed file fails here rather than at row lookup. + * @param file - absolute path of the config or overlay file. + * @returns the parsed top-level entries. + */ + private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] { + const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`) + return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] } /** Profile json under cwd; read-only — never created here, absent = no user config. */ diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b929dc73f2..a1f0fff4c4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,19 +2,24 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `web` is a - * real subcommand. Commander owns `--help`/`--version` and parse errors — it - * prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `meta` and + * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ +/** + * Interactive TUI: the default mode. `--config` applies an overlay over the + * shipped composition in place of the personal one, `--config-replace` boots a + * file as the whole tree instead, and `--resume ` rehydrates a session. + */ interface TuiInvocation { mode: 'tui' config?: string + configReplace?: string resume?: string } @@ -24,18 +29,35 @@ interface HeadlessInvocation { prompt: string } +/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */ +interface MetaInvocation { + mode: 'meta' +} + +/** + * Guided fresh-session entry: `dsh upgrade` seeds the first turn with the + * `dsh-upgrade` skill. It always mints a + * fresh session in the invoking directory and takes no options — `--resume`, + * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + */ +interface SkillSessionInvocation { + mode: 'upgrade' +} + /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal, * `port` a natural ≤ 65535) is the single source of both the default (the - * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad + * shipped Web overlay value stands when a flag is absent) and validity (a bad * value fails loud at boot). `port` is `Number`-coerced only because the schema * wants a number, not a string. `dev` mounts the client HMR driver; * `workspaceRoot` is the parent directory for name-created workspaces. */ interface WebInvocation { mode: 'web' + /** Overlay of loader patches applied over the shipped web composition. */ + config?: string host?: string port?: number dev: boolean @@ -45,10 +67,16 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | MetaInvocation + | SkillSessionInvocation + | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { + config?: string host?: string port?: string dev?: boolean @@ -65,6 +93,7 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', + ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -86,21 +115,34 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: interactive TUI (default), headless task, and browser UI') + .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') + // The default surface takes no positional task, so `dsh "task"` fails + // commander's arity check with no hint; these examples are where a first + // reader learns the entry points and that a one-shot task rides `-p`. + .addHelpText('after', ` +Examples: + dsh start an interactive session in this directory + dsh -p "run the tests" answer one task, print the result, and exit + dsh --resume continue a past session +`) .exitOverride() + // Stop parent options at a subcommand boundary so `web --config` belongs to + // Web while `--config ... web` remains a leaked default-surface option. + .enablePositionalOptions() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') - .action((options: { config?: string; prompt?: string; resume?: string }) => { + .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') + .option('--resume ', 'continue a past session by id') + .option('--config ', 'apply this overlay of loader patches instead of the personal one') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') + .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config or --resume') + if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config, --config-replace, or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -108,32 +150,67 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') + // The two config flags are mutually exclusive: one layers over the shipped + // tree, the other discards it, so accepting both would silently drop one. + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) - const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + // Commander parses the parent (default-surface) options on either side of a + // subcommand into `program.opts()`. For a subcommand that shares none of them, + // a leaked config/prompt/resume option is a mistyped invocation that must fail + // loud rather than silently run and drop the input. + const rejectParentOptions = (command: string): void => { + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined + || parent.prompt !== undefined || parent.resume !== undefined) { + program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`) + } + } + + // Registration order is the rendered help order, so daily use comes first + // and the harness-development surfaces (`web --dev`, `meta`) come last. + // `upgrade` is a guided fresh-session entry: it takes no options and always + // mints a fresh session, so nothing is left to carry. + program + .command('upgrade') + .description('update this dsh installation to the latest version') + .action(() => { + rejectParentOptions('upgrade') + resolved = { mode: 'upgrade' } + }) + + // Host and port name no default: the CLI passes neither through when the flag + // is absent, so the shipped Web overlay value stands and restating it here + // would duplicate a fact this file does not own. + const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') - .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'developer mode: hot-reload the browser client') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `web` shares none of them, so a - // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must - // fail loud rather than silently start the web server and drop it. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error('error: web takes none of --config, -p/--prompt, or --resume') - } + rejectParentOptions('web') resolved = resolveWeb(options) }) + program + .command('meta') + .description('work on the dsh source that runs this command, from any directory') + .action(() => { + rejectParentOptions('meta') + resolved = { mode: 'meta' } + }) + try { program.parse(argv, { from: 'user' }) } catch (error) { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 88dbece55a..6f7ae77c76 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) break } case 'headless': { @@ -40,7 +40,17 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume) + await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) + break + } + case 'meta': { + const { runMeta } = await import('./tui.ts') + await runMeta() + break + } + case 'upgrade': { + const { runSkillSession } = await import('./tui.ts') + await runSkillSession(`dsh-${invocation.mode}`) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..5fef797cc5 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,6 +1,6 @@ /** * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry - * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never + * boots the same base plus Web overlay as `dsh web` (port 0, so parallel runs never * collide), then in-process isomorphic injection (InProcessApiClient over * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire * serialization, zod, SSE framing — really runs). The printed URL opens the @@ -75,7 +75,8 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), + overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), dev: false, port: 0, }) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 0d402c1c51..f13451c04e 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,40 +1,63 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * tui-agent config (or the `--config` override) with the personal overlay + * shared base and TUI overlay, followed by either `--config` or the personal overlay * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking - * directory: sessions, relative paths, and workspace instructions resolve from - * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the - * agent's system prompt is told the path to this harness checkout so it can find - * its own source. + * directory: the session cwd, relative paths, and workspace instructions resolve + * from it, so `dsh` acts on whatever project it is launched in. Session storage + * is the exception — it lives under the Harness home so `/resume` reaches every + * workspace, and an in-place resume enters the selected session's own directory. + * `dsh meta` + * ({@link runMeta}) is the one exception — it makes this harness checkout the + * workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh + * session whose first turn auto-invokes a bundled skill. After boot, the + * agent's system prompt is told the path to this harness checkout so it can + * find its own source. * @module @deepseek-ai/dsh/tui */ -import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, boot, installFailLoud, loadEnv, + loadOverlayPatches, loadPersonalPatches, - RESUME_SESSION_ID_KEY, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' +import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' import { + INITIAL_SKILL_KEY, + MAIN_SESSION_ID_KEY, TUI_GOODBYE_MESSAGE_KEY, + type MainSessionIdentity, type TuiResumeHost, } from '@deepseek-ai/dsh-tui' const NAME = 'dsh' -// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit -// one directory under apps/cli, so the shipped default config resolves with -// the same relative hop from either artifact. -const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, and the TUI's own overlay over +// it. Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) +// sit one directory under apps/cli, so each resolves with the same hop. +const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) +const TUI_OVERLAY = fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)) + +// The `agents` entry in tui.cordis.yml the TUI drives; the launcher binds its +// session identity by this config id. +const MAIN_AGENT_ID = 'main' + +/** Per-process filename of the disposable `/resume` index. */ +const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.db` // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH @@ -42,17 +65,55 @@ const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; - the tui-agent PTY smoke drives this path end to end, personal overlay included */ + the CLI PTY smoke drives this path end to end, personal overlay included */ +/** + * Run the interactive TUI with this harness checkout as the workspace + * (`dsh meta`), whatever directory it was launched from. + */ +export async function runMeta(): Promise { + return runTui(undefined, undefined, SOURCE_ROOT) +} + +/** + * Run the interactive TUI as a guided fresh session whose first turn invokes a + * bundled skill (`dsh upgrade` → `dsh-upgrade`). + * Always mints a fresh session in the invoking directory; the skill is seeded + * only on this first launch, so a later `--resume` of the session is an ordinary + * TUI session with no re-injection. + * @param skill - the bundled skill name to auto-invoke as the first turn. + */ +export async function runSkillSession(skill: string): Promise { + return runTui(undefined, undefined, undefined, skill) +} + /** * Run the interactive TUI from the invoking directory. - * @param config - a config path to boot instead of the shipped default, or - * `undefined` for the default; already parsed from `--config`. - * @param resumeSessionId - a persisted session id to resume, or `undefined`; - * already parsed and non-empty-validated from `--resume`. It is provided on the - * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config - * reads through `!!js` to rehydrate that session. + * @param config - an overlay patch list applied over the shared base and the + * TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never + * inherits the user's route, or `undefined` to use the personal overlay; + * already parsed from `--config`. + * @param resumeSessionId - a persisted session id to resume, or `undefined` to + * mint a fresh one; already parsed and non-empty-validated from `--resume`. + * Either way the resulting identity reaches the booted app through + * {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session + * and an overlay replacing the agent row cannot drop it. + * @param workspace - a directory to make the workspace instead of the invoking + * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. + * @param initialSkill - a bundled skill to auto-invoke as a fresh session's + * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored + * on a resume, so it never re-fires; reaches the app through + * {@link INITIAL_SKILL_KEY}. + * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the + * shared base, the TUI overlay, and the personal overlay alike, or `undefined` + * to compose them; already parsed from `--config-replace`. */ -export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise { +export async function runTui( + config: string | undefined, + resumeSessionId: string | undefined, + workspace?: string, + initialSkill?: string, + configReplace?: string, +): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. @@ -66,28 +127,52 @@ export async function runTui(config: string | undefined, resumeSessionId: string // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolveDshHome()) + // Both .env layers are loaded, so switching the workspace here cannot alter + // environment precedence. The cwd IS the workspace seam: the shipped config + // resolves the session cwd and the HMR watch root from it, so one chdir moves + // both together. Sessions themselves live under the Harness home so `/resume` + // spans every workspace, and are unaffected by this chdir. + if (workspace !== undefined) process.chdir(workspace) process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` - // flag, so the resumed process rehydrates through this same intake. The host - // is offered only when Node exposes `process.execve` and knows its own entry. + // flag, so the resumed process rehydrates through this same intake. The + // selected session may belong to another workspace, so the handoff also enters + // that directory. The host is offered only when Node exposes `process.execve` + // and knows its own entry. + const resolvedConfig = config === undefined ? undefined : resolve(config) + const resolvedConfigReplace = configReplace === undefined ? undefined : resolve(configReplace) const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} - const resumeCommand = (sessionId: string): string => - `${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}` + // Resume always enters the default surface because meta rejects parent + // options, including `--resume`. The resumed session already persists its cwd. + const resumeArgs = (sessionId: string): string[] => [ + `--resume=${sessionId}`, + // Both config flags must survive the handoff: resuming into a different + // tree than the session was created in would silently change the agent. + ...resolvedConfig !== undefined ? ['--config', resolvedConfig] : [], + ...resolvedConfigReplace !== undefined ? ['--config-replace', resolvedConfigReplace] : [], + ] + // Mint the fresh id here rather than in the app bundle: the exit line names + // the session to resume, so the launcher must know it before the tree boots. + const identity: MainSessionIdentity = resumeSessionId === undefined + ? { id: SessionId(`main-session-${randomUUID()}`), resume: false } + : { id: SessionId(resumeSessionId), resume: true } + const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}` const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { async handoff(sessionId, cwd): Promise { const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) - // Rebuild argv from the parsed config plus the selected id: TUI mode's - // only arguments are `--config ` and `--resume `. const nextArgv = [ process.execPath, ...process.execArgv, entry, - `--resume=${sessionId}`, - ...config !== undefined ? ['--config', config] : [], + ...resumeArgs(sessionId), ] + // `execve` inherits the cwd, and the target session may belong to another + // workspace. Enter it BEFORE teardown commits: an unreachable directory + // (deleted, unreadable) must reject while the caller can still restore the + // terminal, and a chdir after disposal would have no owner to report to. try { process.chdir(cwd) } catch (error) { @@ -103,18 +188,55 @@ export async function runTui(config: string | undefined, resumeSessionId: string } }, } + // One include of the shared base, with every overlay applied as a sibling + // patch list: patches never cross an include boundary, so stacking these as + // nested includes would silently stop reaching base rows. Later lists win. + // + // `--config` REPLACES the personal overlay rather than layering under it: an + // explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a + // demo or test config would silently run on the user's provider and model. + // `--config-replace` additionally discards the base and the surface overlay. + const replaceTree = configReplace !== undefined + const patches = replaceTree ? [] : [ + ...loadOverlayPatches(NAME, TUI_OVERLAY), + ...resolvedConfig === undefined + ? loadPersonalPatches(NAME) ?? [] + : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), + ] + const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, - resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), - loadPersonalPatches(NAME), + resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined), + patches, (hostCtx) => { - // Inject the resume id (or undefined) so the shipped config's `!!js` - // reads it as a bare identifier; then offer the in-place handoff host. - hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId) - if (resumeSessionId !== undefined) { - hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`) - } + // The launcher owns session identity and the exit line: a config-mounted + // app bundle reads both from these slots, so no cordis.yml key can drop + // resume. + hostCtx.provide(MAIN_SESSION_ID_KEY, identity) + hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye) + // Shared-store policy is the launcher's: sessions live in one root under + // the Harness home across every cwd, so /resume sees every workspace. + // The bundle treats the slot as opaque. + // The agent-loop row reads this to bind `main`, and the tui row reads the + // same id, so a personal overlay repointing the model route cannot drop + // the session identity or desynchronise the two. + hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) + // The query database is a disposable derived index with single-process + // ownership. Keep it process-local while it indexes the shared logs. + hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, queryIndexPath) + hostCtx.effect(() => async () => { + await Promise.all([ + rm(queryIndexPath, { force: true }), + rm(`${queryIndexPath}-wal`, { force: true }), + rm(`${queryIndexPath}-shm`, { force: true }), + ]) + }, `${SESSION_QUERY_SQLITE_PATH_KEY}.cleanup`) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) + // Seed the first turn only for a fresh session, so resuming never + // re-invokes the skill. + if (initialSkill !== undefined && resumeSessionId === undefined) { + hostCtx.provide(INITIAL_SKILL_KEY, initialSkill) + } }, ) app.current = ctx diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 69e79ab5d9..6c37598e11 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,15 +1,18 @@ /** * `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the * already-parsed host/port/dev, print the URL line, wire signals. All - * composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and + * composition lives in the shared base plus Web overlay; all boot glue lives in AppCLIEntry. Host and * port are unvalidated pass-through overrides — the `dsh-host-webserver` schema * gates them at boot. */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' -const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, plus this surface's overlay over it. +const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) +const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -17,12 +20,15 @@ const LOOPBACK_HOST = '127.0.0.1' /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed - * through only when the flag was given; absent, the `cordis.yml` value stands. + * through only when the flag was given; absent, the shipped Web overlay value stands. * @param host - the bind host, or `undefined` to keep the config default. * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. + * @param config - an overlay of loader patches applied over the shipped web + * composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the + * personal overlay; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -30,9 +36,12 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, + config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: CONFIG_PATH, + configPath: BASE_CONFIG, + overlayPath: WEB_OVERLAY, + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 45830eee30..396a0e6ca5 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,17 +24,22 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - // Bare `web` carries no host/port: the shipped cordis.yml owns the default. + expect(parse(['meta'])).toEqual({ mode: 'meta' }) + // Bare `web` carries no host/port: the shipped Web overlay owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) + expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // Guided fresh-session entries carry nothing: bare mode discriminant only. + expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -47,6 +52,8 @@ describe('parseDshArgs', () => { expect(exitCode(['--resume='])).toBe(1) expect(exitCode(['-p', ''])).toBe(1) expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) expect(exitCode(['bogus-positional'])).toBe(1) @@ -55,6 +62,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) + // Same rule for each subcommand that shares no option with the default + // surface, so a leaked flag is a typo, not something to ignore. + // `meta` fixes its own config tree and always starts fresh, so every + // default-surface option is rejected. + expect(exitCode(['meta', '--resume', 's'])).toBe(1) + expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['meta', '-p', 'task'])).toBe(1) + // `upgrade` takes no options: any leaked default-surface flag is a + // mistyped invocation, not a silently-dropped input. + expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1ea7d9f0db..d5bcbfd378 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -16,7 +16,7 @@ import { describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. Skips before the bin is built. + * apps/cli/tests. Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/apps/cli/tests/fixtures/tui-scripted-llm.ts similarity index 80% rename from examples/tui-agent/tests/fixtures/tui-scripted-llm.ts rename to apps/cli/tests/fixtures/tui-scripted-llm.ts index 517fa6adab..79c4f0504a 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/apps/cli/tests/fixtures/tui-scripted-llm.ts @@ -20,6 +20,13 @@ const SKILL_BLOCK_OPEN = '' const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER' const SKILL_RECEIVED_TEXT = 'Scripted skill body received.' const TITLE_TEXT = 'scripted session title' +// The failing-bash scenario proves the terminal card reports a non-zero exit +// exactly once: the model-facing result carries the `[exit code: N]` marker, and +// the card turns it into its own `[exit N]` pill instead of showing both. +const BASH_FAILURE_PROBE = 'Run the failing scripted command.' +const BASH_FAILURE_COMMAND = 'printf "SCRIPTED_BASH_FAILED\\n"; exit 3' +const BASH_FAILURE_TEXT = 'Scripted bash failure observed.' +const BASH_FAILURE_CALL_ID = CallId('call-bash-failure') function textChunks(text: string): StreamChunk[] { return [ @@ -108,9 +115,23 @@ class ScriptedTuiAdapter extends LlmAdapter { return } - const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false - if (hasToolResult) { - for (const chunk of textChunks(FINAL_TEXT)) yield chunk + const blocks = lastMessage?.content ?? [] + if (blocks.some(block => block.type === 'tool-result')) { + const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) + for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk + return + } + if (lastText.includes(BASH_FAILURE_PROBE)) { + const bashArgs = JSON.stringify({ command: BASH_FAILURE_COMMAND, description: 'Run the failing scripted command' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: BASH_FAILURE_CALL_ID, name: 'bash', argumentsDelta: bashArgs } + yield { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: BASH_FAILURE_CALL_ID, name: 'bash', arguments: bashArgs }, + } + yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } return } diff --git a/apps/cli/tests/fixtures/tui-scripted.cordis.yml b/apps/cli/tests/fixtures/tui-scripted.cordis.yml new file mode 100644 index 0000000000..69921c7e86 --- /dev/null +++ b/apps/cli/tests/fixtures/tui-scripted.cordis.yml @@ -0,0 +1,71 @@ +# Overlay for the keyless conversational PTY test: the shipped composition with +# only the model replaced, so the terminal interaction is deterministic and +# network-free while the agent/TUI/user-question stack stays the production one. +# +# Passed as `--config`, so the launcher includes `base.cordis.yml`, applies +# `tui.cordis.yml`, then this file — all sibling patch lists at one include +# level. A patch replaces the targeted row's whole `config`, so each row below +# restates every key it owns. + +# The scripted adapter replaces the DeepSeek one: no key, no network. A patch's +# `name` is an assertion rather than a replacement, so the base row is disabled +# and the adapter inserted. Relative specifiers resolve against the INCLUDED +# file's directory (apps/cli/config), because the include moves baseUrl there. +- id: llm-deepseek + disabled: true + +- insert: + - id: scripted-llm + name: '../tests/fixtures/tui-scripted-llm.ts' + +- id: agent-loop + config: + agents: + - id: main + provider: tui-scripted + model: tui-scripted-model + # `cwd` scopes the session to this workspace, which is what `/resume` + # filters on; dropping it would hide the seeded session. + cwd: !!js process.cwd() + +- id: system-prompt + config: + persona: 'Scripted model {{model}}.' + +# The smoke's log inspection reads plain `.jsonl` under the workspace, so this +# fixture pins a project-local root instead of the launcher's shared store, and +# keeps the artifacts uncompressed like the other snapshot-facing configs. +- id: session-persistence-jsonl + config: + root: './.sessions' + compression: none + +# The derived index must sit under the same root as the logs it indexes; this +# fixture pins both to the workspace instead of the launcher's shared store. +- id: session-query-sqlite + config: + path: './.sessions/session-query.db' + +- id: plan-mode + config: + section: 'Stay in plan mode for this scripted TUI test.' + +# The scripted adapter answers the tool-less title request with a fixed string, +# so the PTY test can assert the logged title reaches the terminal window title. +- id: session-title-llm + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 10000 + +- id: tui + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + welcome: 'scripted TUI ready.' + showReasoning: true + +# HMR watches the repository; a PTY subprocess test must not start a watcher. +- id: hmr + disabled: true diff --git a/examples/tui-agent/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts similarity index 100% rename from examples/tui-agent/tests/pty-harness.ts rename to apps/cli/tests/pty-harness.ts diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/apps/cli/tests/snapshots/bash-terminal-card/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl rename to apps/cli/tests/snapshots/bash-terminal-card/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt rename to apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl rename to apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt rename to apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/apps/cli/tests/snapshots/code-mode/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode/session.jsonl rename to apps/cli/tests/snapshots/code-mode/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt rename to apps/cli/tests/snapshots/code-mode/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl rename to apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl rename to apps/cli/tests/snapshots/dynamic-workflow/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt rename to apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl rename to apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt rename to apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl b/apps/cli/tests/snapshots/parallel-file-reads/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl rename to apps/cli/tests/snapshots/parallel-file-reads/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt rename to apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt rename to apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt rename to apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl b/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl new file mode 100644 index 0000000000..e7d6e3aeec --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/session.jsonl @@ -0,0 +1,5 @@ +{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}} +{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}} +{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt b/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt new file mode 100644 index 0000000000..2d6068d48b --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/terminal.expected.txt @@ -0,0 +1,157 @@ +=== skill autocomplete === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=13 viewportRow=5 bufferRow=5 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Recorded replay: skill-invocation-policy" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +5| " dsh > /skill " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 13-13 inverse +6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. " + style 7-78 fg=bright-magenta +7-35| + + +=== loaded exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=30 bufferRow=30 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +30| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +31-35| + + +=== denied exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "Skill \"model-only-skill\" is not available for user invocation. " + style 0-61 fg=yellow +30| +31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +32| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +33-35| diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md new file mode 100644 index 0000000000..3608a840f5 --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: model-only-skill +description: Model-only assembled snapshot skill. +user-invocable: false +--- + +MODEL-ONLY BODY MUST NOT LOAD diff --git a/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md new file mode 100644 index 0000000000..fa231cd99d --- /dev/null +++ b/apps/cli/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: user-only-skill +description: User-only assembled snapshot skill. +disable-model-invocation: true +--- + +USER-ONLY BODY diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/apps/cli/tests/snapshots/todo-plan/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/todo-plan/session.jsonl rename to apps/cli/tests/snapshots/todo-plan/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/apps/cli/tests/snapshots/todo-plan/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt rename to apps/cli/tests/snapshots/todo-plan/terminal.expected.txt diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts similarity index 78% rename from examples/tui-agent/tests/tui-keyless-smoke.e2e.ts rename to apps/cli/tests/tui-keyless-smoke.e2e.ts index cd5f83e4e3..59042892e1 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -9,9 +9,9 @@ import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' -const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) +// `--config` layers an overlay over the shared base, so the default surface +// needs no config argument at all; these are the overlays under test. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -119,14 +119,20 @@ async function readLoggedRequestContext(cwd: string): Promise & { label: string }): Promise { return runTuiPtySmoke({ - tempDirPrefix: 'tui-agent-smoke-', + tempDirPrefix: 'dsh-tui-smoke-', binScript: dshBinScript, - configPath, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + // Artifact CI builds and smokes concurrently on a contended runner. + ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), ...overrides, }) } @@ -139,13 +145,14 @@ const SELECT_PRO_MODEL = [ { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const -describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { +describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { // With no configured welcome the borderless banner sweeps in left-to-right; // the detail line's session id (`main-session-`) renders only once // the sweep reaches it, so it marks a settled banner. const output = await smoke({ - label: 'tui-agent boot', + label: 'dsh boot', + configArgs: [], actions: [ { waitFor: 'main-session-', send: '/plan' }, { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' }, @@ -165,8 +172,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ - label: 'tui-agent conversation', - tempDirPrefix: 'tui-agent-conversation-', + label: 'dsh conversation', + tempDirPrefix: 'dsh-tui-conversation-', configPath: scriptedConfigPath, actions: [ ...SELECT_PRO_MODEL, @@ -224,8 +231,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // the model — proven by the scripted adapter echoing the fixture's body // marker only when it arrives. const output = await smoke({ - label: 'tui-agent skill', - tempDirPrefix: 'tui-agent-skill-', + label: 'dsh skill', + tempDirPrefix: 'dsh-tui-skill-', configPath: scriptedConfigPath, prepare: seedWorkspace({ skills: { @@ -282,10 +289,14 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('fuzzy-completes an @file path without reading or submitting the file', async () => { + it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ - label: 'tui-agent file autocomplete', - tempDirPrefix: 'tui-agent-file-autocomplete-', + label: 'dsh file autocomplete', + tempDirPrefix: 'dsh-tui-file-autocomplete-', + // Source-plane PTY coverage complements the deterministic package-level + // autocomplete tests. Artifact CI omits this timing-sensitive terminal + // rendering assertion; built boot is covered by the neighboring cases. + configArgs: [], prepare: seedWorkspace({ workspace: { 'src/terminal-special-case.ts': 'export const marker = true\n', @@ -303,19 +314,6 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { - // The overlay's only keyless composition proof: the include+patch tree, - // worker code runtime, and one-tool registry all mount before the banner. - const output = await smoke({ - label: 'tui-agent code mode', - tempDirPrefix: 'tui-agent-code-mode-', - configPath: codeModeConfigPath, - actions: [{ waitFor: 'TUI Code Mode ready.', send: '/exit\r' }], - }) - expect(output).toContain('TUI Code Mode ready.') - expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { @@ -357,8 +355,10 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => { // The whole personal-config chain in one boot: the personal .env supplies - // the variable, config.yaml patches the tui-agent entry with a `!!js` - // reference to it, and the banner renders the patched welcome verbatim. + // the variable, config.yaml patches the `tui` row — a row the SURFACE + // OVERLAY inserted, not one the base declares — with a `!!js` reference to + // it, and the banner renders the patched welcome verbatim. That proves a + // later patch list reaches a row an earlier one inserted. const output = await smoke({ label: 'dsh personal overlay', tempDirPrefix: 'dsh-personal-overlay-', @@ -368,12 +368,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { personal: { '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n', 'config.yaml': [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + '- id: workspace-context', + ' disabled: true', + '- id: tui', ' config:', - ' provider: deepseek', - ' model: deepseek-v4-flash', - ' workspaceContext: false', + " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", ' welcome: !!js process.env.DSH_PERSONAL_WELCOME', '', ].join('\n'), @@ -397,11 +396,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('must be a top-level YAML array of loader patch entries') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => { - // The flag path end to end: apps/cli parses `--resume missing-session` and - // provides the id on the boot context, the shipped config's `!!js` reads it - // as a bare identifier, and the resume fails loud — proving the printed - // `dsh --resume ` hint reaches the config resume intake with no env var. + it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => { + // The flag path end to end: apps/cli parses `--resume missing-session`, + // provides it as the launcher-owned identity on the boot context, and the + // resume fails loud — proving the printed hint reaches the app's resume + // intake with no config key and no environment variable. const output = await smoke({ label: 'dsh resume flag failure', tempDirPrefix: 'dsh-resume-flag-', @@ -412,6 +411,77 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the launcher-owned resume command on exit, naming the booted config', async () => { + // The exit line is built by apps/cli from this invocation, so it must carry + // `--config`: a hint that omitted it would resume into the default tree. + const output = await smoke({ + label: 'dsh goodbye message', + tempDirPrefix: 'dsh-goodbye-', + binScript: dshBinScript, + configPath: scriptedConfigPath, + actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => { + // Loader patches replace a targeted `config` key wholesale, so a personal + // overlay repointing the model route drops every identity key the shipped + // row declared. Launcher-owned identity makes that unreachable: agent-loop + // applies the launcher's id over whatever route survives. + const output = await smoke({ + label: 'dsh overlay keeps resume', + tempDirPrefix: 'dsh-overlay-resume-', + binScript: dshBinScript, + configArgs: [], + prepare: seedWorkspace({ + personal: { + 'config.yaml': [ + '- id: workspace-context', + ' disabled: true', + '- id: agent-loop', + ' config:', + ' agents:', + ' - id: main', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' cwd: !!js process.cwd()', + '- id: tui', + ' config:', + " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", + ' welcome: OVERLAY REPLACED THE CONFIG.', + '', + ].join('\n'), + }, + }), + actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('reports a failing bash command exactly once, as the terminal card exit pill', async () => { + // The model-facing result ends in `[exit code: 3]`, which the terminal card + // consumes into its own `[exit 3]` pill. Rendering both would report the same + // exit twice, so the marker must not survive into the card body. + const output = await smoke({ + label: 'dsh bash exit pill', + tempDirPrefix: 'dsh-bash-exit-pill-', + configPath: scriptedConfigPath, + actions: [ + ...SELECT_PRO_MODEL, + { + waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', + send: 'Run the failing scripted command.\r', + }, + { waitFor: 'Scripted bash failure observed.', send: '/exit\r' }, + ], + }) + // The command really ran: its stdout is in the card body. + expect(output).toContain('SCRIPTED_BASH_FAILED') + expect(output).toContain('[exit 3]') + expect(output).not.toContain('[exit code: 3]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts similarity index 99% rename from examples/tui-agent/tests/tui.snapshot.ts rename to apps/cli/tests/tui.snapshot.ts index 95b960e3b6..fdbc7fb27e 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/apps/cli/tests/tui.snapshot.ts @@ -54,7 +54,7 @@ interface Scenario { recorded: boolean seedWorkspace?: boolean /** - * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent + * Load the opt-in `todo_write` tool for this scenario. The shipped TUI * config omits it, so only the todo-plan scenario (the enabled-path proof) * mounts it; the rest cover the default, todo-free composition. */ diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ce43c0f31c..a13d015a7a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../packages/util/paths" }, + { + "path": "../../packages/session-query/session-query-sqlite" + }, { "path": "../../packages/client/connection" }, diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 753cdc2953..1c030b4097 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1,6 +1,6 @@ // Shared scaffold for the keyless browser e2e lane (Agent Note: // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). -// Boots the REAL web composition — the shipped apps/cli/cordis.yml through +// Boots the REAL web composition — the shipped base plus web overlay through // the vendored Loader (the same include boot AppCLIEntry drives), patched the // snapshot way — so a real chromium exercises the real HTTP/SSE wire, the // api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: @@ -9,7 +9,7 @@ // from live session memory), refresh (keyless replay that rewrites goldens). // // Composition divergences from `dsh web`, all deliberate, all via include -// patches over the SAME tree (never a second yml): temp persistenceRoot; +// patches after the shipped surface overlay: temp persistenceRoot; // workspace-context disabled (recorded fixtures must not embed this repo's // AGENTS.md); session-title-llm disabled (its fire-and-forget title call // would race the loop for the session's replay cursor); webserver pinned to @@ -28,7 +28,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' -import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import SessionStore, { @@ -60,8 +60,9 @@ export function webSnapshotMode(): WebSnapshotMode { throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) } -/** The shipped composition under test: apps/cli's config tree. */ -const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') +/** The shipped composition under test: apps/cli's shared base and web overlay. */ +const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/config/base.cordis.yml') +const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -163,7 +164,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { const toolRows = page.locator('[data-variant], [data-sample]') await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0) + + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + agent.inject(createUserMessage({ + content: [{ + type: 'text', + text: '\n' + + 'The following workspace instructions may be relevant to your work. ' + + 'Use them as guidance when applicable.\n\n' + + Array.from({ length: 24 }, (_, index) => `Instruction ${index + 1}: preserve the logged context contract.`).join('\n') + + '\n', + }], + source: { + kind: 'workspace-instructions', + baseline: true, + changes: [{ + action: 'set', + scope: '.\u0000AGENTS.md', + path: 'AGENTS.md', + digest: 'context-injection-browser-snapshot', + }], + }, + })) + await page.getByRole('button', { name: '上下文注入' }).waitFor({ timeout: 10_000 }) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -132,6 +158,58 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) + const disclosure = page.getByRole('button', { name: '上下文注入' }) + expect(await disclosure.getAttribute('aria-expanded')).toBe('false') + const collapsedIcon = disclosure.locator('svg').first() + const collapsedIconBox = await collapsedIcon.boundingBox() + expect(collapsedIconBox?.width).toBe(14) + expect(collapsedIconBox?.height).toBe(14) + + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + const body = page.locator('[data-context-injection-body]') + await body.waitFor({ timeout: 5_000 }) + const headerBox = await disclosure.boundingBox() + const bodyBox = await body.boundingBox() + if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable') + expect(headerBox.height).toBe(24) + expect(bodyBox.x - headerBox.x).toBe(22) + expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4) + expect(bodyBox.height).toBe(141) + + const style = await body.evaluate((element) => { + const computed = getComputedStyle(element) + return { + backgroundColor: computed.backgroundColor, + borderRadius: computed.borderRadius, + color: computed.color, + fontSize: computed.fontSize, + lineHeight: computed.lineHeight, + padding: [ + computed.paddingTop, + computed.paddingRight, + computed.paddingBottom, + computed.paddingLeft, + ], + scrolls: element.scrollHeight > element.clientHeight, + } + }) + expect(style).toEqual({ + backgroundColor: 'rgb(249, 250, 251)', + borderRadius: '8px', + color: 'rgb(129, 133, 140)', + fontSize: '11px', + lineHeight: '16px', + padding: ['10px', '16px', '12px', '12px'], + scrolls: true, + }) + + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') + }) + it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) // Interaction over cold-resumed history: read summaries are host-open diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index e1581ba986..efc43a272e 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -31,6 +31,10 @@ - button "在新对话中分支": - img - text: {{clock}} +- button "上下文注入": + - img + - img + - text: 上下文注入 - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index 4bbd790afa..baaaa6f3dc 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -5,6 +5,37 @@ - img - button "browse-golden" - button "Edit path" + - list: + - listitem: + - button "adopted": + - img + - text: adopted + - img + - listitem: + - button "alpha-ws": + - img + - text: alpha-ws + - img + - listitem: + - button "beta-ws": + - img + - text: beta-ws + - img + - listitem: + - button "browse-golden": + - img + - text: browse-golden + - img + - listitem: + - button "same-name": + - img + - text: same-name + - img + - listitem: + - button "workspace": + - img + - text: workspace + - img - list: - listitem: - button "alpha": @@ -19,5 +50,6 @@ - button "New folder": - img - text: New folder + - button "Show hidden files" - button "Cancel" - button "Open" diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 65c0ed1842..f5c9e8fe01 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -357,7 +357,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), `tui-demo` | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4c68290ec3..dde2d6f65b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -1140,7 +1140,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:86`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` @@ -2010,63 +2010,6 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) -## `@deepseek-ai/dsh-tui-demo` - -```ts config-catalog -/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent; a matching adapter must be registered. */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona forwarded to the system-prompt plugin. */ - persona?: string - /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ - toolOrder?: string[] - /** Tool-registry presentation config forwarded through agent-spine-demo. */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Fallback session-title limits forwarded through agent-spine-demo. */ - sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** Cross-session reference discovery and snapshot byte budgets. */ - sessionReferences?: SessionReferenceConfig - /** TUI transcript's optional first line; absent renders nothing on start. */ - welcome?: string - /** - * Shell command template the TUI prints on exit and lists under `/resume`, - * with `{session}` replaced by the live session id (forwarded to the front - * door). Set it to a command that resumes the session, e.g. - * `dsh --resume {session}`. - */ - resumeCommand?: string - /** Full-screen TUI presentation settings. */ - ui?: uiTui.TuiConfig - /** Skill registry, local-provider, and model-facing consumer config. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-spine-demo. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ - goals?: agentCore.GoalConfig | false - /** Persisted session id to resume instead of creating a fresh session. */ - resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) - ## `@deepseek-ai/dsh-typert-loader` Requires: `typert` · `loader` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 9ff53f33c3..b971e0ce51 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 36ab56dcdce1166ef69cec7834f6c17be72d89c3 -extension-cookbook.zh.md: 8c8f9486ec592fcc80f1053f54adce2e33798d4b +extension-cookbook.md: 3fed79372e284df8d2cf87a3b1ca6fac6e2131ae +extension-cookbook.zh.md: 1d422712592c7427fde785f1419633a13a55cca3 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 36ab56dcdc..3fed79372e 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui`](../../packages/ui/tui), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 8c8f9486ec..1d42271259 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 +可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui`](../../packages/ui/tui),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 ## 功能→机制映射 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index dafa342d5a..4045f7e5be 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -398,7 +398,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:148`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:157`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6be9a13333..36a4b53622 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_tui_demo --> pkg_agent - pkg_tui_demo --> pkg_agent_loop - pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_command_goal - pkg_tui_demo --> pkg_commands - pkg_tui_demo --> pkg_invariants - pkg_tui_demo --> pkg_llm - pkg_tui_demo --> pkg_session - pkg_tui_demo --> pkg_session_checkpoint_policy - pkg_tui_demo --> pkg_session_persistence_jsonl - pkg_tui_demo --> pkg_session_query - pkg_tui_demo --> pkg_session_query_sqlite - pkg_tui_demo --> pkg_session_reference - pkg_tui_demo --> pkg_tool_ask_user - pkg_tui_demo --> pkg_tools - pkg_tui_demo --> pkg_tui - pkg_tui_demo --> pkg_user_interaction - pkg_tui_demo --> pkg_workspace_context pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1168,6 +1149,5 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 14e710f5ba..df4b94e6ce 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: fd4879158d7aa1f4726043b0e519f1d2827f41ec -testing.zh.md: bd78afb4fa29e8cc505bae12701f1651f7ba7b5e +testing.md: 8c16dea5e90ff330992a2d0e38f47abc9da20b26 +testing.zh.md: f99f02e2a733a94cadeffddc2c5033242bfde59b diff --git a/docs/testing.md b/docs/testing.md index fd4879158d..8c16dea5e9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index bd78afb4fa..f99f02e2a7 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `examples/tui-agent/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c77374b9b8..176558220d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | @@ -30,7 +30,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -279,7 +279,7 @@ Unmount a current-process temporary Plugin created by cordis_mount. Waits for it Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. +Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. ## `@deepseek-ai/dsh-tool-bash-persistent` @@ -1144,7 +1144,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 402e3537a7..0044ac720e 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -llm-adapter.md: 3bf005647d3e7908f1d1553374266c1d6c12c2e6 -llm-adapter.zh.md: b967d112ed6879b11486ab7aedf64653089d93de +# pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md +llm-adapter.md: 7445688530c1ba61e5c065f9f5e49db6498da5b1 +llm-adapter.zh.md: c30200314a01f7a61d48e3f47288013c31e5aef4 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 3bf005647d..7445688530 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -133,10 +133,12 @@ The first argument lists the model names handled by the adapter. If `cordis.yml` - my-model-v1 - my-model-v2 -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: my-llm + agents: + - id: main + provider: my-llm model: my-model-v1 # References the model registered above. workspaceContext: false ``` diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index b967d112ed..c30200314a 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -133,10 +133,12 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) - my-model-v1 - my-model-v2 -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: my-llm + agents: + - id: main + provider: my-llm model: my-model-v1 # References the model registered above. workspaceContext: false ``` diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 695584a6e6..85ccd636e0 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -config.md: a884cb9c2ec31bd4b12a31cce6290df1d134cf9a -config.zh.md: 3fb9ce69e5f7cb6b92f055ef18595e5ff07d9bbf +# pnpm run verify-translation-pairing --write docs/user/guide/config.md +config.md: 00d103be990f3e5af315ab7667771b01be96ba5d +config.zh.md: a296619c3f3d68726efc4fc13703a510719fc008 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index a884cb9c2e..00d103be99 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -8,7 +8,7 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: -- [tui-agent](../../../examples/tui-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. +- [the shared `dsh` base](../../../apps/cli/config/base.cordis.yml) plus the [`tui.cordis.yml`](../../../apps/cli/config/tui.cordis.yml) overlay combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. - [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) exposes fresh sessions to programmatic ACP clients. @@ -25,12 +25,13 @@ A minimal configuration is a list of plugin entries: - id: bash name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash ``` ## Plugin entries @@ -47,6 +48,12 @@ A minimal configuration is a list of plugin entries: Plugins load in file order. Place plugins that depend on services after the applications or capability plugins that provide them. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. +## CLI overlays + +The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before Web profile and CLI-flag patches. + +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. + ## JavaScript values and environment variables The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 3fb9ce69e5..a296619c3f 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -8,7 +8,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [tui-agent](../../../examples/tui-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 +- [共享的 `dsh` base](../../../apps/cli/config/base.cordis.yml) 叠加 [`tui.cordis.yml`](../../../apps/cli/config/tui.cordis.yml) overlay,组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 - [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 向程序化 ACP(Agent Client Protocol)客户端提供全新会话。 @@ -25,12 +25,13 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 - id: bash name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash ``` ## 插件条目 @@ -47,6 +48,12 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 插件按文件中的顺序加载。依赖其他服务的插件应该排在提供这些服务的应用或能力插件之后;引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 +## CLI 覆盖层 + +TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web profile 与命令行标志补丁之前添加覆盖。 + +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 + ## JavaScript 值和环境变量 Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index e2b307201e..17056741b7 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -index.md: b698b8aeee6cebff374e20ca0f76ddc9e75213c0 -index.zh.md: 337d246baa12ccf6d7a9656d1ea3b06002554c13 +# pnpm run verify-translation-pairing --write docs/user/guide/index.md +index.md: 72d822f6377089f56abda4383f393eed30a6709a +index.zh.md: 442cda48072c3f6bf9579e880c0ab381b799966f diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index b698b8aeee..72d822f637 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -14,12 +14,17 @@ Harness implements every capability an AI agent needs—including LLM calls, too config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the interactive application -- name: '@deepseek-ai/dsh-tui-demo' +# Select the agent the interactive front door drives +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash + +# Select the interactive front door +- name: '@deepseek-ai/dsh-tui' ``` ## Who it is for diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 337d246baa..442cda4807 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -14,12 +14,17 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the interactive application -- name: '@deepseek-ai/dsh-tui-demo' +# Select the agent the interactive front door drives +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash + +# Select the interactive front door +- name: '@deepseek-ai/dsh-tui' ``` ## 适合谁 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index b3de74949b..703e6ec391 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -quickstart.md: 25ce51ee3d010d2eb800071b9697fc62857dace1 -quickstart.zh.md: e2e023670a999566e273d8893c42103dc273e7b1 +# pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md +quickstart.md: 1f01c7f4c9935c5a4772f6c702f023e691d911fc +quickstart.zh.md: 8b5d4608525a603725136e1568aef7217756293f diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 25ce51ee3d..1f01c7f4c9 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -52,7 +52,7 @@ The full-screen agent can read and write files, run commands, delegate subtasks, ## What happened -headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; tui-agent uses the interactive `@deepseek-ai/dsh-tui-demo` app. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. +headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; the interactive `dsh` surface instead composes [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) with the `tui.cordis.yml` overlay and no app bundle. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index e2e023670a..8b5d460852 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -52,7 +52,7 @@ pnpm run demo:tui ## 回头看 -headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app,tui-agent 使用交互式 `@deepseek-ai/dsh-tui-demo` app。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 +headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app;交互式 `dsh` surface 则以 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml) 叠加 `tui.cordis.yml` overlay 组合而成,不使用 app 组合包。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 ## 下一步 diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index 647d829b4f..1956f0fd5e 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/README.md -README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f -README.zh.md: c7c1bf76593661616464558e554d57340d7c03b1 +README.md: a502f34128da497586d593f64d0ce1c05f68a067 +README.zh.md: e3c111bb6a8f67899b6f434345baa3b47640b7ee diff --git a/examples/README.md b/examples/README.md index 7f12178d1b..a502f34128 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: either a `cordis.yml` tree that picks swappable backends and loads one app package, or an **overlay** — a patch list `dsh --config` applies over the shipped composition ([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) plus a surface overlay). Bundled compositions live in [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle; the `dsh` surfaces use flat config trees instead. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI, and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. ## headless-agent @@ -10,26 +10,20 @@ A non-interactive agent demo that accepts one positional task, runs one complete Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. -## tui-agent - -The interactive coding agent: DeepSeek V4, filesystem and bash tools, subagents, workflows, `todo_write`, compaction, and the full-screen TUI. It is also the home of TUI PTY and snapshot scenarios. - -Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). Run its Code Mode overlay with `pnpm run demo:code-mode`. See [tui-agent/README.md](tui-agent/README.md) for controls and composition. - ## jsonrpc-agent An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [jsonrpc-agent/README.md](jsonrpc-agent/README.md). -## cordis-agent +## web-cordis The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the current DSH process, mount model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and unmount them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. -Run the TUI with `pnpm run demo:cordis`, the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis web`, or the ACP server with `pnpm run demo:cordis acp` (all need `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. +Run the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis`, or the ACP server with `pnpm run demo:cordis acp` (both need `DEEPSEEK_API_KEY`). See [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. ## acp-agent An agent exposed as an **Agent Client Protocol (ACP)** automation server over JSON-RPC stdio, via [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo). Programmatic clients create fresh sessions, send text prompts, consume committed assistant text, answer one-shot permission requests, and cancel work. It owns the ACP keyless snapshot suite. -Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the protocol and snapshot-test contracts. +Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the protocol and snapshot-test contracts. The default `cordis.yml` composes [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local), [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), and [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval). `workspace-write` confines bash and filesystem mutations to each session workspace; a wider retry becomes a one-shot machine permission request over ACP. diff --git a/examples/README.zh.md b/examples/README.zh.md index c7c1bf7659..e3c111bb6a 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,34 +2,28 @@ [English](README.md) | 中文 -展示 harness 如何组装的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 ## headless-agent -非交互式 agent(智能体)演示:接受一个位置参数形式的任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 +非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 -## tui-agent - -交互式编码 agent:DeepSeek V4、文件系统与 bash 工具、subagent、工作流、`todo_write`、压缩(compaction)和全屏 TUI。这里也是 TUI PTY 与快照场景的归属地。 - -运行:`pnpm run demo:tui`(需要 `DEEPSEEK_API_KEY`)。使用 `pnpm run demo:code-mode` 运行其 Code Mode 覆盖。控制与组合详见 [tui-agent/README.md](tui-agent/README.md)。 - ## jsonrpc-agent -通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill(技能)和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 +通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 -## cordis-agent +## web-cordis -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一个临时插件注入的服务),并再次卸载它们。这些插件只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 -使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note(agent 决策记录)](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +使用 `pnpm run demo:cordis` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(两者均需 `DEEPSEEK_API_KEY`)。设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## acp-agent -一个通过 JSON-RPC stdio 公开、作为 **Agent Client Protocol (ACP)** 自动化服务器运行的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 +作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 -运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 +运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 -默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;请求更广泛沙箱权限的重试会通过 ACP 触发一次性的机器权限请求。 +默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml index 3b208eb9b5..dc603691e1 100644 --- a/examples/acp-agent/README.i18n.yaml +++ b/examples/acp-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/acp-agent/README.md -README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961 -README.zh.md: 84482aad8352ab38527dcf4d9e1bfefc8d496c91 +README.md: a37954d4d52900413a506f227759a0a7dd2a25d0 +README.zh.md: a38431ea2f01fe16ff39b5dd00ec23e881713f5d diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 0d63ec1f2d..a37954d4d5 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -6,7 +6,7 @@ Automation-oriented [Agent Client Protocol](https://agentclientprotocol.com) ser ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) -pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport +pnpm run demo:code-mode # same protocol with the Code Mode tool transport ``` The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK, and [`web.cordis.yml`](web.cordis.yml) adds the web seam, the local fetch provider, `web_fetch`, and a loopback HTML fixture server for the web-fetch snapshot. diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md index 84482aad83..a38431ea2f 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -6,7 +6,7 @@ ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) -pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport +pnpm run demo:code-mode # same protocol with the Code Mode tool transport ``` 该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture(测试前置数据)服务器。 diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 1192b284c0..a6070d1262 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -1,6 +1,6 @@ # Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, # `run_code`, plus its generated TypeScript SDK prompt. The app bin selects this -# overlay for `demo:code-mode acp` and snapshot recording, and selects the sibling +# overlay for `demo:code-mode` and snapshot recording, and selects the sibling # replay overlay for `DSH_SNAPSHOT=replay`. A config patch replaces the whole app # config, so unchanged base fields are restated below. - id: base diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml deleted file mode 100644 index 4cbfbb67cb..0000000000 --- a/examples/cordis-agent/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 examples/cordis-agent/README.md -README.md: 55970e932bc16d8361932daa9ea55af83ef73d33 -README.zh.md: a8ec332d8b3673d6656663eb3bfd7d37a4e328f6 diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md deleted file mode 100644 index 55970e932b..0000000000 --- a/examples/cordis-agent/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# cordis-agent - -English | [中文](README.zh.md) - -The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which lets the model inspect the current DSH process, mount in-memory temporary Plugins, and unmount them. Temporary Plugins remain active across turns but disappear on unmount, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis # TUI (default) -pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 -pnpm run demo:cordis acp # ACP server -``` - -The intended demo is staged — verify the listener link first, then let the agent extend itself: - -``` -> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). - [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the temporary listener firing, live -> Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) - [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both temporary Plugins. - [tool call] cordis_unmount({"id": "dyn-1"}) -``` - -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and mount two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. - -## End-to-end tests - -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md deleted file mode 100644 index a8ec332d8b..0000000000 --- a/examples/cordis-agent/README.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# cordis-agent - -[English](README.md) | 中文 - -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时插件,并卸载它们。临时插件可跨轮次保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他会话。`ctx.fs` 和 `ctx.web` 仅以能力提供方形式加载,供这些插件使用。设计详见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis # TUI (default) -pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 -pnpm run demo:cordis acp # ACP server -``` - -预期演示分阶段进行:先验证监听器链路,再让 agent(智能体)扩展自身: - -``` -> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). - [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the temporary listener firing, live -> Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) - [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both temporary Plugins. - [tool call] cordis_unmount({"id": "dyn-1"}) -``` - -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写插件代码所用的生成服务/事件资料。还可挂载两个协作临时插件(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 - -## 端到端测试 - -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析,以及收到 EOF 后正常退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态监听器,测试验证其带标记的控制台输出行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时插件。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 包含相关单元测试,并受逐文件 100% 覆盖率门禁约束。 diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md deleted file mode 100644 index 02d68215f5..0000000000 --- a/examples/cordis-agent/composition.md +++ /dev/null @@ -1,55 +0,0 @@ - - -# Cordis Agent App Composition - -The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins. - -```mermaid -flowchart LR - cfg["examples/cordis-agent
cordis.yml"] - plugin_cordis_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_cordis_hmr - plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_cordis_llm_deepseek - plugin_cordis_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] - cfg --> plugin_cordis_subprocess - plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_cordis_bash - plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_cordis_fs_local - plugin_cordis_web["web
@deepseek-ai/dsh-web"] - cfg --> plugin_cordis_web - plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] - cfg --> plugin_cordis_web_fetch_local - plugin_cordis_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_cordis_token_meter - plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] - cfg --> plugin_cordis_tui_agent - plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_cordis_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_cordis_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_cordis_tool_cordis["tool-cordis
@deepseek-ai/dsh-tool-cordis"] - cfg --> plugin_cordis_tool_cordis -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `web` | `@deepseek-ai/dsh-web` | -| `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | -| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | - -Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml deleted file mode 100644 index 83144b2153..0000000000 --- a/examples/cordis-agent/cordis.yml +++ /dev/null @@ -1,88 +0,0 @@ -# Self-referential TUI demo: the coding spine plus tools to inspect the live -# service/plugin/tool/temporary/API/event state, mount a model-written temporary -# Plugin, and quiescently unmount it. The app bin loads the gitignored -# root `.env` before reading the required DeepSeek key and optional base URL. -# Trust stance: the vm and context façade limit accidental global/framework -# access but are not a security boundary; temporary Plugin code reaches live capabilities -# such as `ctx.bash`. Grant this toolset like bash access. See -# ../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. - -# Development-only hot reload; production assemblies omit it. -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The DeepSeek adapter. Shipped default: full thinking at max effort on every -# request (wire-only defaults; they never enter the request header). -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - thinking: enabled - reasoningEffort: max - -# Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an -# ordinary tool whose calls make the mounted listeners observably fire. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# Filesystem service for mounted plugins (ctx.fs) — the local provider only. -# The model-facing read/write/edit tools stay unmounted on purpose: this demo -# is about the agent building its own tools over the services. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -# Web service for mounted plugins (ctx.web): the seam plus the anonymous local -# fetch provider (keyless). No search provider is loaded — ctx.web search -# calls fail loud until a deployment adds one. -- id: web - name: '@deepseek-ai/dsh-web' - -- id: web-fetch-local - name: '@deepseek-ai/dsh-web-fetch-local' - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -# The app bundle pre-creates the self-referential demo's `main` agent. -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a temporary listener, or invent a temporary tool for itself.' - persona: | - You are cordis-agent, a self-referential harness demo powered by the - {{model}} model. - - You run INSIDE a cordis plugin runtime, and your cordis_* tools operate - on that live runtime: cordis_inspect to look around (its `api` and - `events` sections document the service methods, type shapes, and events - your Plugin code can use), cordis_mount to mount an in-memory temporary - Plugin (an event listener, a brand-new tool for yourself, or a service - another temporary Plugin injects), cordis_unmount to clean one up. These - Plugins remain across turns but disappear on unmount, toolset unload, or - DSH restart and may affect other sessions in this process. In Plugin code, NEVER use Node - built-ins (require/setTimeout/fetch) — use the runtime's cordis services - via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small - single-purpose plugins, prefer plain notification events over waterfall - events unless you intend to intercept, and unmount what you no longer - need. Report results briefly. - -# The self-referential cordis toolset (loaded after the app so ctx.tools exists). -- id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/cordis-agent/package.json b/examples/cordis-agent/package.json deleted file mode 100644 index 8d5a693555..0000000000 --- a/examples/cordis-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "cordis-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: the self-referential harness — an agent that inspects and modifies its own cordis runtime" -} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts deleted file mode 100644 index 8589ab77ec..0000000000 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Context } from 'cordis' -import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' -import { cordisHarness, waitForIdle } from './harness.ts' -import { SessionId } from '@deepseek-ai/dsh-session' - -const testToolSignal = new AbortController().signal - -/** - * With-key smoke for the self-referential cordis tools: a REAL model drives - * cordis_mount/cordis_unmount against the live context the test observes. - * World-verified, not self-reported: the mounted listener must actually WRITE - * its tagged console line, the self-made tool must actually EXIST in the - * registry and appear as a real `tool/call`, the cross-mount service must - * actually LAND in the reflect store. Key-gated (see vitest.e2e.config.ts). - */ - -let ctx: Context | undefined - -afterEach(async () => { - vi.restoreAllMocks() - // Always dispose the harness, even on failure/retry/timeout: agent-loop - // teardown stops the loop, and disposing the tree unwinds every dynamic - // mount the model left behind. - await ctx?.fiber.dispose() - ctx = undefined -}) - -/** The tagged write-through lines (`[cordis:dyn-n] …`) captured by a console spy. */ -function taggedCalls(log: { mock: { calls: unknown[][] } }): unknown[][] { - return log.mock.calls.filter(call => typeof call[0] === 'string' && /^\[cordis:dyn-\d+\]$/.test(call[0])) -} - -/** Model-facing text of one tool result, concatenated. */ -function resultText(result: { content: { type: string; text?: string }[] }): string { - return result.content.filter(block => block.type === 'text').map(block => block.text).join('') -} - -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => { - it('mounts a temporary status listener whose tagged output actually fires, then unmounts it', async () => { - ctx = await cordisHarness() - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Use cordis_mount to create a temporary Plugin that listens to the \'agent/status\' ' - + 'Cordis event and logs every change with console.log. Reply "running" once done.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // The WORLD check: the turn's own running→idle transition must have driven - // the mounted listener through the tagged sandbox console. - expect(taggedCalls(log).length).toBeGreaterThan(0) - const mid = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(mid)).toContain('dyn-') - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Now unmount the temporary Plugin you just mounted.' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - const after = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(after)).toContain('No temporary Plugins are running.') - }, 120_000) - - it('builds itself a reverse_text tool and actually calls it', async () => { - ctx = await cordisHarness() - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Give yourself a new tool: use cordis_mount to create a temporary Plugin with ' - + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' - + 'to register a tool named reverse_text with one required string parameter ' - + '"text", returning the text reversed. Then CALL reverse_text with the ' - + 'exact text "harness" and report its exact output.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // World checks: the tool exists in the registry, was invoked as a real tool call, and its - // RESULT (the self-made execute actually running) is the reversed string. Model prose is only - // self-report and is deliberately not asserted. - expect(ctx.tools.get('reverse_text')).toBeDefined() - const events = [...agent.session.events] - const calls = events.filter(event => event.type === 'tool/call') - expect(calls.some(event => event.data.name === 'cordis_mount')).toBe(true) - const reverseCalls = calls.filter(event => event.data.name === 'reverse_text') - expect(reverseCalls.length).toBeGreaterThan(0) - const reverseResults = events - .filter(event => event.type === 'tool/result') - .filter(event => reverseCalls.some(call => call.data.callId === event.data.message.source.callId)) - .flatMap(event => event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text)) - // On failure, surface what the model actually mounted and what the tool - // returned — an e2e failing at a distance is undebuggable without it. - const mountCode = calls - .filter(event => event.data.name === 'cordis_mount') - .map(event => event.data.arguments) - .join('\n---\n') - const trace = events.map((event) => { - switch (event.type) { - case 'tool/call': return `tool/call:${event.data.name}` - case 'tool/result': return `tool/result:${event.data.message.content[0].isError ? 'ERR:' + JSON.stringify(event.data.message.content[0].content).slice(0, 200) : 'ok'}` - case 'turn/end': return `turn/end:${JSON.stringify(event.data.reason)}` - default: return event.type - } - }).join('\n') - expect( - reverseResults.some(text => text.includes('ssenrah')), - `no reversed output in reverse_text results.\nresults: ${JSON.stringify(reverseResults)}\nmount code: ${mountCode}\ntrace:\n${trace}`, - ).toBe(true) - }, 120_000) - - it('composes two temporary Plugins through provide/inject, and unmounting the provider parks the consumer', async () => { - ctx = await cordisHarness() - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Mount TWO separate temporary Plugins with cordis_mount. First a provider: apply calls ' - + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' - + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) ' - + 'a tool named shout_text with one required string parameter "text" whose execute returns ' - + 'ctx.shouter.shout(args.text) as a text content block. Then CALL shout_text with "quiet" ' - + 'and report the exact output.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // World checks: the service is really in the store, the tool really ran. - expect(ctx.get('shouter')).toBeDefined() - expect(ctx.tools.get('shout_text')).toBeDefined() - const events = [...agent.session.events] - const shoutCalls = events - .filter(event => event.type === 'tool/call') - .filter(event => event.data.name === 'shout_text') - expect(shoutCalls.length).toBeGreaterThan(0) - const shoutResults = events - .filter(event => event.type === 'tool/result') - .filter(event => shoutCalls.some(call => call.data.callId === event.data.message.source.callId)) - .flatMap(event => event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text)) - expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Now unmount ONLY the provider temporary Plugin (the one that provided shouter).' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // The consumer must have been parked by cordis itself: service gone, - // dependent tool unregistered, temporary section naming the missing service. - expect(ctx.get('shouter')).toBeUndefined() - expect(ctx.tools.get('shout_text')).toBeUndefined() - const after = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(after)).toContain('waiting for: shouter') - }, 120_000) -}) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts deleted file mode 100644 index 2e12cd76c3..0000000000 --- a/examples/cordis-agent/tests/harness.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' - -/** - * Shared harness for the cordis-agent e2e suite: the agent spine with the real - * DeepSeek adapter and the real `@deepseek-ai/dsh-tool-cordis` plugin, so a - * live model can mount plugins into the very context the test observes. Lives - * outside the *.e2e.ts pattern so importing it never re-registers another - * file's tests. - */ - -const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' - + 'Your cordis_* tools operate on the live cordis runtime you run inside: ' - + 'cordis_inspect to look around, cordis_mount to mount a temporary Plugin, cordis_unmount ' - + 'to unmount one. Follow the tool descriptions exactly and report results briefly.' - -export async function cordisHarness(): Promise { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx, { - systemPrompt: { persona: PERSONA }, - }) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek) - await ctx.plugin(ToolCordis) - return ctx -} - -export function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { - dispose() - resolve() - } - }) - }) -} diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts deleted file mode 100644 index c340eea036..0000000000 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' -import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts' - -const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -describe('cordis-agent keyless smoke (real Loader tree in a PTY)', () => { - it('boots the full tool-cordis tree and exits cleanly through the TUI', async () => { - const output = await runTuiPtySmoke({ - label: 'cordis-agent', - tempDirPrefix: 'cordis-agent-smoke-', - binScript, - configPath, - tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, - actions: [{ waitFor: 'cordis-agent ready.', send: '/exit\r' }], - }) - expect(output).toContain('cordis-agent ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml index fb614a5372..554fb620fd 100644 --- a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml +++ b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml @@ -16,11 +16,6 @@ - id: replay name: '@deepseek-ai/dsh-llm-replay' -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - # The confining filesystem stack: the wide deployment default lives on the # shared policy home; the seeded parent's read-only override must beat it # INSIDE the child for the scenario to deny. @@ -39,6 +34,9 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +# This scenario probes filesystem confinement only, so the bash stack is absent: +# without it the bundle's `toolBash: false` is required, because `tool-bash` would +# otherwise wait forever for a `bash` executor this tree never mounts. - id: agent name: '@deepseek-ai/dsh-agent-spine-demo' config: @@ -46,6 +44,7 @@ workspaceContext: false skills: enabled: false + toolBash: false toolTasks: false goals: false diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index 34e23b828e..d9cc454bfb 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -8,6 +8,9 @@ - id: telemetry-redact-rule name: './telemetry-redact-rule.ts' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/package.json b/examples/package.json index d98371a5ef..d0f7d4143c 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,13 +7,19 @@ "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-command-goal": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", @@ -23,6 +29,7 @@ "@deepseek-ai/dsh-goal-session": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", @@ -32,30 +39,38 @@ "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-skill-local": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", @@ -65,19 +80,20 @@ "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-tool-skill": "workspace:*", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-tasks": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-tui-demo": "workspace:*", + "@deepseek-ai/dsh-tui": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-user-interaction": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", - "@deepseek-ai/dsh-workflow-workerthread": "workspace:*" - }, - "devDependencies": { - "node-pty": "1.1.0" + "@deepseek-ai/dsh-workflow-workerthread": "workspace:*", + "@deepseek-ai/dsh-workspace-context": "workspace:*" } } diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml deleted file mode 100644 index 863092ffb8..0000000000 --- a/examples/tui-agent/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 examples/tui-agent/README.md -README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 -README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md deleted file mode 100644 index ea8695d37e..0000000000 --- a/examples/tui-agent/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# tui-agent - -English | [中文](README.zh.md) - -The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, plan mode (`/plan` enters and `exit_plan_mode` reviews the exit), timeout/spill policy, and JSONL persistence through [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC. - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:tui -``` - -Both the demo script and the installable `dsh` CLI ([`apps/cli`](../../apps/cli/README.md)) boot this example's `cordis.yml` as the shipped default config; `dsh` additionally applies the personal overlay from `~/.dsh` and uses the invoking directory as the workspace. - -Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork`. - -The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. - -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. - -### Resuming a prior session - -Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, pass its id to the installed `dsh` CLI — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: - -```sh -dsh --resume -``` - -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume ` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) for the execution contract. - -```sh -pnpm run demo:code-mode # this overlay under the TUI (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -Try a task that spans several tool calls, e.g.: - -> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. - -and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. - -## What each leaf entry demonstrates - -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the pi-tui channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: - -| Entry | Demonstrates | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access | -| `llm-deepseek` | the default native adapter | -| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | -| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | -| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | -| `plan-mode` | the plugin-owned `/plan [message]` entry and `/plan off` exit commands, plan-mode prompt policy, tool restrictions, and reviewed `exit_plan_mode` transition | -| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | - -## End-to-end tests (`pnpm run test:e2e`) - -The UI-independent with-key suites assemble the full stack programmatically through `tests/harness.ts` (no PTY, no Loader): - -- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. -- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. -- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. -- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. -- `tests/todo-write.e2e.ts` — loads the opt-in `todo_write` tool, then a real model drives it and the test verifies the resulting `todo/write` session event. -- `tests/code-mode.e2e.ts` — the with-key Code Mode proof: a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. - -These self-skip without `DEEPSEEK_API_KEY`. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real Loader tree in a PTY (the one sanctioned PTY surface): the base boot + `/plan` + `/exit`, a scripted-LLM conversation with a question dialog and tool round-trip, the Code Mode overlay welcome line, and the resume-failure exit path. - -## Snapshot tests - -`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable expected terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md deleted file mode 100644 index c6acd39d87..0000000000 --- a/examples/tui-agent/README.zh.md +++ /dev/null @@ -1,80 +0,0 @@ -# tui-agent - -[English](README.md) | 中文 - -全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与全新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合管道调用的单次任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:tui -``` - -演示脚本和可安装的 `dsh` CLI(命令行界面,见 [`apps/cli`](../../apps/cli/README.md))都会以此示例的 `cordis.yml` 作为已交付的默认配置启动;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为工作区。 - -输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次 bash 调用都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。文件系统工具和 bash 都会相对于会话工作区解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 - -`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 - -TUI 渲染 Markdown 历史、推理(reasoning)、工具自有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 - -### 恢复早先的会话 - -每次运行默认都会启动新会话(其事件日志落在 `./.sessions/` 下)。如需 **继续** 先前对话,请将其 id 传给已安装的 `dsh` CLI:此时 `main` agent 会重新水化持久日志,而不会从头开始,因此模型会将早先轮次视为历史: - -```sh -dsh --resume -``` - -`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久化目标阶段和实时/已持久化状态。已安装的 `dsh` 宿主会等待刷写完成,对当前应用执行 dispose(资源释放),然后以 `dsh --resume ` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume ` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 - -```sh -pnpm run demo:code-mode # this overlay under the TUI (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -尝试一项横跨多个工具调用的任务,例如: - -> 统计 docs/ 下每个 `*.md` 文件的行数,并将最大的三个写入 summary.txt。 - -然后观察 transcript(文本记录):一次 `run_code` 调用、一个循环调用工具的程序,以及模型筛选后的结果,而不是五次原始工具输出往返。 - -## 每个叶节点配置项所演示的内容 - -此示例是轻量叶节点 `cordis.yml`:它选择可替换后端、加载一个应用包(package),并添加有意放在共享主干外的产品工具。主干(会话、系统提示词、工具、agent、不变式、`agent-loop`)和入口集群(JSONL 持久化、pi-tui 通道、预创建的 `main` agent)位于 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 应用及其加载的 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 组合包中;叶节点负责接线后端与面向模型的可选工具: - -| 配置项 | 演示内容 | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | -| `llm-deepseek` | 默认原生适配器 | -| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 中可替换的实现侧。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | -| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | -| `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | -| `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent`、`subagent_fork`)公开 | -| `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 | -| `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 | -| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径相对于会话工作区解析 | - -## 端到端测试(`pnpm run test:e2e`) - -与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader): - -- `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。 -- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的说法。 -- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后 dispose 整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。 -- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,模型可见内容缩减(一个替换节点遮蔽了较旧节点),且 agent 在压缩后仍给出正确最终答案。 -- `tests/todo-write.e2e.ts`:加载选用的 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。 -- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言协议层工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。 - -这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM(大语言模型)对话,Code Mode 覆盖配置的欢迎行,以及恢复失败退出路径。 - -## 快照测试 - -`tests/snapshots//session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。对于仅涉及展示的变更,使用 `pnpm run test:snapshot:refresh`;已录制的模型流程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 规定了场景矩阵,以及已录制旅程、包级瞬态快照与 PTY 覆盖之间的分工。 diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml deleted file mode 100644 index 46d982794c..0000000000 --- a/examples/tui-agent/code-mode.cordis.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Code Mode keeps the TUI composition while adding the worker runtime and -# reducing the model-facing registry to the `run_code` transport. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - persistenceRoot: './.sessions' - resumeCommand: 'dsh --resume {session}' - workspaceContext: - maxBytes: 65536 - tools: - mode: code - welcome: 'TUI Code Mode ready. Give it a multi-tool task.' - ui: - showReasoning: true - maxToolOutputLines: 6 - persona: | - You are a coding agent powered by the {{model}} model. - - You work by writing TypeScript programs for run_code: batch related - tool work into one program, loop and branch where it helps, and print - or return ONLY the findings that matter. - - insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/tui-agent/package.json b/examples/tui-agent/package.json deleted file mode 100644 index f45e6746a3..0000000000 --- a/examples/tui-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "tui-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: the coding agent through the full-screen terminal UI" -} diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml deleted file mode 100644 index a32bdf987b..0000000000 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Real Loader composition for the keyless conversational PTY test. The app -# bundle supplies the production agent/TUI/user-question stack; only the model -# is scripted so the terminal interaction is deterministic and network-free. -- id: scripted-llm - name: './tui-scripted-llm.ts' - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: 'Stay in plan mode for this scripted TUI test.' - -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: tui-scripted - model: tui-scripted-model - persistenceRoot: './.sessions' - # The smoke's log inspection reads plain `.jsonl`; keep the scripted - # fixture uncompressed like the other snapshot-facing configs. - persistenceCompression: none - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - resumeCommand: 'dsh --resume {session}' - workspaceContext: - maxBytes: 65536 - welcome: 'scripted TUI ready.' - persona: 'Scripted model {{model}}.' - ui: - showReasoning: true - -# Model-made session titles, as in the shipped cordis.yml: the scripted adapter -# answers the tool-less title request with a fixed string so the PTY test can -# assert the logged title reaches the terminal window title. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 10000 diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml index 80b598c696..4cd96e396f 100644 --- a/examples/web-cordis/cordis.yml +++ b/examples/web-cordis/cordis.yml @@ -1,18 +1,19 @@ # Opt-in Web composition for inspecting the self-referential Cordis tools. # Temporary Plugin code can reach every injected live capability; treat this # deployment like shell access, not as a security boundary. -- id: base - name: '@cordisjs/plugin-include' +# This file is an OVERLAY over the shipped web composition (`base.cordis.yml` + +# `web.cordis.yml`), not a tree: `dsh web --config` applies it as one more +# sibling patch list at the same include level, so these patches reach base and +# overlay rows alike. A patch replaces the targeted row's whole `config`. + +# AppCLIEntry normally injects the assembly-owned dist path before `dsh web` +# boots; pinning the port here keeps this demo off the default 3080. +- id: webserver config: - path: ../../apps/cli/cordis.yml - patches: - # AppCLIEntry normally injects this assembly-owned path before `dsh web` - # boots; the standalone Cordis launcher needs the equivalent patch here. - - id: webserver - config: - host: 127.0.0.1 - port: 3081 - distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" - - insert: - - id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' + host: 127.0.0.1 + port: 3081 + distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" + +- insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/knip.json b/knip.json index 6585e1f628..f5379c8c6e 100644 --- a/knip.json +++ b/knip.json @@ -41,7 +41,6 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", - "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", @@ -478,15 +477,6 @@ "tests/**/*.ts" ] }, - "packages/examples/tui-demo": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/examples/cli-demo": { "entry": [ "tests/**/*.spec.ts", @@ -628,8 +618,15 @@ ] }, "apps/cli": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts", + "tests/fixtures/tui-scripted-llm.ts" + ], "project": [ - "src/**/*.ts" + "src/**/*.ts", + "tests/**/*.ts" ], "ignoreDependencies": [ "@deepseek-ai/.+", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 2b09179410..f3654cae3f 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. -2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). 5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 9158acd4ca..1588367646 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 06fd6b963639390164582e70476b6a80f189bc61 -README.zh.md: 41047d563f5541aaddc87bc7b448f678538ef91a +README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 +README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 06fd6b9636..fc466190a7 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,13 +10,15 @@ The view ring IS a slot: the conversation registration declares the `'conversati Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback. +Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). + Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 41047d563f..f6fbff9c1e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,6 +8,8 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 +已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 + 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 @@ -16,11 +18,11 @@ 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar 坑位本身为 `session-maybe`:没有当前会话时,同一个 bar 以惰性态渲染(machine face 缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此 textarea DOM 在选定 workspace 的切换中得以存活;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css new file mode 100644 index 0000000000..7ffdfca5bd --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -0,0 +1,29 @@ +/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */ + +.root { + min-width: 0; +} + +.root[data-open] { + padding-bottom: 4px; +} + +.chevron { + color: var(--dsw-alias-label-secondary); +} + +.body { + box-sizing: border-box; + width: calc(100% - 22px); + height: 141px; + margin: 4px 0 0 22px; + overflow: auto; + padding: 10px 16px 12px 12px; + border: none; + border-radius: 8px; + background: var(--dsw-alias-markdown-code-block); + color: var(--dsw-alias-label-tertiary); + font: 400 11px/16px var(--ds-font-family-code); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx new file mode 100644 index 0000000000..d21dba531b --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -0,0 +1,81 @@ +import { useMemo, useState } from 'react' +import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' +import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow } from './DisclosureRow.tsx' +import css from './ContextInjectionRow.module.css' + +const MAX_CHARS = 20_000 + +function inlineJson(payload: unknown): string { + const raw = JSON.stringify(payload) + let formatted = '' + let quoted = false + let escaped = false + + for (let index = 0; index < raw.length; index++) { + const char = raw.charAt(index) + if (quoted) { + formatted += char + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === '"') quoted = false + continue + } + if (char === '"') { + quoted = true + formatted += char + continue + } + if (char === '{' || char === '[') { + formatted += char + const close = char === '{' ? '}' : ']' + if (raw[index + 1] !== close) formatted += ' ' + continue + } + if (char === '}' || char === ']') { + const open = char === '}' ? '{' : '[' + if (raw[index - 1] !== open) formatted += ' ' + formatted += char + continue + } + formatted += char === ':' || char === ',' ? `${char} ` : char + } + return formatted +} + +/** Props for the logged non-user message presentation. */ +export interface ContextInjectionRowProps { + content: ContextMessageNode['content'] + source: ContextMessageNode['source'] +} + +/** + * Render logged context with the Tool calls disclosure chrome from Figma. + * @param props - Durable content and source provenance. + * @returns A collapsed context row with a bounded JSON body. + */ +export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) { + const [open, setOpen] = useState(false) + const body = useMemo(() => { + if (!open) return '' + const text = inlineJson({ content, source }) + return text.length > MAX_CHARS + ? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符` + : text + }, [content, open, source]) + + return ( + } + chevronClassName={css.chevron} + title="上下文注入" + open={open} + expandable + expandOnRowClick + onToggle={() => { setOpen(value => !value) }} + > +
{body}
+
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css b/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css new file mode 100644 index 0000000000..04f2d18d0a --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css @@ -0,0 +1,69 @@ +/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */ + +.root { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; +} + +.row { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +.row[data-expandable] { + cursor: pointer; +} + +.leading { + position: relative; + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + padding: 0; + border: none; + background: none; + color: var(--dsw-alias-label-tertiary); +} + +button.leading { + cursor: pointer; +} + +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.row:hover .iconIdle { + opacity: 0; +} + +.row:hover .chevronHover { + opacity: 1; +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx new file mode 100644 index 0000000000..0b1e9ea1b0 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx @@ -0,0 +1,101 @@ +import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import clsx from 'clsx' +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './DisclosureRow.module.css' + +/** Shared 24px disclosure chrome for conversation flow rows. */ +export interface DisclosureRowProps { + icon: ReactNode + title: string + open: boolean + expandable: boolean + onToggle: () => void + /** Makes the complete title row the disclosure target. */ + expandOnRowClick?: boolean | undefined + /** Replaces the collapsed icon with a chevron while the row is hovered. */ + previewChevron?: boolean | undefined + collapsedContent?: ReactNode + children?: ReactNode + className?: string | undefined + rowClassName?: string | undefined + leadingClassName?: string | undefined + chevronClassName?: string | undefined + titleClassName?: string | undefined +} + +/** + * Render one disclosure header and its controlled expanded content. + * @param props - Visual content, controlled state, and interaction policy. + * @returns The disclosure row. + */ +export function DisclosureRow({ + icon, + title, + open, + expandable, + onToggle, + expandOnRowClick = false, + previewChevron = expandable, + collapsedContent, + children, + className, + rowClassName, + leadingClassName, + chevronClassName, + titleClassName, +}: DisclosureRowProps) { + const rowExpands = expandable && expandOnRowClick + const toggleFromLeading = (event: MouseEvent) => { + event.stopPropagation() + onToggle() + } + const toggleFromKeyboard = (event: KeyboardEvent) => { + if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + onToggle() + } + const collapsedLeading = previewChevron + ? ( + <> + {icon} + + + ) + : icon + const leading = open + ? + : collapsedLeading + + return ( +
+
+ {expandable && !rowExpands ? ( + + ) : ( + + {leading} + + )} + {title} + {!open && collapsedContent} +
+ {open && children} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a149d37337..aa56d35270 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' @@ -94,9 +95,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) } case 'context': return ( -
- -
+ ) default: return ( diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 08031f570c..e53b472c50 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -9,10 +9,6 @@ .row { position: relative; /* sweep-glare overlay anchor */ overflow: hidden; - display: flex; - align-items: center; - height: 24px; - min-width: 0; } /* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band — @@ -41,24 +37,8 @@ 90%, 100% { left: 100%; } } -/* Expand-on-row (Think / code): pointer only — no row fill hover. */ -.row[data-expandable] { - cursor: pointer; -} - .leading { - position: relative; /* .chevronHover overlay anchor */ - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - padding: 0; - border: none; - background: none; - color: var(--dsw-alias-label-tertiary); + flex-shrink: 0; } /* Cordis lifecycle tools retain their generic row mechanics while carrying a @@ -76,40 +56,8 @@ background: var(--dsw-alias-state-business-primary); } -button.leading { - cursor: pointer; -} - -/* Hover preview on expandable rows: the idle tool icon crossfades (100ms) - into a down chevron before the row is opened. The chevron overlays the - icon cell absolutely so both can stay mounted for the opacity transition. */ -.iconIdle { - display: inline-flex; - opacity: 1; - transition: opacity 100ms ease; -} - -.chevronHover { - position: absolute; - inset: 0; - margin: auto; - opacity: 0; - transition: opacity 100ms ease; -} - -.row:hover .iconIdle { - opacity: 0; -} - -.row:hover .chevronHover { - opacity: 1; -} - .title { - flex: none; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-secondary); + font-weight: 400; } .sep { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index fe0df556ce..365000ebb9 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -8,11 +8,11 @@ // component-local view state. File-tool summaries are path links that open // through the host; the row itself is not a details-panel control. -import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { useState, type MouseEvent, type ReactNode } from 'react' import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' +import { DisclosureRow } from './DisclosureRow.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -82,63 +82,27 @@ export function ToolRow({ // this substitution never shows. const text = body ?? '' const open = expanded && expandable - const rowExpands = expandable && expandOnRowClick const toggleExpand = () => { setExpanded(v => !v) } - const toggleFromLeading = (event: MouseEvent) => { - event.stopPropagation() - toggleExpand() - } - const toggleFromKeyboard = (event: KeyboardEvent) => { - if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return - event.preventDefault() - toggleExpand() - } const openFile = (event: MouseEvent) => { event.stopPropagation() if (filePath !== undefined) onOpenFile?.(filePath) } - // Expandable rows preview the toggle on hover: the tool icon yields to a - // down chevron (CSS swap on .row:hover); state dots still take precedence. - const collapsedIcon = expandable - ? ( - <> - {icon} - - - ) - : icon - const leading = open - ? - : leadingFor(state, collapsedIcon) return (
-
- {expandable && !rowExpands ? ( - - ) : ( - - {leading} - - )} - {title} - {!open && ( + {fileLink ? ( @@ -154,18 +118,18 @@ export function ToolRow({ )} )} -
- {/* The terminal presenter's description belongs ABOVE the card per the - render-intent contract, so an expanded terminal row keeps showing it - even though the collapsed summary is hidden while open. */} - {open && terminalBody?.description !== undefined && ( -
{terminalBody.description}
- )} - {open && (terminalBody !== null - ? - : variant === 'code' - ? - :
{text}
)} + > + {/* The terminal presenter's description belongs above the card per + the render-intent contract. */} + {terminalBody?.description !== undefined && ( +
{terminalBody.description}
+ )} + {terminalBody !== null + ? + : variant === 'code' + ? + :
{text}
} +
) } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 4c05c2cbca..51d0737ee7 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -5,9 +5,11 @@ flex: none; width: 100%; max-width: 776px; - /* Eat InputBar's 6px top padding and tuck the panel 2px under the card; - the later composer sibling paints its surface and shadow over this edge. */ - margin: 0 auto -10px; + /* Flex gap still applies after this item; subtract it together with the + design's overlap so the later composer paints over the queue edge. */ + margin: 0 auto calc( + 0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap) + ); padding: 2px 12px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 99b91f301d..88300f6086 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -162,14 +162,14 @@ export const queueDockEntry = { name: 'conversation-queue-dock', inject: ['slots', 'conversation', 'sessions'], /** - * Register the queue strip into the input dock (list entry, order 0). + * Register the queue strip as the terminal input-dock entry (order 20). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', - order: 0, + order: 20, inject: (sessionId: SessionId): QueueDockInjected => { const actx = ctx.sessions.scope(sessionId) if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index e240fea889..e48d25ab45 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,14 +127,15 @@ min-height: 0; } -/* Composer stack: dock strips above the input card (design §6 MIX order). - The stack owns the vertical rhythm: one gap here, entries carry no outer - margins — an entry that renders null costs nothing, so spacing stays - correct for any dock combination. */ +/* Composer context stack (Figma 9:937): standalone dock cards share one + rhythm; the terminal queue strip additionally tucks under the input card. */ .composerStack { + --dsh-composer-stack-gap: 6px; + --dsh-queue-composer-overlap: 5px; + display: flex; flex-direction: column; - gap: 8px; + gap: var(--dsh-composer-stack-gap); } /* Common seat for the composer chain (fallback + elected overlay siblings). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 716f3d9419..5d26aa4a5e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,9 +1,8 @@ -/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): - tip surface, 14px radius, status icons + secondary item labels. Column is - calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer - stack owns the gap. */ +/* Todo strip in the composer context stack (Figma 9:959): tip surface, + 14px radius, status icons + secondary item labels. */ .root { + box-sizing: border-box; flex: none; overflow: hidden; margin: 0 auto; @@ -21,13 +20,11 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -/* Compact scale (GoalBar reference): collapsed header totals the goal - strip's 38px (8+8 pad + 20 line + 2 border). */ .body { display: flex; flex-direction: column; gap: 8px; - padding: 8px 14px; + padding: 9px 15px; } .header { @@ -44,8 +41,8 @@ .title { flex: none; - font-size: 13px; - line-height: 20px; + font-size: 14px; + line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 22f5786ab1..1dd12a6564 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -135,10 +135,10 @@ export const todoDockEntry = { name: 'conversation-todo-dock', inject: ['slots', 'conversation'], /** - * Register the plan strip into the input dock (list entry, above the queue rows). + * Register the plan strip between the goal and queue entries (order 10). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock) }, } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index fc5a94af61..4104fa1da5 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -107,11 +107,48 @@ describe('MessageItem arms', () => { expect(view.queryByRole('button', { name: '复制' })).toBeNull() }) - it('context and unknown nodes render their JSON rows', () => { + it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { const ctxView = render( - , + , ) - expect(ctxView.getByText(/上下文注入/)).toBeTruthy() + const disclosure = ctxView.getByRole('button', { name: '上下文注入' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() + expect(ctxView.container.querySelector('svg')).not.toBeNull() + + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe( + '{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], ' + + '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }', + ) + + fireEvent.keyDown(disclosure, { key: ' ' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + }) + + it('context preserves the bounded JSON truncation contract', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '上下文注入' })) + expect(view.container.querySelector('[data-context-injection-body]')?.textContent) + .toMatch(/… 已截断,共 \d+ 字符$/) + }) + + it('unknown nodes retain the generic JSON row', () => { const unknownView = render( , ) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 8d0614d2e9..6175cd352e 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -197,9 +197,14 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('ships the session-scoped registrant plugin shape', () => { + it('registers as the terminal composer-context entry', () => { expect(queueDockEntry.name).toBe('conversation-queue-dock') expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions']) - expect(typeof queueDockEntry.apply).toBe('function') + const register = vi.fn() + queueDockEntry.apply({ slots: { register } } as never) + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }), + QueueDock, + ) }) }) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 8e5b6949c8..fe3c08da3f 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -92,12 +92,12 @@ describe('TodoDock', () => { expect(screen.queryByTestId('todo-panel')).toBeNull() }) - it('ships the registrant plugin shape (list entry above the queue rows)', () => { + it('registers between the goal and queue entries', () => { expect(todoDockEntry.name).toBe('conversation-todo-dock') expect(todoDockEntry.inject).toEqual(['slots', 'conversation']) const register = vi.fn() todoDockEntry.apply({ slots: { register } } as never) - expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock) }) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 666a6e472e..191dade4fa 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: fed4870f73277b22760417297d668853b8afb2db -README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca +README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2 +README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index fed4870f73..2c109ab1fb 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index cc607edc85..b81113c675 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 +Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 `/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index aaf832464e..261c4bfa0a 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,29 +1,24 @@ -/* GoalBar: the goal strip docked above the composer card. The dock's 44px - side padding and the bar's 752px cap match the todo strip's column - (TodoPanel.module.css), 24px inside the composer card's edges. The - negative bottom margin cancels the composer stack's 8px gap and tucks the - bar's square bottom edge 2px under the composer card's top edge (the - card, later in DOM order, paints over it). Surface matches the todo - strip: tip fill, l1 border — no bottom edge where it disappears under the - card. All states share one fixed 38px height so switching between them - never resizes the strip. */ +/* GoalBar: the first standalone card in the composer context stack (Figma + 9:939). Its 752px column matches Todo and the Queue panel. */ .dock { + box-sizing: border-box; + width: 100%; padding: 0 44px; } .bar { + box-sizing: border-box; display: flex; align-items: center; - gap: 6px; - box-sizing: border-box; + gap: 10px; + width: 100%; max-width: 752px; - height: 38px; - margin: 0 auto -10px; - padding: 0 14px; + height: 36px; + margin: 0 auto; + padding: 4px 5px 4px 12px; border: 1px solid var(--dsw-alias-border-l1); - border-bottom: none; - border-radius: 14px 14px 0 0; + border-radius: 14px; background: var(--dsw-specific-tip); } @@ -37,8 +32,8 @@ flex: none; font-size: 13px; line-height: 20px; - font-weight: 600; - color: var(--dsw-alias-label-primary); + font-weight: 500; + color: var(--dsw-alias-label-primary-dimmed); } .objective { @@ -47,7 +42,7 @@ overflow: hidden; font-size: 13px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-primary-dimmed); text-overflow: ellipsis; white-space: nowrap; } @@ -92,7 +87,7 @@ .actions { display: flex; align-items: center; - gap: 8px; + gap: 10px; flex: none; } @@ -100,11 +95,11 @@ display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; padding: 0; border: none; - border-radius: 6px; + border-radius: 999px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index fc1cf1e035..9ea8e7134a 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -59,7 +59,7 @@ export function apply(ctx: ClientContext): void { scope.effect(() => scope.slots.register({ name: 'conversation.input.dock', id: 'goal', - order: 1, + order: 0, inject: (sessionId): GoalBarActions => ({ onEdit: async (objective) => { const ref = refOf(sessionId) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 348d06d1a6..c560ba4b8c 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -92,7 +92,7 @@ describe('ui-goal browser plugin', () => { it('registers the GoalBar dock entry with the documented id and order', async () => { const b = bench() await b.fiber.await() - expect(b.entry()).toMatchObject({ id: 'goal', order: 1 }) + expect(b.entry()).toMatchObject({ id: 'goal', order: 0 }) expect(b.entry()?.inject).toBeTypeOf('function') }) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ca6b83dadb..1342fbd885 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -134,6 +134,15 @@ interface PreparedAgent { declare module 'cordis' { interface Context { agentLoop: AgentLoop + /** + * Launcher-owned exact session identities for configured agents, keyed by + * the agent's config `id` and set with `ctx.provide()` before any Loader + * entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher + * owns identity because only it knows whether the session already exists, + * while the `cordis.yml` row keeps the model route as ordinary patchable + * config. An entry with no matching key keeps its configured identity. + */ + configuredAgentIdentities?: ConfiguredAgentIdentities } interface Events { /** @@ -151,6 +160,53 @@ declare module 'cordis' { export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } +/** + * One launcher-selected session identity for a configured agent. `resume` + * distinguishes rehydrating existing persisted history from creating the + * session fresh under that exact id, which the two config keys express as + * `resumeSessionId` and `sessionId`. + */ +export interface LauncherAgentIdentity { + /** Exact session id to create fresh or resume. */ + id: SessionId + /** Resume existing persisted history instead of creating the session fresh. */ + resume: boolean +} + +/** Launcher-selected identities keyed by the configured agent's `id`. */ +export interface ConfiguredAgentIdentities extends Readonly> {} + +/** + * Context key a launcher sets before any Loader entry mounts + * (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix + * configured agents' session identities without a config key, so an overlay + * repointing the row's model route cannot drop them. + */ +export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities' + +/** + * Apply launcher-owned identities over the configured agents, replacing both + * identity keys for every entry the launcher named so a config-supplied + * identity can never survive alongside a launcher-supplied one. + * @param agents - the configured agent entries. + * @param identities - launcher identities keyed by configured agent `id`, or `undefined`. + * @returns the entries with launcher-owned identities applied. + */ +function applyLauncherIdentities( + agents: Config['agents'], + identities: ConfiguredAgentIdentities | undefined, +): Config['agents'] { + if (identities === undefined) return agents + return agents.map((agent) => { + const identity = identities[agent.id] + if (identity === undefined) return agent + const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent + return identity.resume + ? { ...rest, resumeSessionId: identity.id } + : { ...rest, sessionId: identity.id } + }) +} + /** Agent-loop plugin configuration. */ export interface Config { /** @@ -220,6 +276,7 @@ export class AgentLoop extends Service implements AgentFactory { super(ctx, 'agentLoop') this.config = { ...config, + agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)), maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), } validateConfiguredAgents(this.config.agents) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 7640820e81..c6459c7b39 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -36,6 +36,26 @@ async function makeCoreContext(): Promise { } describe('config-driven session id', () => { + it('applies launcher identities by configured id without changing unmatched entries', async () => { + const ctx = await makeCoreContext() + ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { + fresh: { id: SessionId('launcher-fresh'), resume: false }, + resumed: { id: SessionId('launcher-resumed'), resume: true }, + }) + await ctx.plugin(AgentLoop, { + agents: [ + { id: 'fresh', sessionId: SessionId('config-fresh'), model: 'mock' }, + { id: 'resumed', sessionId: SessionId('config-resumed'), model: 'mock' }, + { id: 'unchanged', sessionId: SessionId('config-unchanged'), model: 'mock' }, + ], + }) + expect(ctx.agents.get(SessionId('launcher-fresh'))?.session.id).toBe('launcher-fresh') + expect(ctx.agents.get(SessionId('launcher-resumed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-resumed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-unchanged'))?.session.id).toBe('config-unchanged') + await ctx.fiber.dispose() + }) + it('rejects an empty exact id before publishing an agent', async () => { const ctx = await makeCoreContext() await expect(ctx.plugin(AgentLoop, { diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index ceb4a2df7a..0b0ee3db7c 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md -README.md: c229cef22087ac290bf862d6b3e31fdb533858c4 -README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e +README.md: d3ad432e71036db0d21f059e52f5d32e58010c42 +README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60 diff --git a/packages/examples/README.md b/packages/examples/README.md index c229cef220..d3ad432e71 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -7,12 +7,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack | -| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | -`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` compose it with headless one-shot and ACP automation front doors, and own their boot bins. The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: its TUI and web surfaces are a shared `base.cordis.yml` plus one overlay each. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 208b9a1385..0218e60df2 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -2,17 +2,16 @@ [English](README.md) | 中文 -预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包(package);npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK 运行时](../../python/sdk-runtime/README.md) 是消费方;每个消费方都只包含可替换后端和一个组合包入口。 +预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 | 包 | npm 名称 | 角色 | |---|---|---| -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent(智能体)主干,打包为一个组合包插件,带后备会话标题和可选择启用的持久化目标栈 | -| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | 全屏终端应用组合包:主干 + 持久化目标 + `/goal` 命令 + JSONL 持久化 + `dsh-tui` + 预创建的 `main` agent;没有 bin,由 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动 | +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化服务器应用:主干 + 持久化目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` | -| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的运行时,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP 自动化服务器应用:主干 + 持久目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` | +| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的 runtime,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 | -`agent-spine-demo` 是共享组合包;`tui-demo`、`cli-demo` 和 `acp-demo` 分别将它与全屏终端、无头单次和 ACP 自动化前端入口组合。`cli-demo` 与 `acp-demo` 拥有各自的启动 bin;`tui-demo` 只交付组合包插件,产品 [`dsh`](../../apps/cli/README.md) CLI 是它的终端前端入口。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK 运行时会启动它。 +`agent-spine-demo` 是共享组合包;`cli-demo` 和 `acp-demo` 分别将它与无头单次和 ACP 自动化前端入口组合,并拥有各自的启动 bin。产品 [`dsh`](../../apps/cli/README.md) CLI 不使用组合包:其 TUI 与 web surface 都是一份共享的 `base.cordis.yml` 加各自一份 overlay。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK runtime 会启动它。 这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。 @@ -20,4 +19,4 @@ ## jsonrpc bin/exe 名称是历史遗留 -`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的运行时启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。 +`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的 runtime 启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index c6bd7944c7..ba1d18b7e7 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md -README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9 -README.zh.md: 4a7e7a1b7eced9317f94eeddf2442ded9a3e0e13 +README.md: 34d68b0791746c28528124853a4d8ea82b68138d +README.zh.md: 1b0a644595e35d8703d0600812268b0950b79182 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 6bbd99217b..34d68b0791 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -47,7 +47,7 @@ The spine is everything COMMON to every front door. The swappable and front-door - **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider. - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. +- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-cli-demo`](../cli-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 4a7e7a1b7e..1b0a644595 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -47,7 +47,7 @@ - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -- **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 +- **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 diff --git a/packages/examples/tui-demo/README.i18n.yaml b/packages/examples/tui-demo/README.i18n.yaml deleted file mode 100644 index a9815be3a3..0000000000 --- a/packages/examples/tui-demo/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/examples/tui-demo/README.md -README.md: 058ebe87af5f041bd19fbfb205a97753ccacf6b9 -README.zh.md: 43681e9c77cbd77459ec0539b69e0ab73274d2c1 diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md deleted file mode 100644 index 058ebe87af..0000000000 --- a/packages/examples/tui-demo/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# @deepseek-ai/dsh-tui-demo - -English | [中文](README.zh.md) - -The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config. - -Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback. - -## What it bakes in - -| Plugin | Why it is here | -|---|---| -| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent | -| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins | -| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack | -| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | -| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | -| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; model-facing query tools remain a leaf opt-in | -| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | -| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | -| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool | - -Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals. - -## Config - -| Key | Default | Routed to | -|---|---|---| -| `provider` | required | Configured `main` agent provider | -| `model` | required | Configured `main` agent model | -| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap | -| `persona` | — | System-prompt persona template | -| `toolOrder` | lexicographic | Explicit model-facing tool order | -| `tools` | owner default | Tool presentation mode | -| `dshHome` | owner default | Harness home used by bash and skills | -| `sessionTitle` | spine example limits | Fallback title word/byte limits | -| `skills` | owner defaults | Skill registry, local provider, and tool config | -| `toolBash` | owner defaults | Model-facing bash tool config | -| `toolTasks` | owner defaults | Background-task control-tool config, or `false` | -| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | -| `workspaceContext` | required | Workspace-instruction config, or `false` | -| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index | -| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | -| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | -| `welcome` | `ready.` | TUI subtitle | -| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff | -| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | -| `resumeSessionId` | — | Exact persisted session to resume | - -Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff. - -## Front door - -This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config ` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node. - -## Example leaf - -```yaml -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: bash - name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: - maxBytes: 65536 - welcome: 'Coding agent ready.' - ui: - showReasoning: true -``` - -## Model Experience - -### Interactive terminal turn - -#### What the model sees - -Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible. - -#### Token effect - -User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens. - -#### KV Cache effect - -Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token. - -### Human-question answer - -#### What the model sees - -`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only. - -#### Token effect - -Only the completed or failed tool result adds retained tokens. - -#### KV Cache effect - -Append-only; the answer follows the reusable request prefix. - -## Known Limitations and Deferred Work - -- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`. -- **One configured terminal session** — the transcript and editor bind to one exact session id. -- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition. -- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer. diff --git a/packages/examples/tui-demo/README.zh.md b/packages/examples/tui-demo/README.zh.md deleted file mode 100644 index 43681e9c77..0000000000 --- a/packages/examples/tui-demo/README.zh.md +++ /dev/null @@ -1,112 +0,0 @@ -# @deepseek-ai/dsh-tui-demo - -[English](README.md) | 中文 - -全屏终端应用组合包:一个 Cordis 插件,组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、持久化的同会话目标、人类命令注册表与 `/goal` 生产方、JSONL 持久化、键盘支持的用户交互、预创建的 `main` agent(智能体),以及 [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md)。一份 `cordis.yml` 将它作为一个 Cordis 配置项挂载;[`dsh`](../../../apps/cli/README.md) CLI(命令行界面)是启动此类配置的入口。 - -管道、脚本和其他非交互式运行应使用 [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md)。此组合包需要一对 TTY,不提供面向行的回退。 - -## 内置组件 - -| 插件 | 设置在此处的原因 | -|---|---| -| `@deepseek-ai/dsh-agent-spine-demo` | 共享服务、面向模型的工具,以及一个已配置的 `main` agent | -| `@deepseek-ai/dsh-commands` | 供 TUI 和命令插件消费、仅面向人类的命令发现与分发 | -| `@deepseek-ai/dsh-command-goal` | 直接在主干的持久化目标栈上提供 `/goal` 状态与变更 | -| `@deepseek-ai/dsh-session-persistence-jsonl` | 位于 `persistenceRoot` 下的持久会话日志 | -| `@deepseek-ai/dsh-session-checkpoint-policy` | 模型请求和顶层工具 effect 前的语义持久性屏障,以及已完成步骤的检查点 | -| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | TUI 消费的组合式精确/FTS 会话查询与有界 `@session` 快照;面向模型的查询工具仍由叶节点选用 | -| `@deepseek-ai/dsh-user-interaction` | 与提供方无关的人类问题服务 | -| `@deepseek-ai/dsh-tui` | 全屏 transcript(文本记录)、编辑器、工具卡片、计划与问题 overlay | -| `@deepseek-ai/dsh-tool-ask-user` | 面向模型的 `ask_user_question` 工具 | - -可替换的 LLM(大语言模型)、bash、文件系统和其他能力提供方仍留在叶节点配置中。`@cordisjs/plugin-hmr` 也仍是仅叶节点使用的开发条目,因为它需要 Loader 内部实现。 - -## 配置 - -| 键 | 默认值 | 路由目标 | -|---|---|---| -| `provider` | 必填 | 已配置 `main` agent 的提供方 | -| `model` | 必填 | 已配置 `main` agent 的模型 | -| `maxParallelToolCalls` | agent-loop 默认值 | 组合包内循环的并发上限 | -| `persona` | 无 | 系统提示词 persona 模板 | -| `toolOrder` | 字典序 | 显式的面向模型工具顺序 | -| `tools` | 拥有者默认值 | 工具呈现模式 | -| `dshHome` | 拥有者默认值 | bash 与 skill(技能)使用的 harness 主目录 | -| `sessionTitle` | 主干示例限制 | 后备标题词数/字节限制 | -| `skills` | 拥有者默认值 | skill 注册表、本地提供方和工具配置 | -| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置 | -| `toolTasks` | 拥有者默认值 | 后台任务控制工具配置,或 `false` | -| `goals` | 拥有者默认值 | 持久目标领域与模型工具配置;`false` 会移除目标栈与 `/goal` 生产方 | -| `workspaceContext` | 必填 | Workspace 指令配置,或 `false` | -| `persistenceRoot` | `./.sessions` | JSONL 持久化根目录,以及派生 `session-query.db` 索引的父目录 | -| `persistenceCompression` | `'zstd'` | JSONL 产物编码(`'zstd'` 或原始 `'none'`) | -| `sessionReferences` | 服务默认值 | 路由到 `dsh-session-reference` 的跨会话候选项与快照限制 | -| `welcome` | `ready.` | TUI 副标题 | -| `resumeCommand` | 无 | 退出和无宿主回退的命令模板;选择器本身使用会话查询与宿主移交 | -| `ui` | 拥有者默认值 | 推理(reasoning)、颜色、卡片高度等 TUI 呈现设置 | -| `resumeSessionId` | 无 | 要恢复的确切持久化会话 | - -新运行会创建 `main-session-` 会话 id,并将它同时传给 TUI 与已配置的 agent。恢复运行会将两个组件都绑定到 `resumeSessionId`。TUI 先于主干挂载,因此它可以渲染匹配的配置启动失败,而不会留下空白终端。应用为 `/resume` 组合持久化和会话查询;嵌入宿主还可以提供 `tuiResumeHost`,用于原地移交进程。 - -## 入口 - -此包(package)不交付 bin。[`dsh`](../../../apps/cli/README.md) CLI 是终端入口:裸 `dsh` 启动已交付的 `examples/tui-agent/cordis.yml`(它挂载此组合包),而 `dsh --config ` 启动另一个挂载此组合包的叶节点配置。它加载 cwd 下可选的 `.env`,驱动 Cordis Loader,并等待完整插件树。仓库安装了 Loader 的可选原生辅助程序,因此裸包说明符可以在纯 Node 下解析。 - -## 叶节点示例 - -```yaml -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: bash - name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: - maxBytes: 65536 - welcome: 'Coding agent ready.' - ui: - showReasoning: true -``` - -## 模型体验 - -### 交互式终端轮次 - -#### 模型看到的内容 - -每次非空、非命令的编辑器提交都会成为用户消息;运行中轮次内的提交成为 steering(中途引导)。斜杠命令输入和输出仍只面向人类,而已接受的 `/goal` 变更会追加领域拥有的模型可见状态。共享主干提供已配置的 persona、workspace 指令、skill 目录、目标控制和可见工具 schema。TUI 渲染本身对模型不可见。 - -#### Token 影响 - -用户、assistant 与工具历史按常规会话和压缩(compaction)规则增长。Header、卡片、计划、Markdown 样式和快捷键不增加 token。 - -#### KV Cache 影响 - -只要组合后的提示词、schema、路由和保留历史前缀保持稳定,就保持仅追加。组合方式变更与 compaction 可能从第一个变化的 token 起使复用失效。 - -### 人类问题答案 - -#### 模型看到的内容 - -`ask_user_question` 会保留工具调用,以及 `dsh-tool-ask-user` 定义的精简答案或稳定中断错误。问题 overlay 只在终端显示。 - -#### Token 影响 - -只有已完成或失败的工具结果会增加保留 token。 - -#### KV Cache 影响 - -仅追加;答案跟在可复用请求前缀之后。 - -## 已知限制与暂缓事项 - -- **只支持 TTY**:stdin 与 stdout 都必须是终端;自动化使用 `dsh-cli-demo`。 -- **一个已配置的终端会话**:transcript 与编辑器绑定到一个确切会话 id。 -- **应用集群固定不变**:JSONL 持久化与 ask-user 工具内置;不同策略需要另一种组合。 -- **批准机制独立存在**:此应用回答 `ctx.userInteraction`,而不是 `ctx.approval`;权限提示需要批准服务和回答方。 diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json deleted file mode 100644 index 50145e6c29..0000000000 --- a/packages/examples/tui-demo/package.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-tui-demo", - "description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-command-goal": "^0.0.1", - "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", - "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", - "@deepseek-ai/dsh-session-reference": "^0.0.1", - "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-tui": "^0.0.1", - "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", - "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" - } -} diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts deleted file mode 100644 index c60ba94b3c..0000000000 --- a/packages/examples/tui-demo/src/index.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) - * plus persisted goals, human commands, JSONL persistence, keyboard-backed - * user interaction, and one pre-created agent whose exact session identity the - * TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin - * intentionally exposes named exports only; a default export would hide its - * `Config` schema (see docs/postmortem/0001). - * @module @deepseek-ai/dsh-tui-demo - */ - -import type { Context } from 'cordis' -import { randomUUID } from 'node:crypto' -import { join } from 'node:path' -import z from 'schemastery' -import { SessionId } from '@deepseek-ai/dsh-session' -import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import CommandService from '@deepseek-ai/dsh-commands' -import * as commandGoal from '@deepseek-ai/dsh-command-goal' -import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' -import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' -import SessionPersistenceJsonl, { - JsonlCompressionSchema, - type JsonlCompression, -} from '@deepseek-ai/dsh-session-persistence-jsonl' -import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' -import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -import * as uiTui from '@deepseek-ai/dsh-tui' - -export const name = 'tui-demo' -const DEFAULT_PERSISTENCE_ROOT = './.sessions' - -// Each front door keeps a complete Loader contract so its deployment config is -// readable without a cross-package facade. -/* jscpd:ignore-start */ -/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent; a matching adapter must be registered. */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona forwarded to the system-prompt plugin. */ - persona?: string - /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ - toolOrder?: string[] - /** Tool-registry presentation config forwarded through agent-spine-demo. */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Fallback session-title limits forwarded through agent-spine-demo. */ - sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** Cross-session reference discovery and snapshot byte budgets. */ - sessionReferences?: SessionReferenceConfig - /** TUI transcript's optional first line; absent renders nothing on start. */ - welcome?: string - /** - * Shell command template the TUI prints on exit and lists under `/resume`, - * with `{session}` replaced by the live session id (forwarded to the front - * door). Set it to a command that resumes the session, e.g. - * `dsh --resume {session}`. - */ - resumeCommand?: string - /** Full-screen TUI presentation settings. */ - ui?: uiTui.TuiConfig - /** Skill registry, local-provider, and model-facing consumer config. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-spine-demo. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ - goals?: agentCore.GoalConfig | false - /** Persisted session id to resume instead of creating a fresh session. */ - resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} - -export const Config: z = z.object({ - provider: z.string().required(), - model: z.string().required(), - maxParallelToolCalls: z.number().step(1).min(1), - persona: z.string(), - // Absent means lexicographic order; schemastery's native array default is []. - toolOrder: z.array(z.string()).default(undefined as unknown as string[]), - tools: ToolRegistry.Config, - dshHome: z.string(), - sessionTitle: agentCore.SessionTitleConfigSchema, - persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), - persistenceCompression: JsonlCompressionSchema, - sessionReferences: SessionReferenceService.Config, - welcome: z.string(), - resumeCommand: z.string(), - ui: uiTui.TuiConfigSchema, - skills: agentCore.SkillConfigSchema, - toolBash: agentCore.ToolBashConfigSchema, - toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), - goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - resumeSessionId: z.string(), - workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), -}) -/* jscpd:ignore-end */ - -/** - * Compose the spine, TUI, JSONL persistence, and user-question tool around one - * exact fresh or resumed session identity. The TUI subscribes to startup - * failures before the spine creates the agent. - * @param ctx - context receiving the app's child plugins. - * @param config - validated app configuration. - */ -export function composeTuiApp(ctx: Context, config: Config): void { - const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId - const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) - const goals = config.goals ?? {} - const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT - ctx.plugin(CommandService) - if (goals !== false) ctx.plugin(commandGoal) - ctx.plugin(SessionPersistenceJsonl, { - root: persistenceRoot, - ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), - }) - ctx.plugin(sessionCheckpointPolicy) - ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }) - ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) - ctx.plugin(UserInteractionService) - ctx.plugin(uiTui.TuiPromptService) - ctx.plugin(uiTui, { - ...config.ui, - ...config.welcome === undefined ? {} : { welcome: config.welcome }, - ...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand }, - sessionId, - }) - ctx.plugin(agentCore, { - ...agentCore.pickSpineConfig(config), - goals, - agents: [{ - id: SessionId('main'), - provider: config.provider, - model: config.model, - cwd: process.cwd(), - ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, - }], - }) - ctx.plugin(toolAskUser) -} - -/** - * Compose the configured full-screen terminal app. - * @param ctx - context receiving the app's child plugins. - * @param config - validated app configuration. - */ -export function apply(ctx: Context, config: Config): void { - composeTuiApp(ctx, config) -} diff --git a/packages/examples/tui-demo/src/invariant.ts b/packages/examples/tui-demo/src/invariant.ts deleted file mode 100644 index 1bb55546bf..0000000000 --- a/packages/examples/tui-demo/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tui-demo`. - * @module @deepseek-ai/dsh-tui-demo/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-tui-demo' - -/** Cordis companion plugin name. */ -export const name = 'tui-demo-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this composition-only package delegates mutable state and event streams - * to the agent spine, persistence, and TUI packages that own their checks. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts deleted file mode 100644 index f647b3d9c6..0000000000 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { join } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' -import * as tuiAgent from '../src/index.ts' - -interface PluginCall { - readonly name: string - readonly config: unknown -} - -function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } { - const calls: PluginCall[] = [] - const ctx = { - plugin(plugin: { name?: string }, config?: unknown) { - calls.push({ name: plugin.name ?? '', config }) - }, - } as unknown as Context - return { ctx, calls } -} - -describe('dsh-tui-demo app', () => { - it('composes the TUI cluster around one fresh exact session identity', () => { - const { ctx, calls } = recordingContext() - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - maxParallelToolCalls: 3, - persona: 'test persona', - toolOrder: ['zulu', TOOL_ORDER_REST], - tools: { mode: 'code' }, - dshHome: '/tmp/dsh-home', - persistenceRoot: '/tmp/tui-sessions', - persistenceCompression: 'none', - sessionReferences: { - maxReferences: 2, - candidateLimit: 7, - maxReferenceBytes: 1234, - }, - welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', - ui: { theme: { color: false }, maxToolOutputLines: 3 }, - skills: { tool: { catalogDescriptionMaxLength: 8 } }, - toolBash: { enableRunInBackground: false }, - toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, - workspaceContext: false, - }) - - expect(calls.map(call => call.name)).toEqual([ - 'CommandService', - 'command-goal', - 'SessionPersistenceJsonl', - 'session-checkpoint-policy', - 'SessionQuerySqlite', - 'SessionReferenceService', - 'UserInteractionService', - 'TuiPromptService', - 'ui-tui', - 'agent-spine-demo', - 'tool-ask-user', - ]) - expect(calls[0]?.config).toBeUndefined() - expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') }) - expect(calls[5]?.config).toEqual({ - maxReferences: 2, - candidateLimit: 7, - maxReferenceBytes: 1234, - }) - const tuiConfig = calls[8]?.config as { sessionId: string } - expect(tuiConfig).toMatchObject({ - welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', - theme: { color: false }, - maxToolOutputLines: 3, - }) - expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[9]?.config as { - readonly agents: Array> - readonly goals: Record - readonly maxParallelToolCalls: number - readonly persona: string - readonly toolOrder: string[] - readonly tools: { mode: string } - } - expect(spineConfig).toMatchObject({ - maxParallelToolCalls: 3, - persona: 'test persona', - toolOrder: ['zulu', TOOL_ORDER_REST], - tools: { mode: 'code' }, - goals: {}, - }) - expect(spineConfig.agents[0]).toMatchObject({ - id: 'main', - provider: 'mock', - model: 'mock-model', - cwd: process.cwd(), - sessionId: tuiConfig.sessionId, - }) - }) - - it('resumes the configured session and applies runtime defaults', () => { - const { ctx, calls } = recordingContext() - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - resumeSessionId: 'persisted-session', - workspaceContext: false, - }) - - expect(calls[2]?.config).toEqual({ root: './.sessions' }) - expect(calls[5]?.config).toEqual({}) - // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[9]?.config as { agents: Array> }).agents[0]).toMatchObject({ - id: 'main', - resumeSessionId: 'persisted-session', - }) - }) - - it('normalizes an empty resume id and routes apply through the same composition', () => { - const { ctx, calls } = recordingContext() - tuiAgent.apply(ctx, { - provider: 'mock', - model: 'mock-model', - resumeSessionId: '', - goals: false, - workspaceContext: false, - }) - - const tuiConfig = calls[7]?.config as { sessionId: string } - expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[8]?.config as { agents: Array> }).agents[0]) - .toMatchObject({ sessionId: tuiConfig.sessionId }) - expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[8]?.config).toMatchObject({ goals: false }) - }) - - it('has the namespace-plugin export shape so the Loader keeps its schema', () => { - expect(tuiAgent.name).toBe('tui-demo') - expect(tuiAgent.Config).toBeDefined() - expect('default' in tuiAgent).toBe(false) - expect(typeof tuiAgent.apply).toBe('function') - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(tuiAgent) as Record - expect(unwrapped).toBe(tuiAgent) - expect(unwrapped.name).toBe('tui-demo') - expect(unwrapped.Config).toBeDefined() - }) -}) diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json deleted file mode 100644 index d26d5b7da6..0000000000 --- a/packages/examples/tui-demo/tsconfig.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session-query/session-query" - }, - { - "path": "../../session-query/session-query-sqlite" - }, - { - "path": "../../context/session-reference" - }, - { - "path": "../../ui/commands" - }, - { - "path": "../../goal/command-goal" - }, - { - "path": "../agent-spine-demo" - }, - { - "path": "../../context/workspace-context" - }, - { - "path": "../../ui/user-interaction" - }, - { - "path": "../../ui/tui" - }, - { - "path": "../../ui/tool-ask-user" - }, - { - "path": "../../session-persistence/session-checkpoint-policy" - }, - { - "path": "../../session-persistence/session-persistence-jsonl" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/examples/tui-demo/tsdown.config.ts b/packages/examples/tui-demo/tsdown.config.ts deleted file mode 100644 index 1033dc08df..0000000000 --- a/packages/examples/tui-demo/tsdown.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** - * tui-demo ships the plugin (`index`) and its invariant companion; the CLI - * front door is `dsh` (apps/cli), which mounts this bundle through its config. - * The root tsdown builds only `lib/types/index.js`, so this override adds the - * invariant entry. Declarations come from `tsc -b` (dts: false), matching - * every package. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index afc0e2a695..8c321a13b7 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: d44770f70be16c12f44b78155089e092a3e9bba0 -README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 +README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982 +README.zh.md: 46516d187321029ed739d8c071f246bc1755b125 diff --git a/packages/host/README.md b/packages/host/README.md index d44770f70b..0417a1b8ae 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. +The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. | Package | Role | ctx key | |---|---|---| diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 2b6878b08b..46516d1873 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 +dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 | 包 | 角色 | ctx 键 | |---|---|---| diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 764ec328f1..236cc5968f 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: d839563319350ed91eee769fb05b6a9ee2391265 -README.zh.md: 3a3021f1770f2629e8667aa33886712c5d5d002b +README.md: 8f9deb6add7d30bf1609cc7febcb1febafe392c1 +README.zh.md: 399d45208b6d3f4152c27556523b6944432bec66 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d839563319..8f9deb6add 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 3a3021f177..399d45208b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。 ## 契约层(`/api`) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4454afde3a..d807bd737a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 -README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 +README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 +README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 3183804052..23153881b8 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (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, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 2f88f64cc2..d7010e2941 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 800af854a1..85349962e5 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -1,6 +1,7 @@ /* Directory-browser dialog (figma 813-23126 family). The shared Modal renders * headless here — mask, card, Escape only — and this module owns the figma - * frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator), + * frame: 680×500 card (viewport-clamped; upsized from the figma 600×420), + * header (title + crumbs, l3 separator), * the one-or-two-column Miller content, and the bordered footer. */ /* Doubled class beats Modal's own .dialog regardless of stylesheet order. */ @@ -8,19 +9,26 @@ * columns scroll, so shrinking the height keeps Open/Cancel reachable * instead of clipping them below a fixed overlay. */ .dialog.dialog { - width: min(600px, 100%); - height: min(420px, calc(100dvh - 32px)); + width: min(680px, 100%); + height: min(500px, calc(100dvh - 32px)); padding: 0; gap: 0; } -/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ +/* Card-scope wrapper hosting the path editor's Escape and focus-leave + * observers; display:contents keeps header/content/footer as direct flex + * children of the Modal card. */ +.editorScope { + display: contents; +} + +/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */ .header { display: flex; flex-direction: column; gap: 8px; flex: none; - padding: 22px 14px 12px 24px; + padding: 16px 14px 8px 24px; border-bottom: 1px solid var(--dsw-alias-border-l3); } @@ -54,8 +62,12 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - gap: 20px; + /* 12px of row gap on each side of the divider; the left side reads wider + * by the column's trailing 8px scrollbar clearance, which is deliberate — + * the thumb needs that room, the right pane's rows do not. */ + gap: 12px; overflow-x: auto; + scrollbar-width: none; } .crumbTrail { @@ -126,28 +138,34 @@ color: var(--dsw-alias-label-primary); } -/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with - * the hairline divider centered between them; each column scrolls alone. */ +/* Miller content: symmetric 16px vertical padding so the divider clears the + * header and footer rules evenly; each column scrolls alone (column widths + * live at .column). */ .content { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; - padding: 16px 24px 0; + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ + padding: 16px 16px 16px 24px; } +/* Columns split the row evenly around the divider (a solo column takes the + * whole row); 256px is the floor below which the row scrolls (scrollbar + * hidden, the effect pins the child pane into view) instead of squeezing + * the panes. */ .column { display: flex; flex-direction: column; gap: 2px; - width: 256px; - flex: none; - overflow-y: auto; -} - -.columnWide { - width: 100%; flex: 1 1 0; + min-width: 256px; + overflow-y: auto; + /* The themed scrollbar occupies the column's edge (styled scrollbars are + * classic, gutter-taking ones); the extra clearance keeps the row pills + * clear of the thumb. */ + padding-right: 8px; } .divider { @@ -228,8 +246,8 @@ color: var(--dsw-alias-state-error-primary); } -/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed - * card leaves the figma 28px below the 36px buttons. */ +/* Footer: l3 separator on top, symmetric padding so the row sits vertically + * centered in the bar; New-folder and the show-hidden toggle pin left. */ .footerBar { display: flex; align-items: center; @@ -238,10 +256,42 @@ flex-wrap: wrap; gap: 8px; flex: none; - padding: 12px 24px 28px; + padding: 16px 24px; border-top: 1px solid var(--dsw-alias-border-l3); } +/* Show-hidden toggle: a subtle fixed-label text button left of the gap; + * the pressed state seats a check glyph after the label (Menu's selected + * vocabulary; trailing so the label never shifts) instead of flipping the + * wording. */ +.showHiddenToggle { + display: inline-flex; + align-items: center; + gap: 4px; + border: none; + background: transparent; + padding: 0; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + white-space: nowrap; +} + +.showHiddenToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.showHiddenToggle:disabled { + color: var(--dsw-alias-label-caption); + cursor: default; +} + +.showHiddenToggleActive { + color: var(--dsw-alias-label-primary); +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f348f1dd8d..859667d115 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -1,22 +1,30 @@ /** * The in-app workspace-directory browser (figma Harness 813-23126 family): a - * 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls + * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path * breadcrumb, and a click-to-edit path zone; below it a Miller view — one - * full-width level until a row is selected, then two 256px columns (level | - * selected folder's children) around a hairline divider. Selecting in the + * full-width level until a row is selected, then two columns splitting the + * row evenly (256px floor; level | selected folder's children) around a + * hairline divider. Navigations land selection-anchored: a crumb jump or a + * submitted path commits the target immediately, then re-selects it in its + * parent level once that level arrives, so stepping back keeps two panes + * away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation - * error surface. Hidden entries are host-flagged and filtered here (a - * show-hidden toggle is deferred work, client-side only). + * error surface. Hidden entries are host-flagged and hidden by default; the + * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when + * on) reveals them (client-side only). The path editor opens seeded with a + * trailing separator, and while the draft's directory part names a listed + * level, its final segment prefix-filters that level's rows (a dot-led + * prefix also reveals the hidden entries it names). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, + Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' @@ -59,17 +67,59 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } +/** + * The listing's platform separator, inferred from the home path the host + * stamped — never from typed text or entry paths, where a backslash is a + * legal POSIX name character. Still a heuristic at the last step: a POSIX + * home directory whose own name contains a backslash would misread. + * TODO: replace with a host-stamped `separator` field on the wire + * DirectoryListing so the platform fact travels verbatim (the trade-off is + * recorded in the directory-picker capability seam Agent Note). + */ +function separatorOf(listing: DirectoryListing): '\\' | '/' { + return listing.home.includes('\\') ? '\\' : '/' +} + +/** + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. The directory part compares + * exactly (it is the host's own path text, reached by seeding or erasing); + * only the name filter downstream is case-insensitive. + */ +function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { + if (draft === null) return null + const sep = separatorOf(listing) + const cut = draft.lastIndexOf(sep) + if (cut === -1) return null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null +} + /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { +function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void - wide: boolean + showHidden: boolean + filterPrefix: string | null + pathEditing: boolean }) { + const visible = entries.filter((entry) => { + // The selection is exempt from both filters: it anchors the two-pane + // view (crumbs and the child pane point at it), so neither the hidden + // filter after a dot-reveal pick nor a prefix miss may orphan it. + if (entry.path === selectedPath) return true + if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true + }) return ( -
- {entries.filter(entry => !entry.hidden).map((entry) => { +
+ {visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -80,6 +130,15 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { aria-current={selected || undefined} className={clsx(css.row, selected && css.rowSelected)} disabled={busy} + // While the path editor is open, keep focus in it: a focus + // steal on mousedown would blur the editor and (in engines + // where the blur lands before our guards) drop this click. + // Outside editing, rows keep native focus behavior. + onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} + // Editing-time focus parking happens after commit (the + // DirectoryBrowser refocus effect): a right-pane pick replaces + // this very column, so focusing the clicked node here would + // still fall to body. onClick={() => { onPick(entry) }} > {selected @@ -110,6 +169,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) + // Show-hidden toggle state (pure client-side filter, reset on each open). + const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) @@ -156,28 +217,86 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) - /** Replace the whole view with one freshly listed level (no selection). */ + /** + * Launch a follow-up listing under the CURRENT supersession seq: a newer + * intent aborts it like the leg it continues, and it supersedes nothing. + */ + const continueScan = useCallback((path: string): Promise => { + const controller = new AbortController() + scanController.current = controller + return listDirectory(path, controller.signal) + }, [listDirectory]) + + /** + * Replace the whole view with a freshly navigated level. The target level + * commits the moment it arrives (single wide level: the editor closes and + * loading ends on this first settlement, so an Enter-submitted navigation + * is never withdrawn waiting on anything further). Away from the display + * root — the same collapse the crumb header renders, so crumbs and pane + * shape never disagree — a parent leg then upgrades the landing in place: + * the target's ACTUAL parent-level entry re-selected (left pane = parent, + * right pane = the target), so a crumb jump reads as stepping back one + * pane. A failed parent leg, or a truncated parent window that lacks the + * target, leaves the committed single-pane landing — the upgrade must + * never orphan the selection it exists to anchor. + */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) setLoading(true) setError(null) - scan.then((next) => { + scan.then((target) => { if (seq !== requestSeq.current) return - setParent(next) + setParent(target) setSelected(null) setChild(null) setLoading(false) setPathDraft(null) + // Arity is label-independent: only the collapsed chain's depth decides. + if (displayCrumbs(target, '').length < 2) return + const parentCrumb = target.crumbs.at(-2) + /* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */ + if (parentCrumb === undefined) return + continueScan(parentCrumb.path).then((parentLevel) => { + if (seq !== requestSeq.current) return + // Windows resolves a typed path preserving its case; anchor on the + // parent level's actual entry so selection comparisons hold. + const sep = separatorOf(parentLevel) + const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) + const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) + if (match === undefined) return + setParent(parentLevel) + setSelected(match) + setChild(target) + }, () => { + // Swallows the parent-leg failure (its abort included): the + // committed single-pane landing stands, and nobody asked to see + // the parent level. + }) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) - }, [launchListing]) + }, [launchListing, continueScan]) + + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. + const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) + // A pick while the path editor is open adopts the (filtered) row and + // closes the editor — the draft served its purpose. Focus re-parks on + // the selection after commit (see the refocus effect below). + if (pathDraft !== null) refocusPick.current = true + setPathDraft(null) setSelected(entry) setChild(null) setLoading(true) @@ -193,8 +312,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // An unreadable selection cannot be the committing target while the // breadcrumb still names the level: fall back to the single pane. setSelected(null) + // Clearing the selection can unmount the very row the pick parked + // focus on (a dot-revealed hidden row re-hides); the refocus effect + // re-parks on the edit zone only if focus actually fell to body. + refocusEditZone.current = true }) - }, [launchListing]) + }, [launchListing, pathDraft]) + + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ + const cancelPathEdit = useCallback(() => { + // Cancel also withdraws a navigation the editor already launched: its + // late success must not jump to the cancelled path, so the pending + // request is superseded and the view leaves the loading state. + supersede() + setLoading(false) + setPathDraft(null) + setError(null) + // Editing may have superseded the selection's preview request; a + // selection with no preview would render a half-empty two-pane view, so + // cancel falls back to the single-pane level. + if (child === null) setSelected(null) + // With no level listed yet (the editor superseded the initial home + // listing), plain cancellation would leave a permanently blank picker: + // restart the home listing. + if (parent === null) navigate() + }, [supersede, child, parent, navigate]) /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { @@ -213,6 +355,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setSelected(null) setChild(null) setCreatingFolder(false) + setShowHidden(false) navigate() return } @@ -221,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setPathDraft(null) setFolderDraft(null) setCreateError(null) + // A close mid-flight (failed Enter, then Cancel) may leave refocus + // flags armed; retire them so a later render cannot consume them. + refocusPick.current = false + refocusEditZone.current = false }, [open, navigate, supersede]) /** The folder a create or Open acts on: the selection, else the listed level. */ @@ -285,6 +432,37 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. + useEffect(() => { + if (pathDraft !== null) return + if (refocusPick.current) { + refocusPick.current = false + refocusEditZone.current = false + const rowHost = millerRowRef.current + /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ + if (rowHost === null) return + const row = rowHost.querySelector('button[aria-current="true"]') + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return + } + if (refocusEditZone.current) { + refocusEditZone.current = false + // Re-park only when the close actually dropped focus to body; focus + // the user parked elsewhere (a surviving row) stays theirs. + if (document.activeElement !== document.body) return + const zone = editZoneRef.current + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ + if (zone === null) return + zone.focus() + } + }) if (!open) return null const twoPane = selected !== null @@ -310,149 +488,216 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, className={clsx(css.dialog)} headless > -
-

{t('browser.title')}

-
- {pathDraft === null - ? ( - <> - - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} - - {/* The empty zone right of the crumbs is the path-edit affordance. */} - + + ))} + + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+
+
+ {parent !== null && ( + )} -
-
-
-
- {parent !== null && ( - - )} - {twoPane && } - {twoPane && child !== null && ( - - )} -
- {loading &&
{t('browser.loading')}
} - {/* The backend bounds a level at its complete-result limit; say so + {twoPane && } + {twoPane && child !== null && ( + + )} +
+ {loading &&
{t('browser.loading')}
} + {/* The backend bounds a level at its complete-result limit; say so * whenever a visible pane was cut instead of letting the tail of a * huge directory go silently missing. */} - {(parent?.truncated === true || child?.truncated === true) && !loading + {(parent?.truncated === true || child?.truncated === true) && !loading &&
{t('browser.truncated')}
} - {error !== null &&
{error}
} -
-
- - - - + {error !== null &&
{error}
} +
+
+ + + + + +
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}